Compare commits
188
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77e34c2c02 | ||
|
|
f4ee409106 | ||
|
|
bfb608bea6 | ||
|
|
95a95fe7d2 | ||
|
|
1bdf2ae71b | ||
|
|
f9e7a2f320 | ||
|
|
988fac8522 | ||
|
|
d4832a6a38 | ||
|
|
f9c8736e5b | ||
|
|
a815dd33fa | ||
|
|
cc9c75a636 | ||
|
|
43179e03c0 | ||
|
|
693ac4ed64 | ||
|
|
f94d663ac4 | ||
|
|
d1a21bd42e | ||
|
|
7ee2d73010 | ||
|
|
682bde84fe | ||
|
|
16bdbe5f44 | ||
|
|
f43fba9fed | ||
|
|
d1745413fd | ||
|
|
1b7a8025c3 | ||
|
|
d92a87483e | ||
|
|
0bea626ed8 | ||
|
|
f453dd27f3 | ||
|
|
36546c2712 | ||
|
|
fbe3fc3e05 | ||
|
|
a77cebec43 | ||
|
|
8167f93705 | ||
|
|
52dc46072e | ||
|
|
c18ab4cce8 | ||
|
|
52b28e5b48 | ||
|
|
575fd82c59 | ||
|
|
ead3f5fd4f | ||
|
|
36a922be64 | ||
|
|
530a1c9591 | ||
|
|
353faa9da5 | ||
|
|
76fdbcfd70 | ||
|
|
5365e22fff | ||
|
|
1e3974fd88 | ||
|
|
72854d58b1 | ||
|
|
b63e0e726d | ||
|
|
fff3ba8d91 | ||
|
|
7c155e3693 | ||
|
|
d15d3b594c | ||
|
|
915b2ebe54 | ||
|
|
cf3634ee2b | ||
|
|
88b11856d3 | ||
|
|
aede6c8cb3 | ||
|
|
c1187f0f2f | ||
|
|
6ff473820c | ||
|
|
847a21cc0c | ||
|
|
8ebd97643d | ||
|
|
8aded16da9 | ||
|
|
a35c0b34f8 | ||
|
|
877cfad88a | ||
|
|
fb0b8deef7 | ||
|
|
1a710f071c | ||
|
|
89b1461431 | ||
|
|
ce72f7790e | ||
|
|
51f4d29ebb | ||
|
|
97a2dac96d | ||
|
|
a569361d43 | ||
|
|
35dfc8c051 | ||
|
|
4a1ece7ad0 | ||
|
|
41b6fb4f84 | ||
|
|
a15b247d1a | ||
|
|
2d486dd395 | ||
|
|
0fcb833c83 | ||
|
|
4507b2270a | ||
|
|
1cd3da5ac6 | ||
|
|
afe2be9304 | ||
|
|
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 | ||
|
|
2bfe21eaff | ||
|
|
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 | ||
|
|
87cd9e7b9d | ||
|
|
51a020bd22 | ||
|
|
34ff4d0629 |
@@ -25,7 +25,7 @@ otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
|
|||||||
|
|
||||||
## Checklist
|
## 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
|
- [ ] 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
|
- [ ] 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
|
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
VERSION: ${{ steps.metadata.outputs.version }}
|
VERSION: ${{ steps.metadata.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
gh workflow run release-android.yml \
|
gh workflow run release-android.yml \
|
||||||
--ref="android-v${VERSION}" \
|
--ref=main \
|
||||||
-f version="$VERSION"
|
-f version="$VERSION"
|
||||||
|
|
||||||
- name: Approval summary
|
- name: Approval summary
|
||||||
@@ -97,4 +97,4 @@ jobs:
|
|||||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "The release workflow was dispatched at that tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ on:
|
|||||||
- ".github/workflows/approve-release-android.yml"
|
- ".github/workflows/approve-release-android.yml"
|
||||||
- ".github/workflows/release-android.yml"
|
- ".github/workflows/release-android.yml"
|
||||||
|
|
||||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||||
|
# commit must finish its independent validation.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-android-${{ github.ref }}
|
group: ci-android-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
- name: Syntax check (plugin relay — canonical location)
|
||||||
run: |
|
run: |
|
||||||
|
python -m py_compile plugin/relay/config.py
|
||||||
python -m py_compile plugin/relay/server.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/terminal.py
|
||||||
python -m py_compile plugin/relay/channels/chat.py
|
python -m py_compile plugin/relay/channels/chat.py
|
||||||
@@ -103,4 +104,6 @@ jobs:
|
|||||||
plugin/tests/test_relay_security.py \
|
plugin/tests/test_relay_security.py \
|
||||||
plugin/tests/test_voice_routes.py \
|
plugin/tests/test_voice_routes.py \
|
||||||
plugin/tests/test_session_grants.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
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "legacy-pages-redirect/**"
|
- "legacy-pages-redirect/**"
|
||||||
|
- "website/public/privacy.html"
|
||||||
- ".github/workflows/legacy-docs-redirect.yml"
|
- ".github/workflows/legacy-docs-redirect.yml"
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "legacy-pages-redirect/**"
|
- "legacy-pages-redirect/**"
|
||||||
|
- "website/public/privacy.html"
|
||||||
- ".github/workflows/legacy-docs-redirect.yml"
|
- ".github/workflows/legacy-docs-redirect.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -34,10 +36,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source_file="legacy-pages-redirect/redirect.html"
|
source_file="legacy-pages-redirect/redirect.html"
|
||||||
|
privacy_file="website/public/privacy.html"
|
||||||
output_dir="legacy-pages-redirect/_site"
|
output_dir="legacy-pages-redirect/_site"
|
||||||
rm -rf "$output_dir"
|
rm -rf "$output_dir"
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
"$output_dir/guide/getting-started" \
|
"$output_dir/guide/getting-started" \
|
||||||
|
"$output_dir/privacy" \
|
||||||
"$output_dir/reference/relay-server" \
|
"$output_dir/reference/relay-server" \
|
||||||
"$output_dir/architecture"
|
"$output_dir/architecture"
|
||||||
for target in \
|
for target in \
|
||||||
@@ -50,8 +54,12 @@ jobs:
|
|||||||
architecture/connection-security.html; do
|
architecture/connection-security.html; do
|
||||||
cp "$source_file" "$output_dir/$target"
|
cp "$source_file" "$output_dir/$target"
|
||||||
done
|
done
|
||||||
|
cp "$privacy_file" "$output_dir/privacy.html"
|
||||||
|
cp "$privacy_file" "$output_dir/privacy/index.html"
|
||||||
touch "$output_dir/.nojekyll"
|
touch "$output_dir/.nojekyll"
|
||||||
test "$(find "$output_dir" -type f | wc -l)" -eq 8
|
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
|
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
|
||||||
echo "Full documentation content must not be deployed by this workflow." >&2
|
echo "Full documentation content must not be deployed by this workflow." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ jobs:
|
|||||||
- name: Validate release metadata and source compatibility
|
- name: Validate release metadata and source compatibility
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/check-version-tracks.py
|
python3 scripts/check-version-tracks.py
|
||||||
|
python3 scripts/check-privacy-policy.py --live
|
||||||
python3 scripts/check-android-locales.py
|
python3 scripts/check-android-locales.py
|
||||||
python3 scripts/check-android-collection-apis.py
|
python3 scripts/check-android-collection-apis.py
|
||||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
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.
|
# Triggered when an Android release tag (android-v*) is pushed.
|
||||||
# Validates the tag matches the app version in libs.versions.toml,
|
# Validates the tag matches the app version in libs.versions.toml,
|
||||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
# 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
|
name: Release Android
|
||||||
|
|
||||||
@@ -12,8 +12,9 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "android-v*"
|
- "android-v*"
|
||||||
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
||||||
# does not recursively start workflows. It explicitly dispatches this file
|
# does not recursively start workflows. It dispatches the current workflow
|
||||||
# at that tag instead. Manual tag pushes continue to use the push trigger.
|
# definition from main, while every job checks out the immutable tag. Manual
|
||||||
|
# tag pushes continue to use the push trigger.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -35,19 +36,24 @@ jobs:
|
|||||||
version_code: ${{ steps.version.outputs.version_code }}
|
version_code: ${{ steps.version.outputs.version_code }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
env:
|
env:
|
||||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||||
if [ "$GITHUB_REF" = "$REF_VERSION" ]; then
|
|
||||||
REF_VERSION="$DISPATCHED_VERSION"
|
REF_VERSION="$DISPATCHED_VERSION"
|
||||||
fi
|
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||||
if [ -n "$DISPATCHED_VERSION" ] && [ "$DISPATCHED_VERSION" != "$REF_VERSION" ]; then
|
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||||
echo "::error::Dispatched version $DISPATCHED_VERSION does not match ref version $REF_VERSION"
|
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||||
fi
|
fi
|
||||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
@@ -65,9 +71,26 @@ jobs:
|
|||||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||||
|
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Version validated: $TAG_VERSION"
|
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
|
- name: Require successful Play preflight for this exact release tree
|
||||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||||
env:
|
env:
|
||||||
@@ -92,6 +115,8 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
@@ -107,6 +132,7 @@ jobs:
|
|||||||
- name: Validate release metadata and Android API compatibility
|
- name: Validate release metadata and Android API compatibility
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/check-version-tracks.py
|
python3 scripts/check-version-tracks.py
|
||||||
|
python3 scripts/check-privacy-policy.py
|
||||||
python3 scripts/check-android-locales.py
|
python3 scripts/check-android-locales.py
|
||||||
python3 scripts/check-android-collection-apis.py
|
python3 scripts/check-android-collection-apis.py
|
||||||
|
|
||||||
@@ -127,6 +153,8 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Release CLI
|
name: Release Desktop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['cli-v*']
|
tags: ['desktop-v*']
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -36,13 +36,17 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
version="${GITHUB_REF_NAME#cli-v}"
|
version="${GITHUB_REF_NAME#desktop-v}"
|
||||||
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
npm run check:version-sync -- --expect "$version"
|
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
|
- name: Verify tagged commit belongs to main
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -52,7 +56,7 @@ jobs:
|
|||||||
git fetch origin main --no-tags
|
git fetch origin main --no-tags
|
||||||
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
||||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -234,9 +238,9 @@ jobs:
|
|||||||
# (the other publish-release steps only consume downloaded build artifacts).
|
# (the other publish-release steps only consume downloaded build artifacts).
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Extract CLI version
|
- name: Extract Desktop version
|
||||||
id: 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
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
@@ -253,7 +257,7 @@ jobs:
|
|||||||
|
|
||||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
# 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
|
- name: Render release notes
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
@@ -266,7 +270,7 @@ jobs:
|
|||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
|
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "plugin-v*"
|
- "server-v*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
name: Validate Plugin release
|
name: Validate Server release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
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
|
- name: Verify Server version sync and changelog
|
||||||
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
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:
|
env:
|
||||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
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:
|
test:
|
||||||
name: Test Plugin package
|
name: Test Plugin package
|
||||||
needs: validate
|
needs: validate
|
||||||
@@ -100,8 +117,8 @@ jobs:
|
|||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
|
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||||
tag_name: plugin-v${{ needs.validate.outputs.version }}
|
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
body_path: release_notes_rendered.md
|
body_path: release_notes_rendered.md
|
||||||
|
|||||||
@@ -5,26 +5,49 @@ coding agent (Claude Code, Codex, Cursor, etc.).
|
|||||||
|
|
||||||
## Read this first
|
## Read this first
|
||||||
|
|
||||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
This file is the provider-neutral canonical agent context. Read it before
|
||||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
|
||||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
|
||||||
then `docs/spec.md` and `docs/decisions.md`.
|
not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|
||||||
|
|
||||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.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)
|
- 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)
|
## Non-negotiables (the short list)
|
||||||
|
|
||||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
|
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
|
||||||
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — must work
|
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
|
||||||
against unmodified upstream hermes-agent. Server-side needs go through upstream
|
and Vanilla Hermes voice. The API server is an optional automatic fallback and
|
||||||
PRs or the optional relay plugin, never fork patches.
|
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` /
|
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
|
||||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
|
`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),
|
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||||
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
|
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
|
||||||
|
|||||||
+87
-1
@@ -6,6 +6,93 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## [Android 1.5.2] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Dashboard sign-in completes across supported providers and network routes.** Self-hosted OIDC stays on the dashboard cookie flow, while Nous Portal opens in the system browser and completes standards-compatible PKCE through HTTPS, private-LAN, or Tailscale dashboard routes.
|
||||||
|
- **Replayed chat updates no longer destabilize the conversation list.** Duplicate upstream message identifiers are coalesced before Compose renders them.
|
||||||
|
|
||||||
|
## [Android 1.5.1] - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Voice supports focused and conversational layouts.** Focus keeps spoken turns, Markdown, tools, media, and actions in a compact voice surface, while Conversation opens the full Chat renderer without leaving the active voice session.
|
||||||
|
- **Voice can speak only settled answers.** A global Voice setting keeps tool progress, service updates, and intermediate commentary visual while supported voice paths wait to speak the final Hermes answer.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Chat answers are easier to read in every theme.** Primary assistant text now uses the theme's full-contrast foreground, and chat prose uses a 15sp size with 21sp line height.
|
||||||
|
- **Google Play builds target Android 16.** The app now targets API level 36 while retaining its existing minimum-device support.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Completed streamed answers render their formatting without losing the reading position.** Markdown headings, lists, emphasis, and code blocks replace the live text renderer only after completion, then the measured trailing edge remains anchored at the bottom.
|
||||||
|
- **Standard Voice speaks completed assistant replies again.** Session and message fences no longer suppress a valid final answer during the handoff from generation to narration.
|
||||||
|
- **Realtime background work no longer blocks the active voice controls.** A promoted task releases the foreground spinner and microphone while its progress, tools, cancellation, and final result remain available in the owning chat.
|
||||||
|
|
||||||
|
## [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-25
|
||||||
|
|
||||||
|
### 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 and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||||
|
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||||
|
- **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; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||||
|
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||||
|
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||||
|
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||||
|
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||||
|
|
||||||
|
## [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
|
### 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.
|
- **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.
|
||||||
@@ -13,7 +100,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
|||||||
### Fixed
|
### 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.
|
- **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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## [Android 1.4.6] - 2026-07-15
|
## [Android 1.4.6] - 2026-07-15
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## What This Is
|
||||||
|
|
||||||
@@ -183,18 +186,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
|||||||
### Git
|
### Git
|
||||||
|
|
||||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
- **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`.
|
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
|
||||||
- **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).
|
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
|
||||||
- **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."
|
a Claude-specific parallel policy here.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
- **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)
|
- **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
|
## 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.
|
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.
|
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.
|
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.
|
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`.
|
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
|
### Release Process
|
||||||
|
|
||||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
See [AGENTS.md](AGENTS.md) for the canonical branch contract and
|
||||||
|
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
|
||||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
hotfixes, secrets, publishing, and verification. Claude-specific automation
|
||||||
- **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`
|
must not infer release authority from feature completion.
|
||||||
- **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`
|
|
||||||
|
|
||||||
## Integration Points
|
## Integration Points
|
||||||
|
|
||||||
|
|||||||
+21
-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:`.
|
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
|
## Stale PR salvage and contributor credit
|
||||||
|
|
||||||
@@ -158,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||||
|
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||||
|
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||||
|
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||||
|
to catch common hosted failures without waiting for another full Actions
|
||||||
|
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||||
|
|
||||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||||
|
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||||
|
runs are never canceled because each release-branch commit must complete its
|
||||||
|
independent validation.
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,263 @@
|
|||||||
# Hermes-Relay — Dev Log
|
# Hermes-Relay — Dev Log
|
||||||
|
|
||||||
|
## 2026-07-28 — Android 1.5.2 production release
|
||||||
|
|
||||||
|
Android 1.5.2 shipped from the approved `dev` to `main` release tree as
|
||||||
|
versionCode 35. The release adds provider-aware Dashboard sign-in: Nous uses
|
||||||
|
the advertised native PKCE system-browser flow, while compatible self-hosted
|
||||||
|
providers retain cookie-backed full-page Dashboard authentication. Callback
|
||||||
|
origin discovery remains server-driven, private-network HTTP compatibility is
|
||||||
|
preserved, and arbitrary public HTTP redirects remain rejected.
|
||||||
|
|
||||||
|
The private Play preflight validated the exact application tree before release
|
||||||
|
PR #265 merged. The immutable `android-v1.5.2` tag resolves to the resulting
|
||||||
|
`main` tip, the production workflow promoted versionCode 35 to the completed
|
||||||
|
Google Play production track, and the public GitHub release contains the
|
||||||
|
signed AAB, sideload APK, and SHA-256 manifest. The published sideload APK
|
||||||
|
checksum was independently verified; replacing the debug-signed phone build
|
||||||
|
with the release-signed artifact requires an uninstall because Android
|
||||||
|
correctly rejects cross-signature in-place updates.
|
||||||
|
|
||||||
|
## 2026-07-27 — Android replayed-message identity reconciliation
|
||||||
|
|
||||||
|
Android history reconciliation now collapses reconnect/rejoin replays of the
|
||||||
|
same persisted message ID before publishing the transcript to Compose. The
|
||||||
|
latest repeated snapshot replaces the value at the message's first transcript
|
||||||
|
position, preserving stable ordering, distinct messages, and the LazyColumn
|
||||||
|
identity contract without index- or random-key fallbacks.
|
||||||
|
|
||||||
|
Focused coverage reproduces the duplicate UUID condition and verifies that the
|
||||||
|
authoritative final content wins while every rendered message keeps a unique
|
||||||
|
stable UI key.
|
||||||
|
|
||||||
|
## 2026-07-26 — Android 1.5.1 patch reconciliation
|
||||||
|
|
||||||
|
Android 1.5.1 reconciles the post-1.5.0 voice and chat fixes into versionCode
|
||||||
|
34. Voice now offers compact Focus and full Conversation presentation,
|
||||||
|
Standard narration preserves valid completed replies, and promoted Realtime
|
||||||
|
tasks release foreground voice controls while retaining progress and results.
|
||||||
|
|
||||||
|
Completed streamed answers promote from the stable live text node to full
|
||||||
|
Markdown only after completion. The measured Markdown row is then positioned
|
||||||
|
by its trailing edge until deferred code and attachment measurement settles,
|
||||||
|
preventing the LazyColumn from restoring the start of a tall response.
|
||||||
|
|
||||||
|
The release also targets Android API level 36. Release notes, in-app What's
|
||||||
|
New assets, localized Play notes, and the Play listing reference were updated
|
||||||
|
for Android 1.5.1.
|
||||||
|
|
||||||
|
## 2026-07-25 — Immutable Android release dispatch repair
|
||||||
|
|
||||||
|
Android approval now dispatches the current release workflow definition from
|
||||||
|
`main`, while every release job explicitly checks out the immutable
|
||||||
|
`android-v*` tag. Validation confirms the dispatched version resolves to that
|
||||||
|
checked-out commit and accepts the repository's surface-qualified
|
||||||
|
`[Android x.y.z]` changelog heading. Existing tags remain unchanged, and a
|
||||||
|
workflow-only correction can resume a failed publication without rebuilding
|
||||||
|
from a different application tree.
|
||||||
|
|
||||||
|
Audited `.github/workflows/approve-release-android.yml`,
|
||||||
|
`.github/workflows/release-android.yml`, `RELEASE.md`, and `DEVLOG.md`.
|
||||||
|
|
||||||
|
## 2026-07-25 — Android 1.5.0 final release reconciliation
|
||||||
|
|
||||||
|
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
|
||||||
|
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
|
||||||
|
voice, attachment, image-generation, security, localization, and Developer
|
||||||
|
Options work into one public release narrative. Android remains version 1.5.0
|
||||||
|
with monotonic versionCode 33 because that prepared version was not previously
|
||||||
|
tagged or uploaded to production.
|
||||||
|
|
||||||
|
Release notes, the in-app What's New assets, localized Play release notes, and
|
||||||
|
the Play listing reference now describe the final tree rather than the earlier
|
||||||
|
voice-focused candidate. The release train is gated by the exact-tree private
|
||||||
|
Play preflight before the `dev` to `main` release merge and public tag.
|
||||||
|
|
||||||
|
## 2026-07-25 — Active-turn retention and actionable interaction alerts
|
||||||
|
|
||||||
|
Android now promotes user-started chat work to foreground execution until every
|
||||||
|
connection/profile/session-scoped turn settles. Independent leases preserve
|
||||||
|
concurrent detached Gateway sessions, track sessions paused for input, and
|
||||||
|
prevent one completion from stopping protection for siblings. The existing
|
||||||
|
Persistent connection switch now extends retention only to idle periods.
|
||||||
|
|
||||||
|
The foreground notification reports active and waiting session counts.
|
||||||
|
Interaction alerts add privacy-safe expanded profile/session context and an
|
||||||
|
explicit review, answer, or secure-response action that deep-links to the exact
|
||||||
|
conversation; commands, questions, passwords, secrets, and environment-variable
|
||||||
|
names remain confined to the authenticated chat surface.
|
||||||
|
|
||||||
|
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
|
||||||
|
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
|
||||||
|
manifest, Android settings copy, chat user documentation, and Play foreground
|
||||||
|
service declaration guidance.
|
||||||
|
|
||||||
|
## 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-20 — Faster Android validation feedback
|
||||||
|
|
||||||
|
Android contributors now have one cross-platform pre-push command for locale,
|
||||||
|
documentation, collection-API, and version checks plus primary Play-variant
|
||||||
|
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
|
||||||
|
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
|
||||||
|
Android SDK without writing worktree-local configuration. Hosted CI retains
|
||||||
|
the exhaustive all-variant lint gate.
|
||||||
|
|
||||||
|
Android CI now cancels a superseded run on `dev` or a pull-request ref while
|
||||||
|
preserving every `main` run. A newer integration commit therefore stops paying
|
||||||
|
for an older release smoke that can no longer become the tested release tip.
|
||||||
|
|
||||||
|
## 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
|
## 2026-07-17 — Smooth streamed-reply rendering and finalization
|
||||||
|
|
||||||
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
|
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
|
||||||
@@ -194,6 +452,60 @@ Verification: seven profile-avatar endpoint tests and the focused Android host
|
|||||||
avatar client plus profile-controller suites passed. Android lint and final diff
|
avatar client plus profile-controller suites passed. Android lint and final diff
|
||||||
checks are recorded with the completed work.
|
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
|
## 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
|
Android 1.4.5 shipped as versionCode 28 after the signed release build, final
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
# Hermes-Relay-Plugin v__VERSION__
|
# 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
|
## What's changed
|
||||||
|
|
||||||
### Added
|
### 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
|
### 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
|
## 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*.
|
||||||
|
|||||||
@@ -54,52 +54,48 @@ Install → connect → talk, in about two minutes.
|
|||||||
|
|
||||||
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.
|
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://hermes-relay.dev/docs/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
|
```bash
|
||||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||||
|
hermes dashboard # start the standard Dashboard/Gateway surface
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`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**.
|
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)
|
||||||
> **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://hermes-relay.dev/docs/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://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
|
### 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.
|
The separate API server can be discovered automatically or added later under
|
||||||
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
|
||||||
- **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.
|
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:
|
The wizard probes everything and finishes with a capability card:
|
||||||
|
|
||||||
| Line | What it means |
|
| Line | What it means |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
| **Chat** | API server reachable — you can talk |
|
| **Chat** | Dashboard/Gateway ready — you can talk |
|
||||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
| **Manage** | Models, keys, skills, and profiles are available from the phone |
|
||||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||||
| **Remote** | Fallback route configured — keeps working away from home |
|
| **API fallback** | Optional API route available/unavailable |
|
||||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
| **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://hermes-relay.dev/docs/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
|
### 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).
|
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
|
## Screenshots
|
||||||
|
|
||||||
@@ -201,7 +197,7 @@ hermes-relay daemon start # background tool router — agen
|
|||||||
hermes-relay update # self-update via GitHub Releases
|
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.
|
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.
|
||||||
|
|
||||||
@@ -236,7 +232,7 @@ configure API, dashboard, and relay routes without merging their auth models.
|
|||||||
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces |
|
| [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 |
|
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
| [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>
|
<details>
|
||||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||||
|
|||||||
+130
-67
@@ -13,23 +13,24 @@ with optional prerelease identifiers.
|
|||||||
- `PATCH` — bug fixes, backwards compatible
|
- `PATCH` — bug fixes, backwards compatible
|
||||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||||
|
|
||||||
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
|
Hermes-Relay ships three independently versioned production surfaces. Public
|
||||||
Release titles use product names (`Hermes-Relay-Android`,
|
GitHub Release titles use product names (`Hermes-Relay-Android`,
|
||||||
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
|
`Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
|
||||||
for automation.
|
the corresponding build and deployment lane.
|
||||||
|
|
||||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
| 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-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-Server | `server-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-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
|
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
|
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
|
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
|
||||||
Android releases before this naming split used bare `v*` tags. Historical
|
Android releases before this naming split used bare `v*` tags. Historical
|
||||||
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
|
plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
|
||||||
`desktop-v*` tags. New releases use the explicit tag prefixes above.
|
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
|
||||||
|
new releases use the canonical prefixes above.
|
||||||
|
|
||||||
### Android app versioning
|
### Android app versioning
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ lockstep:
|
|||||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||||
|
|
||||||
Always bump Plugin releases via:
|
Always bump Server releases via:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/bump-plugin-version.sh 0.6.2
|
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
|
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
|
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
|
consistent. It deliberately does not require all three tracks to share the same
|
||||||
SemVer.
|
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
|
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.
|
artifacts.
|
||||||
|
|
||||||
### CLI / tray versioning
|
### 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
|
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,
|
a menu-only controller for the installed CLI; it has no application window,
|
||||||
WebView, embedded terminal, or separate desktop product surface. The public
|
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.
|
optional Windows installer.
|
||||||
|
|
||||||
| File | Purpose |
|
| 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
|
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
|
(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`,
|
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
|
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
|
||||||
`dev` so merged features get exercised against real data before they
|
an immutable release-candidate tag to staging. Record that source in the Forge
|
||||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
release issue/session. Never deploy a moving branch name as the source of record
|
||||||
see state that lives on `main` and on release tags.
|
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
|
### Branch names
|
||||||
|
|
||||||
@@ -211,23 +228,35 @@ version files and, for Android, on `appVersionCode` (which must be
|
|||||||
monotonic).
|
monotonic).
|
||||||
|
|
||||||
Version-bump commits live on `dev` as the last commit of release-prep
|
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
|
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||||
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
use `release(server): server-vX.Y.Z`; desktop commits use
|
||||||
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
|
`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` with `--no-ff`, and the matching tag is cut from the resulting
|
||||||
`main` tip.
|
`main` tip.
|
||||||
|
|
||||||
### Branch protection
|
### 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
|
The intended settings are:
|
||||||
here. PR must pass CI (Android + Plugin) before merge. Force push and
|
|
||||||
branch deletion blocked.
|
- **`main`** — PRs required; `Required checks` required and current; force push
|
||||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
and deletion blocked. Normal work does not target this branch.
|
||||||
branches PR in. PR must pass CI. Force push and branch deletion
|
- **`dev`** — PRs and `Required checks` required; force push and deletion
|
||||||
blocked.
|
blocked. This is the normal contribution target.
|
||||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
- **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
|
## 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
|
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||||
the unstable build.
|
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
|
## Release Process
|
||||||
|
|
||||||
### 1. Bump the Android app version
|
### 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
|
three* surfaces (Android + CLI + plugin), but releases are
|
||||||
per-surface. Move only the entries for the surface you're cutting into
|
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 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` /
|
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
|
||||||
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
|
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
|
||||||
historical record.)
|
historical record.)
|
||||||
@@ -572,8 +610,12 @@ git push origin dev
|
|||||||
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
||||||
`main`, and enter the version. Starting the workflow is the release approval. It
|
`main`, and enter the version. Starting the workflow is the release approval. It
|
||||||
verifies that `main` has the exact preflighted tree and creates the
|
verifies that `main` has the exact preflighted tree and creates the
|
||||||
`android-v<version>` tag. Manual stable tags are still guarded by the same
|
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
|
||||||
preflight proof in the tag workflow.
|
another workflow, approval dispatches the current release workflow definition
|
||||||
|
from `main`; every release job explicitly checks out and verifies the immutable
|
||||||
|
`android-v<version>` tag. This lets release-workflow fixes apply without moving
|
||||||
|
an existing tag or changing its artifact tree. Manual stable tags are still
|
||||||
|
guarded by the same preflight proof in the tag workflow.
|
||||||
|
|
||||||
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
artifacts, changes the existing Play Production draft to `completed` (submitting
|
||||||
@@ -585,14 +627,14 @@ publication.
|
|||||||
Plugin/Python version files are intentionally not part of an Android app
|
Plugin/Python version files are intentionally not part of an Android app
|
||||||
release unless the plugin package itself is also being released.
|
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
|
Use this when plugin or relay behavior changes independently of Android app
|
||||||
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
||||||
voice auth, dashboard plugin UI, or packaging changes.
|
voice auth, dashboard plugin UI, or packaging changes.
|
||||||
|
|
||||||
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
|
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
|
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
|
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.
|
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
|
||||||
@@ -603,31 +645,31 @@ git pull --ff-only origin dev
|
|||||||
|
|
||||||
bash scripts/bump-plugin-version.sh 0.6.2
|
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 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
|
git push origin dev
|
||||||
|
|
||||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||||
# After merge, tag from the new main tip:
|
# After merge, tag from the new main tip:
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull --ff-only origin main
|
git pull --ff-only origin main
|
||||||
git tag plugin-v0.6.2
|
git tag server-v0.6.2
|
||||||
git push origin plugin-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
|
validates all plugin-owned version metadata with
|
||||||
`scripts/check-plugin-version-sync.py`. Run
|
`scripts/check-plugin-version-sync.py`. Run
|
||||||
`python scripts/check-version-tracks.py` locally before tagging when a change
|
`python scripts/check-version-tracks.py` locally before tagging when a change
|
||||||
touches more than one release surface. The workflow also runs plugin tests,
|
touches more than one release surface. The workflow also runs plugin tests,
|
||||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
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
|
### CLI / Windows systray release
|
||||||
|
|
||||||
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
|
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
|
||||||
Android and plugin versions do not need to move with it.
|
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:
|
CLI/tray-relevant changelog bullets into the release block. Then:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -641,7 +683,7 @@ cd ..
|
|||||||
|
|
||||||
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
|
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
|
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
|
git push origin dev
|
||||||
|
|
||||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||||
@@ -651,8 +693,8 @@ git pull --ff-only origin main
|
|||||||
cd desktop
|
cd desktop
|
||||||
npm run check:version-sync -- --expect 0.4.0-alpha.2
|
npm run check:version-sync -- --expect 0.4.0-alpha.2
|
||||||
cd ..
|
cd ..
|
||||||
git tag cli-v0.4.0-alpha.2
|
git tag desktop-v0.4.0-alpha.2
|
||||||
git push origin cli-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
|
The tag workflow rejects version drift and tags whose commit is not in
|
||||||
@@ -768,7 +810,8 @@ plugin changes from forcing an Android app `versionCode` bump.
|
|||||||
|
|
||||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
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).
|
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||||
regression slice with explicit timeouts.
|
regression slice with explicit timeouts.
|
||||||
@@ -778,30 +821,35 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
|
|||||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||||
googlePlay AAB are attached (see §Release assets).
|
googlePlay AAB are attached (see §Release assets).
|
||||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
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
|
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.
|
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
|
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
|
||||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
|
||||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
|
||||||
|
|
||||||
On every push of a tag matching `plugin-v*`,
|
On every push of a tag matching `server-v*`,
|
||||||
`.github/workflows/release-plugin.yml`:
|
`.github/workflows/release-plugin.yml`:
|
||||||
|
|
||||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
1. Verifies the tag commit is contained in `main`, validates the tag against
|
||||||
`scripts/check-plugin-version-sync.py`.
|
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.
|
2. Runs plugin syntax checks and the focused route/auth/session test slice.
|
||||||
3. Builds the Python wheel and sdist with `python -m build`.
|
3. Builds the Python wheel and sdist with `python -m build`.
|
||||||
4. Generates `dist/SHA256SUMS.txt`.
|
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.
|
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
|
`.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`
|
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
|
(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.
|
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.
|
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||||
Dashboard-only changes are covered by
|
Dashboard-only changes are covered by
|
||||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||||
@@ -816,19 +864,28 @@ in the built bundle.
|
|||||||
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
||||||
| `HERMES_KEY_ALIAS` | Key alias | Alias 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 |
|
| `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
|
Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
|
||||||
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
|
the Production draft and the tag workflow promotes that exact version code to
|
||||||
tags only — prereleases are skipped). CI does the upload; you still click **Start
|
`completed`. The workflow does not fall back to manual upload or publish GitHub
|
||||||
rollout** in Play Console. If the secret is unset, the workflow skips the upload
|
first. With Play Managed Publishing off, an approved release publishes
|
||||||
and you upload manually (§5) — nothing else changes.
|
automatically; with it on, Play holds the approved change for an operator action
|
||||||
|
that the Developer API does not expose.
|
||||||
|
|
||||||
## Hotfix Recipe
|
## Hotfix Recipe
|
||||||
|
|
||||||
When production has a bug and you need to ship a fix without picking up
|
When production has a bug, use the same invariant for every surface:
|
||||||
unreleased work from `dev`, branch from the affected release tag and only
|
|
||||||
bump the version source for the surface you are shipping.
|
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:
|
For an Android app hotfix:
|
||||||
|
|
||||||
@@ -842,17 +899,23 @@ For an Android app hotfix:
|
|||||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
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`
|
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.
|
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`)
|
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,
|
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||||
bump collides.
|
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
|
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.
|
`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
|
## Troubleshooting
|
||||||
|
|
||||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||||
|
|||||||
+10
-12
@@ -1,10 +1,10 @@
|
|||||||
# Hermes-Relay-Android v1.4.6
|
# Hermes-Relay-Android v1.5.2
|
||||||
|
|
||||||
**Release Date:** July 15, 2026
|
**Release Date:** July 28, 2026
|
||||||
|
|
||||||
## Download
|
## 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.2-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.
|
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||||
|
|
||||||
@@ -12,19 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
|||||||
|
|
||||||
## Summary
|
## 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 patch restores reliable dashboard sign-in for self-hosted OIDC and Nous Portal connections, including private-LAN and Tailscale routes, and prevents replayed chat events from destabilizing the conversation list.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Fixed
|
## 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.
|
- Self-hosted OIDC returns through the dashboard cookie flow instead of a desktop-only loopback callback.
|
||||||
- Host image import distinguishes an outdated Relay from a genuinely missing avatar and presents **Choose file** as a reliable fallback.
|
- Nous Portal authentication opens in the system browser so provider security challenges can complete.
|
||||||
|
- Native PKCE uses standards-compatible unpadded Base64URL and preserves the dashboard's canonical HTTPS callback origin while keeping tokens scoped to the active route.
|
||||||
|
- Full-screen in-app sign-in remains available for compatible dashboard providers.
|
||||||
|
- Replayed upstream chat events are coalesced before rendering, preventing duplicate message keys.
|
||||||
|
|
||||||
## Install / Verify
|
## Install / Verify
|
||||||
|
|
||||||
- App version: **1.4.6** (versionCode **29**).
|
- App version: **1.5.2** (versionCode **35**).
|
||||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Verify Android native dashboard sign-in on device
|
||||||
|
|
||||||
|
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||||
|
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
|
||||||
|
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
|
||||||
|
untrusted callback noise, and closes on completion, cancellation, navigation,
|
||||||
|
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
|
||||||
|
prewarm, and standard voice; sign-out clears both cookie and native sessions.
|
||||||
|
Older gateways retain the identified WebView cookie fallback.
|
||||||
|
|
||||||
|
Before release, device-test the real Custom Tab → provider → loopback return,
|
||||||
|
configuration/background transitions, Manage reload, Gateway chat ticket,
|
||||||
|
standard voice, sign-out, and process relaunch. Native bearer exchange remains
|
||||||
|
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
|
||||||
|
using the native flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Active — Remove temporary GitHub Pages docs redirects
|
## Active — Remove temporary GitHub Pages docs redirects
|
||||||
|
|
||||||
PR #210 moved current source and production documentation to
|
PR #210 moved current source and production documentation to
|
||||||
@@ -36,6 +54,68 @@ 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.
|
||||||
|
- Keep persistent approval-mode writes for multiplexed non-launch profiles
|
||||||
|
read-only until upstream `config.get` / `config.set` bind an explicit
|
||||||
|
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
|
||||||
|
accepts `approvals.mode` but resolves it against the gateway process home;
|
||||||
|
Android may reconcile a selected profile's `session.info.approval_mode`, but
|
||||||
|
must not claim a profile-scoped write that upstream ignores.
|
||||||
|
- Keep gateway `model.options` profile scoping blocked until the supported
|
||||||
|
upstream RPC accepts an explicit `profile` and documents that the returned
|
||||||
|
provider inventory was built inside that profile's runtime scope. Android
|
||||||
|
now keys picker results to its active profile context and rejects late
|
||||||
|
responses after a profile switch, but it deliberately does not send an
|
||||||
|
invented `profile` parameter. API-server fallback can use the separate,
|
||||||
|
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
|
||||||
|
- 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)
|
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||||
|
|
||||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||||
@@ -637,9 +717,10 @@ Deferred:
|
|||||||
|
|
||||||
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
||||||
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||||
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
|
scaling down, list/paragraph unified to 15sp/21sp, primary assistant prose moved
|
||||||
|
to the theme's full-contrast `onSurface`, inline+fenced code 13sp, `textLink`
|
||||||
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
||||||
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
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
|
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||||
@@ -651,10 +732,6 @@ gated to pre-first-token. Deferred:
|
|||||||
parses one full CommonMark document so global link references, indentation, and
|
parses one full CommonMark document so global link references, indentation, and
|
||||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
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
|
|
||||||
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
|
|
||||||
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
||||||
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
||||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||||
@@ -1070,7 +1147,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).
|
- **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`.
|
- **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?
|
- `**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 — 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.
|
- **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").
|
- **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").
|
||||||
@@ -1121,6 +1198,7 @@ Follow-ups:
|
|||||||
|
|
||||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||||
|
|
||||||
|
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
|
||||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ android {
|
|||||||
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||||
applicationId = "com.axiomlabs.hermesrelay"
|
applicationId = "com.axiomlabs.hermesrelay"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
versionName = libs.versions.appVersionName.get()
|
||||||
|
|
||||||
@@ -245,6 +245,7 @@ dependencies {
|
|||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
implementation(libs.activity.compose)
|
implementation(libs.activity.compose)
|
||||||
|
implementation(libs.browser)
|
||||||
implementation(libs.appcompat)
|
implementation(libs.appcompat)
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
@@ -326,8 +327,8 @@ dependencies {
|
|||||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||||
// status bar, no clipping. See StoreScreenshotTest.
|
// status bar, no clipping. See StoreScreenshotTest.
|
||||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.68.0")
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
|
||||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
|
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
|
||||||
testImplementation(libs.compose.ui.test.junit4)
|
testImplementation(libs.compose.ui.test.junit4)
|
||||||
testImplementation(libs.compose.ui.test.manifest)
|
testImplementation(libs.compose.ui.test.manifest)
|
||||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
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
|
@Test
|
||||||
fun connectPage_showsStandardChoiceFirst() {
|
fun connectPage_showsNearbyFirst() {
|
||||||
setOnboardingContent()
|
setOnboardingContent()
|
||||||
navigateToPage(4)
|
navigateToPage(4)
|
||||||
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Vanilla Hermes")
|
.onNodeWithText("Enter address instead")
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun standardSetup_showsApiFields() {
|
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
|
||||||
setOnboardingContent()
|
setOnboardingContent()
|
||||||
navigateToPage(4)
|
navigateToPage(4)
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("API server URL")
|
.onNodeWithText("Hermes address")
|
||||||
.assertIsDisplayed()
|
|
||||||
composeTestRule
|
|
||||||
.onNodeWithText("API key")
|
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
fun manualSetup_findButton_isShown() {
|
||||||
setOnboardingContent()
|
setOnboardingContent()
|
||||||
navigateToPage(4)
|
navigateToPage(4)
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Connect")
|
.onNodeWithText("Find Hermes")
|
||||||
.assertIsEnabled()
|
.assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
|
|||||||
setOnboardingContent()
|
setOnboardingContent()
|
||||||
navigateToPage(4)
|
navigateToPage(4)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Other connection methods").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Pair Relay by code")
|
.onNodeWithText("Pair Relay by code")
|
||||||
.assertIsDisplayed()
|
.assertIsDisplayed()
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?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" />
|
||||||
|
<activity
|
||||||
|
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hermesandroid.relay.R
|
||||||
|
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||||
|
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||||
|
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||||
|
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-build-only live host for fast image-generation motion tuning.
|
||||||
|
*
|
||||||
|
* Launch directly:
|
||||||
|
* adb shell am start -n <applicationId>/
|
||||||
|
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||||
|
*/
|
||||||
|
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||||
|
setContent {
|
||||||
|
HermesRelayTheme(themePreference = themePreference) {
|
||||||
|
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||||
|
var restartKey by remember { mutableIntStateOf(0) }
|
||||||
|
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||||
|
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||||
|
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Image generation lab") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Live debug preview · no generation request",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||||
|
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||||
|
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||||
|
).forEach { (style, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = visualStyle == style,
|
||||||
|
onClick = { visualStyle = style },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key(restartKey, durationMillis, visualStyle) {
|
||||||
|
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||||
|
ImageGenerationResultTransition(
|
||||||
|
generating = !showResult,
|
||||||
|
startedAtMillis = startedAtMillis,
|
||||||
|
animationDurationMillis = durationMillis,
|
||||||
|
visualStyle = visualStyle,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||||
|
contentDescription = "Generated landscape preview",
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(16f / 9f),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Generated image",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "12.4s",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Cycle speed",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
7_200 to "Slow",
|
||||||
|
4_800 to "Normal",
|
||||||
|
3_200 to "Fast",
|
||||||
|
).forEach { (duration, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = durationMillis == duration,
|
||||||
|
onClick = { durationMillis = duration },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
showResult = true
|
||||||
|
},
|
||||||
|
enabled = !showResult,
|
||||||
|
) {
|
||||||
|
Text("Reveal result")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
showResult = false
|
||||||
|
restartKey++
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Restart")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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.
|
Dashboard sign-in now completes reliably for self-hosted OIDC and Nous Portal, including private-LAN and Tailscale routes. Nous opens securely in the system browser, while compatible providers retain full-screen in-app sign-in. Replayed chat updates no longer duplicate conversation rows.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
“服务器默认”现在会让代理、聊天、会话抽屉和记录保持在 Hermes 当前活动配置文件中。可按连接重新排序或隐藏配置文件,从手机选择图标,或从已更新且配对的 Relay 导入 avatar.png/profile.jpg。图像导入也会明确区分 Relay 版本过旧与头像文件缺失。
|
Hermes 仪表板登录现在可为自托管 OIDC 和 Nous Portal 可靠完成认证,并支持私有局域网与 Tailscale 路由。Nous 会在系统浏览器中安全打开,兼容的提供商仍可使用应用内全屏登录。重放的聊天更新不再产生重复会话行。
|
||||||
|
|||||||
@@ -90,12 +90,10 @@
|
|||||||
android:name=".notifications.ProactiveReplyReceiver"
|
android:name=".notifications.ProactiveReplyReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Opt-in "Persistent connection" — holds the user's connection to
|
<!-- Protects user-started active turns automatically; the optional
|
||||||
Hermes open while backgrounded so messages and live features stay
|
"Persistent connection" setting extends the same foreground
|
||||||
responsive (relay-paired setups also keep device control +
|
protection to idle/background connectivity (and relay-paired
|
||||||
notification mirroring reachable). In main so BOTH flavors ship it
|
device features). In main so BOTH flavors ship it. specialUse
|
||||||
(Home-Assistant-class persistent connection). Off by default; only
|
|
||||||
runs while the user has explicitly enabled the toggle. specialUse
|
|
||||||
needs a Play Console foreground-service declaration at submission. -->
|
needs a Play Console foreground-service declaration at submission. -->
|
||||||
<service
|
<service
|
||||||
android:name=".network.upstream.GatewayKeepAliveService"
|
android:name=".network.upstream.GatewayKeepAliveService"
|
||||||
@@ -103,7 +101,7 @@
|
|||||||
android:foregroundServiceType="specialUse">
|
android:foregroundServiceType="specialUse">
|
||||||
<property
|
<property
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
|
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -1,5 +1,144 @@
|
|||||||
{
|
{
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.5.2",
|
||||||
|
"title": "Sign in without detours",
|
||||||
|
"date": "2026-07-28",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"header": "Provider-compatible sign-in",
|
||||||
|
"bullets": [
|
||||||
|
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||||
|
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Stable conversation updates",
|
||||||
|
"bullets": [
|
||||||
|
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.5.1",
|
||||||
|
"title": "Voice and chat stay in place",
|
||||||
|
"date": "2026-07-26",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"header": "Voice at the right depth",
|
||||||
|
"bullets": [
|
||||||
|
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
|
||||||
|
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Reliable narration and background work",
|
||||||
|
"bullets": [
|
||||||
|
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||||
|
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Formatted answers stay readable",
|
||||||
|
"bullets": [
|
||||||
|
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
|
||||||
|
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.5.0",
|
||||||
|
"title": "Hermes, always in reach",
|
||||||
|
"date": "2026-07-25",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"header": "One secure Hermes connection",
|
||||||
|
"bullets": [
|
||||||
|
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||||
|
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Active work stays reachable",
|
||||||
|
"bullets": [
|
||||||
|
"Multiple user-started chats remain active in the background until every session settles.",
|
||||||
|
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Richer chat and voice",
|
||||||
|
"bullets": [
|
||||||
|
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||||
|
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Setup without surprises",
|
||||||
|
"bullets": [
|
||||||
|
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||||
|
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "1.4.6",
|
||||||
"title": "Profiles stay together",
|
"title": "Profiles stay together",
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
v1.4.6 - Profiles stay together
|
v1.5.2 - Sign in without detours
|
||||||
|
|
||||||
Profile continuity
|
* Complete self-hosted OIDC sign-in through the dashboard callback.
|
||||||
* Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile.
|
* Open Nous Portal securely in the system browser.
|
||||||
* Reorder or hide profiles per connection without changing the server.
|
* Sign in over private-LAN and Tailscale dashboard routes.
|
||||||
|
* Keep replayed chat updates from duplicating conversation rows.
|
||||||
Profile icons
|
|
||||||
* Choose an image file on your phone or import avatar.png/profile.jpg from an updated paired Relay.
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.hermesandroid.relay.bridge.BridgeForegroundService
|
|||||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||||
import com.hermesandroid.relay.data.BuildFlavor
|
import com.hermesandroid.relay.data.BuildFlavor
|
||||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||||
|
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
|
||||||
import com.hermesandroid.relay.ui.RelayApp
|
import com.hermesandroid.relay.ui.RelayApp
|
||||||
import com.hermesandroid.relay.util.NavRouteRequest
|
import com.hermesandroid.relay.util.NavRouteRequest
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// Returning to the app clears the one-slot "Hermes finished
|
// Returning to the app clears the one-slot "Hermes finished
|
||||||
// responding" notification — the chat surface is the answer.
|
// responding" notification — the chat surface is the answer.
|
||||||
TurnCompleteNotifier.cancel(this)
|
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
|
// v0.4.1 — register this activity as the host for
|
||||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||||
// we don't leak the Activity past its lifecycle. The unattended-
|
// we don't leak the Activity past its lifecycle. The unattended-
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ object AgentDisplay {
|
|||||||
fun effectiveDisplayProfile(
|
fun effectiveDisplayProfile(
|
||||||
selectedProfile: Profile?,
|
selectedProfile: Profile?,
|
||||||
profiles: List<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
|
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||||
// name first. The synthetic default profile uses its description only when
|
// name first. The synthetic default profile uses its description only when
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ data class ChatMessage(
|
|||||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||||
*/
|
*/
|
||||||
val uiKey: String = id,
|
val uiKey: String = id,
|
||||||
|
/**
|
||||||
|
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||||
|
* Unavailable advisors retain only neutral state, never their raw failure
|
||||||
|
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||||
|
* but server history never owns these presentation blocks.
|
||||||
|
*/
|
||||||
|
val moaReferences: List<MoaReference> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MoaReference(
|
||||||
|
val index: Int,
|
||||||
|
val count: Int?,
|
||||||
|
val label: String,
|
||||||
|
val text: String,
|
||||||
|
val available: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||||
@@ -392,6 +407,8 @@ data class ChatSession(
|
|||||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||||
*/
|
*/
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
/** Server reports a persisted session runtime/model binding. */
|
||||||
|
val hasModelConfig: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val activityTimestamp: Long
|
val activityTimestamp: Long
|
||||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||||
|
|||||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
|||||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||||
|
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||||
|
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatTurnMoaReferenceCheckpoint(
|
||||||
|
val index: Int,
|
||||||
|
val count: Int? = null,
|
||||||
|
val label: String,
|
||||||
|
val text: String = "",
|
||||||
|
val available: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -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 authProvider: String? = null,
|
||||||
val gatewayTicketAvailable: Boolean? = null,
|
val gatewayTicketAvailable: Boolean? = null,
|
||||||
val message: String? = 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.
|
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||||
*
|
*
|
||||||
* Each connection has its own:
|
* 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
|
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||||
* session token, device ID, API key, and paired-session metadata.
|
* session token, device ID, API key, and paired-session metadata.
|
||||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||||
@@ -73,17 +76,34 @@ data class Connection(
|
|||||||
val preferredRouteRole: String? = null,
|
val preferredRouteRole: String? = null,
|
||||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||||
val pairedAt: Long? = null,
|
val pairedAt: Long? = null,
|
||||||
|
/** Last time the user explicitly selected this connection. */
|
||||||
|
val lastUsedAt: Long? = null,
|
||||||
val lastActiveSessionId: String? = null,
|
val lastActiveSessionId: String? = null,
|
||||||
val transportHint: String? = null,
|
val transportHint: String? = null,
|
||||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||||
val expiresAt: Long? = null,
|
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
|
val resolvedDashboardUrl: String
|
||||||
get() = dashboardUrl
|
get() = dashboardUrl
|
||||||
?.trim()
|
?.trim()
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
?: 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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
* 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 LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||||
|
|
||||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
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
|
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||||
@@ -111,12 +133,40 @@ data class Connection(
|
|||||||
* user typed a malformed value — better to show something recognizable
|
* user typed a malformed value — better to show something recognizable
|
||||||
* than to crash).
|
* than to crash).
|
||||||
*/
|
*/
|
||||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
fun extractDefaultLabel(apiServerUrl: String): String =
|
||||||
return try {
|
extractHost(apiServerUrl) ?: apiServerUrl
|
||||||
URI(apiServerUrl).host ?: apiServerUrl
|
|
||||||
} catch (_: Exception) {
|
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
|
||||||
apiServerUrl
|
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(
|
fun deriveDefaultDashboardUrl(
|
||||||
@@ -141,6 +191,29 @@ data class Connection(
|
|||||||
return "$scheme://$hostPart:$dashboardPort"
|
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 {
|
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||||
if (trimmed.isEmpty()) return true
|
if (trimmed.isEmpty()) return true
|
||||||
@@ -150,7 +223,7 @@ data class Connection(
|
|||||||
|
|
||||||
fun deriveDefaultRelayUrl(
|
fun deriveDefaultRelayUrl(
|
||||||
apiServerUrl: String,
|
apiServerUrl: String,
|
||||||
relayPort: Int = 8767,
|
relayPort: Int = DEFAULT_RELAY_PORT,
|
||||||
): String? {
|
): String? {
|
||||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||||
if (trimmed.isEmpty()) return null
|
if (trimmed.isEmpty()) return null
|
||||||
@@ -174,6 +247,7 @@ data class Connection(
|
|||||||
apiServerUrl: String,
|
apiServerUrl: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||||
|
dashboardUrl: String? = null,
|
||||||
): List<EndpointCandidate> {
|
): List<EndpointCandidate> {
|
||||||
val routes = buildList {
|
val routes = buildList {
|
||||||
endpointCandidateFromApiUrl(
|
endpointCandidateFromApiUrl(
|
||||||
@@ -182,6 +256,7 @@ data class Connection(
|
|||||||
apiServerUrl = apiServerUrl,
|
apiServerUrl = apiServerUrl,
|
||||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||||
|
dashboardUrl = dashboardUrl,
|
||||||
)?.let(::add)
|
)?.let(::add)
|
||||||
|
|
||||||
extraApiUrls
|
extraApiUrls
|
||||||
@@ -193,13 +268,14 @@ data class Connection(
|
|||||||
priority = index + 1,
|
priority = index + 1,
|
||||||
apiServerUrl = url,
|
apiServerUrl = url,
|
||||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||||
|
dashboardUrl = dashboardUrl,
|
||||||
)?.let(::add)
|
)?.let(::add)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
.distinctBy {
|
.distinctBy {
|
||||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
"${it.role.lowercase()}|${it.routeAuthority()}"
|
||||||
}
|
}
|
||||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||||
}
|
}
|
||||||
@@ -222,13 +298,13 @@ data class Connection(
|
|||||||
existing: List<EndpointCandidate>,
|
existing: List<EndpointCandidate>,
|
||||||
): List<EndpointCandidate> {
|
): List<EndpointCandidate> {
|
||||||
val rebuiltHostPorts = rebuilt
|
val rebuiltHostPorts = rebuilt
|
||||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
.mapNotNull { it.mergeAuthority() }
|
||||||
.toSet()
|
.toSet()
|
||||||
val preserved = existing
|
val preserved = existing
|
||||||
.filter { it.priority > 0 }
|
.filter { it.priority > 0 }
|
||||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
|
||||||
return (rebuilt + preserved)
|
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 })
|
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +343,7 @@ data class Connection(
|
|||||||
priority: Int,
|
priority: Int,
|
||||||
apiServerUrl: String,
|
apiServerUrl: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
|
dashboardUrl: String? = null,
|
||||||
): EndpointCandidate? {
|
): EndpointCandidate? {
|
||||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||||
?: return null
|
?: return null
|
||||||
@@ -290,12 +367,141 @@ data class Connection(
|
|||||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||||
priority = priority,
|
priority = priority,
|
||||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
dashboard = dashboardUrl
|
||||||
|
?.trim()
|
||||||
|
?.trimEnd('/')
|
||||||
|
?.takeIf { it.isNotBlank() && urlsShareHost(it, apiServerUrl) }
|
||||||
|
?.let { DashboardEndpoint(url = it) }
|
||||||
|
?: deriveDefaultDashboardUrl(apiServerUrl)
|
||||||
?.let { DashboardEndpoint(url = it) },
|
?.let { DashboardEndpoint(url = it) },
|
||||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile stored API-derived routes with the Dashboard origin that
|
||||||
|
* was actually verified during setup. Older app versions synthesized
|
||||||
|
* `:9119` for every API route, even when the same host was reached
|
||||||
|
* through an HTTPS reverse proxy on 443. Replace only that conventional
|
||||||
|
* synthesized value (or a missing value); preserve explicit and
|
||||||
|
* different-host LAN/Tailscale routes.
|
||||||
|
*/
|
||||||
|
fun reconcileDashboardRoutes(
|
||||||
|
dashboardUrl: String?,
|
||||||
|
candidates: List<EndpointCandidate>,
|
||||||
|
): List<EndpointCandidate> {
|
||||||
|
val explicitDashboard = dashboardUrl
|
||||||
|
?.trim()
|
||||||
|
?.trimEnd('/')
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: return candidates
|
||||||
|
return candidates.map { candidate ->
|
||||||
|
val apiUrl = candidate.api?.url ?: return@map candidate
|
||||||
|
if (!urlsShareHost(explicitDashboard, apiUrl)) return@map candidate
|
||||||
|
|
||||||
|
val currentDashboard = candidate.dashboard?.url
|
||||||
|
val derivedDashboard = deriveDefaultDashboardUrl(apiUrl)
|
||||||
|
val canReplace = currentDashboard.isNullOrBlank() ||
|
||||||
|
(
|
||||||
|
derivedDashboard != null &&
|
||||||
|
currentDashboard.trim().trimEnd('/')
|
||||||
|
.equals(derivedDashboard, ignoreCase = true)
|
||||||
|
)
|
||||||
|
if (canReplace) {
|
||||||
|
candidate.copy(dashboard = DashboardEndpoint(url = explicitDashboard))
|
||||||
|
} else {
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun urlsShareHost(leftUrl: String, rightUrl: String): Boolean {
|
||||||
|
val leftHost = runCatching { URI(leftUrl.trim()) }.getOrNull()?.host
|
||||||
|
val rightHost = runCatching { URI(rightUrl.trim()) }.getOrNull()?.host
|
||||||
|
return !leftHost.isNullOrBlank() &&
|
||||||
|
!rightHost.isNullOrBlank() &&
|
||||||
|
leftHost.equals(rightHost, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
fun inferRouteRole(apiServerUrl: String): String {
|
||||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
|
|||||||
class ConnectionStore private constructor(
|
class ConnectionStore private constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
private val context: Context?,
|
private val context: Context?,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
|
|||||||
constructor(context: Context) : this(
|
constructor(context: Context) : this(
|
||||||
dataStore = context.relayDataStore,
|
dataStore = context.relayDataStore,
|
||||||
context = context.applicationContext,
|
context = context.applicationContext,
|
||||||
|
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
|
|||||||
internal constructor(dataStore: DataStore<Preferences>) : this(
|
internal constructor(dataStore: DataStore<Preferences>) : this(
|
||||||
dataStore = dataStore,
|
dataStore = dataStore,
|
||||||
context = null,
|
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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
|
|||||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
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
|
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||||
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
|
|||||||
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
||||||
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
||||||
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_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,
|
// 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
|
// 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)
|
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_connections.value = decodeConnections(oldJson)
|
val restored = decodeConnections(oldJson)
|
||||||
_activeConnectionId.value = activeOld
|
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||||
|
_connections.value = restored
|
||||||
|
_startupConnectionId.value = validStartupId
|
||||||
|
_activeConnectionId.value = validStartupId ?: activeOld
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG,
|
TAG,
|
||||||
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
_connections.value = decodeConnections(newJson)
|
val restored = decodeConnections(newJson)
|
||||||
_activeConnectionId.value = activeNew
|
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||||
|
_connections.value = restored
|
||||||
|
_startupConnectionId.value = validStartupId
|
||||||
|
_activeConnectionId.value = validStartupId ?: activeNew
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||||
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
|
|||||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||||
_activeConnectionId.value = null
|
_activeConnectionId.value = null
|
||||||
}
|
}
|
||||||
|
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
|
||||||
|
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||||
|
_startupConnectionId.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
removed?.let { deleteTokenStoresFor(it) }
|
removed?.let { deleteTokenStoresFor(it) }
|
||||||
}
|
}
|
||||||
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
|
|||||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||||
prefs.remove(KEY_CONNECTIONS)
|
prefs.remove(KEY_CONNECTIONS)
|
||||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||||
|
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||||
prefs.remove(KEY_LEGACY_PROFILES)
|
prefs.remove(KEY_LEGACY_PROFILES)
|
||||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||||
_connections.value = emptyList()
|
_connections.value = emptyList()
|
||||||
_activeConnectionId.value = null
|
_activeConnectionId.value = null
|
||||||
|
_startupConnectionId.value = null
|
||||||
}
|
}
|
||||||
removed.forEach { deleteTokenStoresFor(it) }
|
removed.forEach { deleteTokenStoresFor(it) }
|
||||||
}
|
}
|
||||||
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
|
|||||||
suspend fun replaceConnections(
|
suspend fun replaceConnections(
|
||||||
connections: List<Connection>,
|
connections: List<Connection>,
|
||||||
activeConnectionId: String? = null,
|
activeConnectionId: String? = null,
|
||||||
|
startupConnectionId: String? = null,
|
||||||
) {
|
) {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
var removed: List<Connection> = emptyList()
|
var removed: List<Connection> = emptyList()
|
||||||
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
|
|||||||
val normalizedActiveId = activeConnectionId
|
val normalizedActiveId = activeConnectionId
|
||||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||||
?: normalizedConnections.firstOrNull()?.id
|
?: normalizedConnections.firstOrNull()?.id
|
||||||
|
val normalizedStartupId = startupConnectionId
|
||||||
|
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||||
|
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||||
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
|
|||||||
}
|
}
|
||||||
prefs.remove(KEY_LEGACY_PROFILES)
|
prefs.remove(KEY_LEGACY_PROFILES)
|
||||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
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
|
_connections.value = normalizedConnections
|
||||||
_activeConnectionId.value = normalizedActiveId
|
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
|
||||||
|
_startupConnectionId.value = normalizedStartupId
|
||||||
}
|
}
|
||||||
removed.forEach { deleteTokenStoresFor(it) }
|
removed.forEach { deleteTokenStoresFor(it) }
|
||||||
}
|
}
|
||||||
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
|
|||||||
suspend fun setActiveConnection(id: String) {
|
suspend fun setActiveConnection(id: String) {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
dataStore.edit { prefs ->
|
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
|
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||||
_activeConnectionId.value = 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.
|
* Update just the `lastActiveSessionId` on the identified connection.
|
||||||
* Called whenever the user picks a chat session so connection-switch can
|
* Called whenever the user picks a chat session so connection-switch can
|
||||||
@@ -476,34 +543,12 @@ class ConnectionStore private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Connection.withDashboardDefaults(): Connection {
|
|
||||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
|
||||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
|
||||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
|
||||||
}
|
|
||||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
|
||||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
return if (
|
|
||||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
|
||||||
normalizedRoutes != routeCandidates ||
|
|
||||||
normalizedPreferredRouteRole != preferredRouteRole
|
|
||||||
) {
|
|
||||||
copy(
|
|
||||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
|
||||||
routeCandidates = normalizedRoutes,
|
|
||||||
preferredRouteRole = normalizedPreferredRouteRole,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ConnectionStore"
|
private const val TAG = "ConnectionStore"
|
||||||
|
|
||||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
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
|
// Pre-rename DataStore keys — read once in init on first launch after
|
||||||
// the rename, then wiped. See the init block above.
|
// the rename, then wiped. See the init block above.
|
||||||
@@ -517,3 +562,40 @@ class ConnectionStore private constructor(
|
|||||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore route defaults after loading a serialized connection. This remains
|
||||||
|
* internal so focused persistence tests can exercise the same normalization
|
||||||
|
* path used by [ConnectionStore].
|
||||||
|
*/
|
||||||
|
internal fun Connection.withDashboardDefaults(): Connection {
|
||||||
|
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||||
|
val effectiveDashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl
|
||||||
|
val storedOrDefaultRoutes = routeCandidates.ifEmpty {
|
||||||
|
Connection.buildRouteCandidates(
|
||||||
|
apiServerUrl = apiServerUrl,
|
||||||
|
relayUrl = relayUrl,
|
||||||
|
dashboardUrl = effectiveDashboardUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val normalizedRoutes = Connection.reconcileDashboardRoutes(
|
||||||
|
dashboardUrl = effectiveDashboardUrl,
|
||||||
|
candidates = storedOrDefaultRoutes,
|
||||||
|
)
|
||||||
|
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||||
|
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
return if (
|
||||||
|
dashboardUrl != effectiveDashboardUrl ||
|
||||||
|
normalizedRoutes != routeCandidates ||
|
||||||
|
normalizedPreferredRouteRole != preferredRouteRole
|
||||||
|
) {
|
||||||
|
copy(
|
||||||
|
dashboardUrl = effectiveDashboardUrl,
|
||||||
|
routeCandidates = normalizedRoutes,
|
||||||
|
preferredRouteRole = normalizedPreferredRouteRole,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,45 @@ object ConnectionValidation {
|
|||||||
kind = "relay URL",
|
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
|
* Catches the "added the same server twice" mistake. Matches when the
|
||||||
* candidate's api + relay URLs exactly match an existing connection
|
* candidate's api + relay URLs exactly match an existing connection
|
||||||
@@ -67,12 +106,33 @@ object ConnectionValidation {
|
|||||||
apiServerUrl: String,
|
apiServerUrl: String,
|
||||||
relayUrl: String,
|
relayUrl: String,
|
||||||
excludeId: String? = null,
|
excludeId: String? = null,
|
||||||
|
dashboardUrl: String? = null,
|
||||||
): Connection? = connections.firstOrNull { c ->
|
): Connection? = connections.firstOrNull { c ->
|
||||||
c.id != excludeId &&
|
if (c.id == excludeId) {
|
||||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
false
|
||||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
} 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? {
|
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||||
val trimmed = raw.trim()
|
val trimmed = raw.trim()
|
||||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
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
|
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||||
* paired metadata, and dashboard cookies.
|
* paired metadata, and dashboard cookies.
|
||||||
|
* - v6 (2026-07-19): preserves the optional pinned startup connection.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppBackup(
|
data class AppBackup(
|
||||||
val version: Int = 5,
|
val version: Int = 6,
|
||||||
val serverUrl: String? = null, // legacy (v1 compat)
|
val serverUrl: String? = null, // legacy (v1 compat)
|
||||||
val apiServerUrl: String? = null,
|
val apiServerUrl: String? = null,
|
||||||
val relayUrl: String? = null,
|
val relayUrl: String? = null,
|
||||||
@@ -83,6 +84,7 @@ class DataManager(
|
|||||||
val onboardingCompleted: Boolean = false,
|
val onboardingCompleted: Boolean = false,
|
||||||
val connections: List<Connection> = emptyList(),
|
val connections: List<Connection> = emptyList(),
|
||||||
val activeConnectionId: String? = null,
|
val activeConnectionId: String? = null,
|
||||||
|
val startupConnectionId: String? = null,
|
||||||
val containsSensitiveData: Boolean = true,
|
val containsSensitiveData: Boolean = true,
|
||||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||||
val exportedAt: Long = System.currentTimeMillis(),
|
val exportedAt: Long = System.currentTimeMillis(),
|
||||||
@@ -160,6 +162,7 @@ class DataManager(
|
|||||||
onboardingCompleted = onboardingCompleted,
|
onboardingCompleted = onboardingCompleted,
|
||||||
connections = connectionsSnapshot,
|
connections = connectionsSnapshot,
|
||||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||||
|
startupConnectionId = connectionStore?.startupConnectionId?.value,
|
||||||
containsSensitiveData = true,
|
containsSensitiveData = true,
|
||||||
connectionSecrets = connectionSecrets,
|
connectionSecrets = connectionSecrets,
|
||||||
exportedAt = System.currentTimeMillis(),
|
exportedAt = System.currentTimeMillis(),
|
||||||
@@ -173,6 +176,7 @@ class DataManager(
|
|||||||
store.replaceConnections(
|
store.replaceConnections(
|
||||||
connections = backup.connections,
|
connections = backup.connections,
|
||||||
activeConnectionId = backup.activeConnectionId,
|
activeConnectionId = backup.activeConnectionId,
|
||||||
|
startupConnectionId = backup.startupConnectionId,
|
||||||
)
|
)
|
||||||
|
|
||||||
val connectionsById = backup.connections.associateBy { it.id }
|
val connectionsById = backup.connections.associateBy { it.id }
|
||||||
@@ -251,9 +255,11 @@ class DataManager(
|
|||||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
?: return@withContext false
|
||||||
outputStream.flush()
|
outputStream.use {
|
||||||
|
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||||
|
it.flush()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -284,9 +290,10 @@ class DataManager(
|
|||||||
* - Clear DataStore preferences
|
* - Clear DataStore preferences
|
||||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||||
* - Clear any cached data
|
* - Clear any cached data
|
||||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||||
|
* should show onboarding again.
|
||||||
*/
|
*/
|
||||||
suspend fun resetAppData() {
|
suspend fun resetAppData(): Boolean =
|
||||||
try {
|
try {
|
||||||
// Preserve onboarding state before clearing
|
// Preserve onboarding state before clearing
|
||||||
val onboarding = isOnboardingCompleted()
|
val onboarding = isOnboardingCompleted()
|
||||||
@@ -316,10 +323,11 @@ class DataManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "App data reset complete")
|
Log.d(TAG, "App data reset complete")
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset app data", e)
|
Log.e(TAG, "Failed to reset app data", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun deleteSensitivePreferenceFiles() {
|
private suspend fun deleteSensitivePreferenceFiles() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -380,16 +388,17 @@ class DataManager(
|
|||||||
* Reset only the onboarding completion flag.
|
* Reset only the onboarding completion flag.
|
||||||
* Next app launch will show onboarding again.
|
* Next app launch will show onboarding again.
|
||||||
*/
|
*/
|
||||||
suspend fun resetOnboarding() {
|
suspend fun resetOnboarding(): Boolean =
|
||||||
try {
|
try {
|
||||||
context.relayDataStore.edit { preferences ->
|
context.relayDataStore.edit { preferences ->
|
||||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Onboarding flag reset")
|
Log.d(TAG, "Onboarding flag reset")
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if onboarding has been completed.
|
* Check if onboarding has been completed.
|
||||||
@@ -408,13 +417,14 @@ class DataManager(
|
|||||||
/**
|
/**
|
||||||
* Mark onboarding as completed.
|
* Mark onboarding as completed.
|
||||||
*/
|
*/
|
||||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||||
try {
|
try {
|
||||||
context.relayDataStore.edit { preferences ->
|
context.relayDataStore.edit { preferences ->
|
||||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||||
}
|
}
|
||||||
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||||
@@ -38,8 +39,10 @@ import kotlinx.serialization.Serializable
|
|||||||
data class EndpointCandidate(
|
data class EndpointCandidate(
|
||||||
val role: String,
|
val role: String,
|
||||||
val priority: Int = 0,
|
val priority: Int = 0,
|
||||||
val api: ApiEndpoint,
|
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
|
||||||
val relay: RelayEndpoint,
|
val api: ApiEndpoint? = null,
|
||||||
|
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
|
||||||
|
val relay: RelayEndpoint? = null,
|
||||||
val dashboard: DashboardEndpoint? = null,
|
val dashboard: DashboardEndpoint? = null,
|
||||||
val proxy: ProxyEndpoint? = null,
|
val proxy: ProxyEndpoint? = null,
|
||||||
val security: String? = null,
|
val security: String? = null,
|
||||||
@@ -137,13 +140,42 @@ fun EndpointCandidate.displayLabel(): String {
|
|||||||
return when (role.lowercase()) {
|
return when (role.lowercase()) {
|
||||||
"lan" -> "LAN"
|
"lan" -> "LAN"
|
||||||
"tailscale" -> "Tailscale"
|
"tailscale" -> "Tailscale"
|
||||||
"public" -> if (api.tls) "HTTPS" else "Public"
|
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
|
||||||
|
"HTTPS"
|
||||||
|
} else {
|
||||||
|
"Public"
|
||||||
|
}
|
||||||
"https" -> "HTTPS"
|
"https" -> "HTTPS"
|
||||||
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
|
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
|
||||||
else -> "Custom VPN ($role)"
|
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 =
|
fun EndpointCandidate.hasSecureProxy(): Boolean =
|
||||||
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
|
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
|
||||||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
|
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
|||||||
/**
|
/**
|
||||||
* Feature flags with compile-time defaults and runtime overrides.
|
* Feature flags with compile-time defaults and runtime overrides.
|
||||||
*
|
*
|
||||||
* In debug builds, all features are unlocked by default.
|
* In debug builds, Developer Options are unlocked by default until the user
|
||||||
|
* explicitly locks them.
|
||||||
* In release builds, experimental features are hidden unless the user
|
* In release builds, experimental features are hidden unless the user
|
||||||
* enables Developer Options (tap version 7 times in Settings > About).
|
* enables Developer Options (tap version 7 times in Settings > About).
|
||||||
*
|
*
|
||||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
|||||||
|
|
||||||
// DataStore keys
|
// DataStore keys
|
||||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
|
||||||
|
|
||||||
/** Whether the app is running a debug build. */
|
/** Whether the app is running a debug build. */
|
||||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
|||||||
/** Observe whether Developer Options have been unlocked. */
|
/** Observe whether Developer Options have been unlocked. */
|
||||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||||
context.relayDataStore.data.map { prefs ->
|
context.relayDataStore.data.map { prefs ->
|
||||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||||
}
|
|
||||||
|
|
||||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
|
||||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
|
||||||
context.relayDataStore.data.map { prefs ->
|
|
||||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unlock Developer Options. */
|
/** Unlock Developer Options. */
|
||||||
@@ -45,18 +39,10 @@ object FeatureFlags {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lock Developer Options and disable all experimental features. */
|
/** Lock Developer Options, including in debug builds. */
|
||||||
suspend fun lockDevOptions(context: Context) {
|
suspend fun lockDevOptions(context: Context) {
|
||||||
context.relayDataStore.edit { prefs ->
|
context.relayDataStore.edit { prefs ->
|
||||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||||
prefs[KEY_RELAY_ENABLED] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
|
||||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
|
||||||
context.relayDataStore.edit { prefs ->
|
|
||||||
prefs[KEY_RELAY_ENABLED] = enabled
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ data class VoiceSettings(
|
|||||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||||
val interactionMode: String = "tap",
|
val interactionMode: String = "tap",
|
||||||
val silenceThresholdMs: Long = 1250L,
|
val silenceThresholdMs: Long = 1250L,
|
||||||
|
/**
|
||||||
|
* When true, voice keeps progress visual and waits for the settled Hermes
|
||||||
|
* answer before speaking. Tool status, service updates, and intermediate
|
||||||
|
* assistant commentary are not narrated.
|
||||||
|
*/
|
||||||
|
val finalAnswerOnly: Boolean = false,
|
||||||
|
/** Presentation only; changing this never restarts or interrupts voice. */
|
||||||
|
val presentationMode: String = VoicePresentationMode.Focus.storageValue,
|
||||||
val realtimeTraceDetails: Boolean = false,
|
val realtimeTraceDetails: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||||
@@ -122,6 +130,16 @@ enum class VoiceAudioRoute(val storageValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class VoicePresentationMode(val storageValue: String) {
|
||||||
|
Focus("focus"),
|
||||||
|
Conversation("conversation");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromStorage(value: String?): VoicePresentationMode =
|
||||||
|
values().firstOrNull { it.storageValue == value } ?: Focus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Active scope for per-profile voice prefs.
|
* Active scope for per-profile voice prefs.
|
||||||
*
|
*
|
||||||
@@ -182,6 +200,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
|||||||
// un-namespaced means switching profiles never churns these.
|
// un-namespaced means switching profiles never churns these.
|
||||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||||
|
private val KEY_FINAL_ANSWER_ONLY = booleanPreferencesKey("voice_final_answer_only")
|
||||||
|
private val KEY_PRESENTATION_MODE = stringPreferencesKey("voice_presentation_mode")
|
||||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||||
@@ -191,6 +211,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
|||||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
||||||
|
const val DEFAULT_FINAL_ANSWER_ONLY = false
|
||||||
|
const val DEFAULT_PRESENTATION_MODE = "focus"
|
||||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||||
|
|
||||||
@@ -258,6 +280,10 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
|||||||
// --- global (shared across profiles) ---
|
// --- global (shared across profiles) ---
|
||||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||||
|
finalAnswerOnly = prefs[KEY_FINAL_ANSWER_ONLY] ?: DEFAULT_FINAL_ANSWER_ONLY,
|
||||||
|
presentationMode = VoicePresentationMode.fromStorage(
|
||||||
|
prefs[KEY_PRESENTATION_MODE] ?: DEFAULT_PRESENTATION_MODE,
|
||||||
|
).storageValue,
|
||||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||||
@@ -367,6 +393,14 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
|||||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setFinalAnswerOnly(enabled: Boolean) {
|
||||||
|
dataStore.edit { it[KEY_FINAL_ANSWER_ONLY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPresentationMode(mode: VoicePresentationMode) {
|
||||||
|
dataStore.edit { it[KEY_PRESENTATION_MODE] = mode.storageValue }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.util.Log
|
|||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.auth.CertPinStore
|
import com.hermesandroid.relay.auth.CertPinStore
|
||||||
import com.hermesandroid.relay.data.EndpointCandidate
|
import com.hermesandroid.relay.data.EndpointCandidate
|
||||||
|
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||||
import com.hermesandroid.relay.data.PairingPreferences
|
import com.hermesandroid.relay.data.PairingPreferences
|
||||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||||
@@ -325,17 +326,18 @@ class ConnectionManager(
|
|||||||
// the synthesized list just collapses to the same URL anyway.
|
// the synthesized list just collapses to the same URL anyway.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val resolved = resolveBestEndpointSafe()
|
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) {
|
if (resolved != null) {
|
||||||
_activeEndpoint.value = resolved
|
_activeEndpoint.value = resolved
|
||||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
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(
|
DiagnosticsLog.record(
|
||||||
category = DiagnosticCategory.Relay,
|
category = DiagnosticCategory.Relay,
|
||||||
severity = DiagnosticSeverity.Info,
|
severity = DiagnosticSeverity.Info,
|
||||||
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
|
title = context?.getString(R.string.conn_diag_route_selected) ?: "Relay route selected",
|
||||||
endpointRole = resolved.role,
|
endpointRole = resolved.role,
|
||||||
url = resolved.relay.url,
|
url = resolved.primaryRouteUrl(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
_activeEndpoint.value = null
|
_activeEndpoint.value = null
|
||||||
@@ -348,7 +350,11 @@ class ConnectionManager(
|
|||||||
url = url,
|
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
|
// (connectToUrlOnMainPath force-sets shouldReconnect = true, so
|
||||||
// the swap path never re-checked it.)
|
// the swap path never re-checked it.)
|
||||||
if (!shouldReconnect) return@launch
|
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) {
|
if (normalizedNew != current) {
|
||||||
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
Log.i(TAG, "network change: swapping $current → $normalizedNew")
|
||||||
connectToUrlOnMainPath(resolved.relay.url, closeReason)
|
connectToUrlOnMainPath(relayUrl, closeReason)
|
||||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||||
reconnectGate()
|
reconnectGate()
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -135,6 +135,91 @@ class RelayHttpClient(
|
|||||||
val text: String,
|
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
|
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
|
||||||
* [Result] — success carries a [FetchedMedia], failure wraps the
|
* [Result] — success carries a [FetchedMedia], failure wraps the
|
||||||
@@ -615,6 +700,14 @@ class RelayHttpClient(
|
|||||||
val capabilities: List<String> = emptyList(),
|
val capabilities: List<String> = emptyList(),
|
||||||
val profiles: List<RelayProfileInfo> = emptyList(),
|
val profiles: List<RelayProfileInfo> = emptyList(),
|
||||||
val health: String = "unknown",
|
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. */
|
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||||
|
|||||||
@@ -834,6 +834,11 @@ class RelayVoiceClient(
|
|||||||
suspend fun runVoiceOutput(
|
suspend fun runVoiceOutput(
|
||||||
text: String,
|
text: String,
|
||||||
renderMode: String? = "verbatim",
|
renderMode: String? = "verbatim",
|
||||||
|
provider: String? = null,
|
||||||
|
model: String? = null,
|
||||||
|
voice: String? = null,
|
||||||
|
sampleRate: Int? = null,
|
||||||
|
language: String? = null,
|
||||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||||
onEvent: (RealtimeVoiceEvent) -> Unit,
|
onEvent: (RealtimeVoiceEvent) -> Unit,
|
||||||
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
|
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
|
||||||
@@ -844,7 +849,15 @@ class RelayVoiceClient(
|
|||||||
return@withContext Result.failure(missingAuthError())
|
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) {
|
if (sessionResult.isFailure) {
|
||||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
|
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
|
||||||
}
|
}
|
||||||
@@ -1261,8 +1274,11 @@ class RelayVoiceClient(
|
|||||||
inputSampleRate: Int = 16_000,
|
inputSampleRate: Int = 16_000,
|
||||||
chatSessionId: String? = null,
|
chatSessionId: String? = null,
|
||||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||||
|
provider: String? = null,
|
||||||
model: String? = null,
|
model: String? = null,
|
||||||
voice: String? = null,
|
voice: String? = null,
|
||||||
|
sampleRate: Int? = null,
|
||||||
|
finalAnswerOnly: Boolean = false,
|
||||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||||
@@ -1290,8 +1306,11 @@ class RelayVoiceClient(
|
|||||||
token = token,
|
token = token,
|
||||||
chatSessionId = chatSessionId,
|
chatSessionId = chatSessionId,
|
||||||
conversationContext = conversationContext,
|
conversationContext = conversationContext,
|
||||||
|
provider = provider,
|
||||||
model = model,
|
model = model,
|
||||||
voice = voice,
|
voice = voice,
|
||||||
|
sampleRate = sampleRate,
|
||||||
|
finalAnswerOnly = finalAnswerOnly,
|
||||||
)
|
)
|
||||||
if (sessionResult.isFailure) {
|
if (sessionResult.isFailure) {
|
||||||
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
|
||||||
@@ -1805,6 +1824,28 @@ class RelayVoiceClient(
|
|||||||
if (event.type == "hermes.run.promoted") {
|
if (event.type == "hermes.run.promoted") {
|
||||||
longRunningTurn.set(true)
|
longRunningTurn.set(true)
|
||||||
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
Log.i(TAG, "Realtime agent turn marked long-running (run promoted); relaxing idle guard")
|
||||||
|
if (persistent &&
|
||||||
|
event.spokenHandoff == false &&
|
||||||
|
activeTurn.compareAndSet(true, false)
|
||||||
|
) {
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"Realtime agent foreground turn ended at silent background promotion",
|
||||||
|
)
|
||||||
|
onTurnComplete(
|
||||||
|
RealtimeVoiceSummary(
|
||||||
|
provider = event.provider ?: session.provider,
|
||||||
|
model = event.model ?: session.model,
|
||||||
|
voice = event.voice ?: session.voice,
|
||||||
|
sampleRate = session.sampleRate,
|
||||||
|
audioChunks = audioChunks,
|
||||||
|
audioBytes = audioBytes,
|
||||||
|
firstAudioMs = event.firstAudioMs,
|
||||||
|
responseDoneMs = event.responseDoneMs,
|
||||||
|
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.isAudioDelta) {
|
if (event.isAudioDelta) {
|
||||||
audioChunks += 1
|
audioChunks += 1
|
||||||
@@ -1830,13 +1871,12 @@ class RelayVoiceClient(
|
|||||||
responseDoneMs = event.responseDoneMs,
|
responseDoneMs = event.responseDoneMs,
|
||||||
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
eventLogPath = event.eventLogPath ?: session.eventLogPath,
|
||||||
)
|
)
|
||||||
if (persistent) {
|
if (persistent && activeTurn.compareAndSet(true, false)) {
|
||||||
// Turn boundary, not session boundary: keep the socket
|
// Turn boundary, not session boundary: keep the socket
|
||||||
// open for the next utterance.
|
// open for the next utterance.
|
||||||
activeTurn.set(false)
|
|
||||||
longRunningTurn.set(false)
|
longRunningTurn.set(false)
|
||||||
onTurnComplete(summary)
|
onTurnComplete(summary)
|
||||||
} else {
|
} else if (!persistent) {
|
||||||
if (claimTerminalSocket(
|
if (claimTerminalSocket(
|
||||||
webSocket,
|
webSocket,
|
||||||
generation,
|
generation,
|
||||||
@@ -2658,17 +2698,29 @@ class RelayVoiceClient(
|
|||||||
token: String,
|
token: String,
|
||||||
chatSessionId: String?,
|
chatSessionId: String?,
|
||||||
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
|
||||||
|
provider: String? = null,
|
||||||
model: String? = null,
|
model: String? = null,
|
||||||
voice: String? = null,
|
voice: String? = null,
|
||||||
|
sampleRate: Int? = null,
|
||||||
|
finalAnswerOnly: Boolean = false,
|
||||||
): Result<RealtimeSessionResponse> {
|
): Result<RealtimeSessionResponse> {
|
||||||
val body = buildJsonObject {
|
val body = buildJsonObject {
|
||||||
putProfile()
|
putProfile()
|
||||||
|
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
put("provider", JsonPrimitive(it))
|
||||||
|
}
|
||||||
model?.trim()?.takeIf { it.isNotBlank() }?.let {
|
model?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||||
put("model", JsonPrimitive(it))
|
put("model", JsonPrimitive(it))
|
||||||
}
|
}
|
||||||
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
|
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||||
put("voice", JsonPrimitive(it))
|
put("voice", JsonPrimitive(it))
|
||||||
}
|
}
|
||||||
|
sampleRate?.takeIf { it > 0 }?.let {
|
||||||
|
put("sample_rate", JsonPrimitive(it))
|
||||||
|
}
|
||||||
|
if (finalAnswerOnly) {
|
||||||
|
put("final_answer_only", JsonPrimitive(true))
|
||||||
|
}
|
||||||
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
|
||||||
put("chat_session_id", JsonPrimitive(it))
|
put("chat_session_id", JsonPrimitive(it))
|
||||||
}
|
}
|
||||||
@@ -2726,8 +2778,23 @@ class RelayVoiceClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
|
private fun createVoiceOutputSession(
|
||||||
val body = buildJsonObject { putProfile() }.toString()
|
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()
|
val request = Request.Builder()
|
||||||
.url("$httpBase/voice/output/session")
|
.url("$httpBase/voice/output/session")
|
||||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||||
@@ -2940,6 +3007,9 @@ class RelayVoiceClient(
|
|||||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||||
|
spokenHandoff = (obj["spoken_handoff"] as? JsonPrimitive)
|
||||||
|
?.contentOrNull
|
||||||
|
?.toBooleanStrictOrNull(),
|
||||||
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull
|
||||||
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
?: (obj["tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||||
@@ -2970,6 +3040,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
|
* Wire shape of `GET /voice/config`. Providers are returned as nested
|
||||||
* objects describing the currently-active STT and TTS backend. Extra
|
* objects describing the currently-active STT and TTS backend. Extra
|
||||||
@@ -3318,6 +3404,7 @@ data class RealtimeVoiceEvent(
|
|||||||
// ADR 33: background-run promotion fields.
|
// ADR 33: background-run promotion fields.
|
||||||
val tier: String? = null,
|
val tier: String? = null,
|
||||||
val floor: String? = null,
|
val floor: String? = null,
|
||||||
|
val spokenHandoff: Boolean? = null,
|
||||||
// hermes.run.progress extras — drive the live background-run chip.
|
// hermes.run.progress extras — drive the live background-run chip.
|
||||||
val activeToolName: String? = null,
|
val activeToolName: String? = null,
|
||||||
val completedToolCount: Int? = null,
|
val completedToolCount: Int? = null,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.data.EndpointCandidate
|
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.DiagnosticCategory
|
||||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||||
@@ -56,7 +58,10 @@ data class RouteProbeOutcome(
|
|||||||
* candidate is reachable we use it; reachability never promotes a lower
|
* candidate is reachable we use it; reachability never promotes a lower
|
||||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||||
* among candidates that share the same priority.
|
* 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
|
* per-candidate timeout. Positive results are cached longer than negative
|
||||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||||
* transient handoff misses do not pin a good fallback offline.
|
* 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 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 probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||||
|
|
||||||
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
private val _probeOutcomes = MutableStateFlow<Map<String, RouteProbeOutcome>>(emptyMap())
|
||||||
@@ -156,13 +167,13 @@ class EndpointResolver(
|
|||||||
private const val PROBE_TIMEOUT_DETAIL = "No answer (timed out)"
|
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)
|
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||||
* but hostnames are lowercased — two roles pointing at the same
|
* but hostnames are lowercased — two roles pointing at the same
|
||||||
* host:port share reachability state.
|
* host:port share reachability state.
|
||||||
*/
|
*/
|
||||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
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)
|
val winner = raceGroup(group)
|
||||||
if (winner != null) {
|
if (winner != null) {
|
||||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||||
"api=${winner.api.host}:${winner.api.port} priority=$priority")
|
"route=${winner.primaryRouteUrl()} priority=$priority")
|
||||||
DiagnosticsLog.record(
|
DiagnosticsLog.record(
|
||||||
category = DiagnosticCategory.Endpoint,
|
category = DiagnosticCategory.Endpoint,
|
||||||
severity = DiagnosticSeverity.Info,
|
severity = DiagnosticSeverity.Info,
|
||||||
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
|
title = context?.getString(R.string.endpoint_diag_selected) ?: "Endpoint selected",
|
||||||
detail = "priority=$priority",
|
detail = "priority=$priority",
|
||||||
endpointRole = winner.role,
|
endpointRole = winner.role,
|
||||||
url = winner.relay.url,
|
url = winner.primaryRouteUrl(),
|
||||||
)
|
)
|
||||||
return winner
|
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
|
* no retries — callers that need retry semantics can re-invoke after
|
||||||
* the cache expires.
|
* the cache expires.
|
||||||
*
|
*
|
||||||
@@ -290,18 +301,19 @@ class EndpointResolver(
|
|||||||
*/
|
*/
|
||||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||||
val startedAtMs = clock()
|
val startedAtMs = clock()
|
||||||
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
val target = probeTarget(candidate)
|
||||||
|
val url = target?.requestUrl?.toHttpUrlOrNull()
|
||||||
?: run {
|
?: run {
|
||||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||||
DiagnosticsLog.record(
|
DiagnosticsLog.record(
|
||||||
category = DiagnosticCategory.Endpoint,
|
category = DiagnosticCategory.Endpoint,
|
||||||
severity = DiagnosticSeverity.Error,
|
severity = DiagnosticSeverity.Error,
|
||||||
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
|
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,
|
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
|
return false
|
||||||
}
|
}
|
||||||
val fastClient = httpClient.newBuilder()
|
val fastClient = httpClient.newBuilder()
|
||||||
@@ -310,11 +322,14 @@ class EndpointResolver(
|
|||||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
.build()
|
.build()
|
||||||
val request = Request.Builder()
|
val requestBuilder = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.head()
|
|
||||||
.header("Accept", "*/*")
|
.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) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||||
@@ -331,13 +346,13 @@ class EndpointResolver(
|
|||||||
title = probeTitle,
|
title = probeTitle,
|
||||||
detail = if (ok) null else "HTTP ${resp.code}",
|
detail = if (ok) null else "HTTP ${resp.code}",
|
||||||
endpointRole = candidate.role,
|
endpointRole = candidate.role,
|
||||||
url = candidate.api.url,
|
url = target.baseUrl,
|
||||||
elapsedMs = clock() - startedAtMs,
|
elapsedMs = clock() - startedAtMs,
|
||||||
)
|
)
|
||||||
recordOutcome(
|
recordOutcome(
|
||||||
candidate,
|
candidate,
|
||||||
reachable = ok,
|
reachable = ok,
|
||||||
detail = if (ok) null else "HTTP ${resp.code} from /health",
|
detail = if (ok) null else "HTTP ${resp.code} from ${target.path}",
|
||||||
)
|
)
|
||||||
ok
|
ok
|
||||||
}
|
}
|
||||||
@@ -346,9 +361,9 @@ class EndpointResolver(
|
|||||||
category = DiagnosticCategory.Endpoint,
|
category = DiagnosticCategory.Endpoint,
|
||||||
severity = DiagnosticSeverity.Warning,
|
severity = DiagnosticSeverity.Warning,
|
||||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
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,
|
endpointRole = candidate.role,
|
||||||
url = candidate.api.url,
|
url = target.baseUrl,
|
||||||
elapsedMs = clock() - startedAtMs,
|
elapsedMs = clock() - startedAtMs,
|
||||||
)
|
)
|
||||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||||
@@ -359,23 +374,23 @@ class EndpointResolver(
|
|||||||
category = DiagnosticCategory.Endpoint,
|
category = DiagnosticCategory.Endpoint,
|
||||||
severity = DiagnosticSeverity.Warning,
|
severity = DiagnosticSeverity.Warning,
|
||||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
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,
|
endpointRole = candidate.role,
|
||||||
url = candidate.api.url,
|
url = target.baseUrl,
|
||||||
elapsedMs = clock() - startedAtMs,
|
elapsedMs = clock() - startedAtMs,
|
||||||
)
|
)
|
||||||
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
recordOutcome(candidate, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||||
false
|
false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
"route=${target.baseUrl}: ${e.javaClass.simpleName}")
|
||||||
DiagnosticsLog.record(
|
DiagnosticsLog.record(
|
||||||
category = DiagnosticCategory.Endpoint,
|
category = DiagnosticCategory.Endpoint,
|
||||||
severity = DiagnosticSeverity.Warning,
|
severity = DiagnosticSeverity.Warning,
|
||||||
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
||||||
detail = e.javaClass.simpleName,
|
detail = e.javaClass.simpleName,
|
||||||
endpointRole = candidate.role,
|
endpointRole = candidate.role,
|
||||||
url = candidate.api.url,
|
url = target.baseUrl,
|
||||||
elapsedMs = clock() - startedAtMs,
|
elapsedMs = clock() - startedAtMs,
|
||||||
)
|
)
|
||||||
recordOutcome(candidate, reachable = false, detail = humanProbeFailure(e))
|
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
|
* Map a probe exception to a short, actionable string for the Routes
|
||||||
* card. The TLS case is the headline: a route saved with `https://`
|
* 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.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
|
import java.net.InetAddress
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
data class HermesLanDiscoveryResult(
|
data class HermesLanDiscoveryResult(
|
||||||
val host: String,
|
val host: String,
|
||||||
|
val hostname: String? = null,
|
||||||
val apiUrl: String,
|
val apiUrl: String,
|
||||||
val dashboardUrl: String?,
|
val dashboardUrl: String?,
|
||||||
val apiReachable: Boolean,
|
val apiReachable: Boolean,
|
||||||
val dashboardReachable: Boolean,
|
val dashboardReachable: Boolean,
|
||||||
)
|
) {
|
||||||
|
val displayHost: String
|
||||||
|
get() = hostname ?: host
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-triggered local-network discovery for standard Hermes setup.
|
* User-triggered local-network discovery for standard Hermes setup.
|
||||||
@@ -59,7 +66,9 @@ object HermesLanDiscovery {
|
|||||||
hosts.map { host ->
|
hosts.map { host ->
|
||||||
async {
|
async {
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
probeHost(client, host, apiPort, dashboardPort)
|
probeHost(client, host, apiPort, dashboardPort)?.let { result ->
|
||||||
|
result.copy(hostname = resolveHostname(host))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.awaitAll()
|
}.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 {
|
private fun looksLikeDashboardStatus(body: String, contentType: String): Boolean {
|
||||||
val lower = body.lowercase()
|
val lower = body.lowercase()
|
||||||
return contentType.contains("json", ignoreCase = true) && (
|
return contentType.contains("json", ignoreCase = true) && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.hermesandroid.relay.network.shared
|
package com.hermesandroid.relay.network.shared
|
||||||
|
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||||
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
|
|||||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
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 {
|
private fun isLocalBindHost(host: String): Boolean {
|
||||||
return when (host.lowercase().trim('[', ']')) {
|
return when (host.lowercase().trim('[', ']')) {
|
||||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
"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 com.hermesandroid.relay.data.VoiceAudioRoute
|
||||||
import java.io.File
|
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
|
* Transport-neutral STT/TTS contract. The routing seam between the Standard
|
||||||
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
|
* (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 transcribe(audioFile: File): Result<String>
|
||||||
suspend fun synthesize(text: String): Result<File>
|
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> =
|
override suspend fun synthesize(text: String): Result<File> =
|
||||||
runWithSelectedRoute { it.synthesize(text) }
|
runWithSelectedRoute { it.synthesize(text) }
|
||||||
|
|
||||||
|
override suspend fun openSpeechStream(
|
||||||
|
callbacks: VoiceSpeechStreamCallbacks,
|
||||||
|
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
|
||||||
|
client.openSpeechStream(callbacks)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun <T> runWithSelectedRoute(
|
private suspend fun <T> runWithSelectedRoute(
|
||||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||||
): Result<T> {
|
): Result<T> {
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.hermesandroid.relay.network.upstream
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-local ownership of background protection for work the user already
|
||||||
|
* started. Keys are connection/profile/session scoped, so detached sibling
|
||||||
|
* sessions retain independent leases and one completion cannot release
|
||||||
|
* another session's protection.
|
||||||
|
*/
|
||||||
|
object ActiveTurnKeepAliveRegistry {
|
||||||
|
data class Snapshot(
|
||||||
|
val activeTurnCount: Int = 0,
|
||||||
|
val waitingSessionCount: Int = 0,
|
||||||
|
) {
|
||||||
|
val required: Boolean get() = activeTurnCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lock = Any()
|
||||||
|
private val leases = linkedMapOf<String, Boolean>()
|
||||||
|
private val _snapshot = MutableStateFlow(Snapshot())
|
||||||
|
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
|
||||||
|
|
||||||
|
fun acquire(key: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
leases[key] = leases[key] ?: false
|
||||||
|
publishLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWaiting(key: String, waiting: Boolean) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (key in leases) {
|
||||||
|
leases[key] = waiting
|
||||||
|
publishLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rename(oldKey: String, newKey: String) {
|
||||||
|
if (oldKey == newKey) return
|
||||||
|
synchronized(lock) {
|
||||||
|
val waiting = leases.remove(oldKey) ?: return
|
||||||
|
leases[newKey] = waiting
|
||||||
|
publishLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release(key: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (leases.remove(key) != null) publishLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseAll() {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (leases.isNotEmpty()) {
|
||||||
|
leases.clear()
|
||||||
|
publishLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resetForTest() {
|
||||||
|
releaseAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishLocked() {
|
||||||
|
_snapshot.value = Snapshot(
|
||||||
|
activeTurnCount = leases.size,
|
||||||
|
waitingSessionCount = leases.count { it.value },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
|||||||
import com.hermesandroid.relay.data.HermesCard
|
import com.hermesandroid.relay.data.HermesCard
|
||||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
|
import com.hermesandroid.relay.data.MoaReference
|
||||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||||
import com.hermesandroid.relay.data.ToolCall
|
import com.hermesandroid.relay.data.ToolCall
|
||||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
|
||||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||||
@@ -43,6 +43,9 @@ class ChatHandler {
|
|||||||
|
|
||||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||||
internal const val MAX_MESSAGES = 500
|
internal const val MAX_MESSAGES = 500
|
||||||
|
private const val MAX_MOA_REFERENCES = 32
|
||||||
|
private const val MAX_MOA_LABEL_CHARS = 120
|
||||||
|
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||||
|
|
||||||
private fun timestampToMillis(timestamp: Double?): Long {
|
private fun timestampToMillis(timestamp: Double?): Long {
|
||||||
val value = timestamp ?: return 0L
|
val value = timestamp ?: return 0L
|
||||||
@@ -155,6 +158,15 @@ class ChatHandler {
|
|||||||
*/
|
*/
|
||||||
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired for a canonical `@image:<path>` directive found on a persisted
|
||||||
|
* USER history row. This is intentionally separate from free-form
|
||||||
|
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
|
||||||
|
* can reach this callback.
|
||||||
|
*/
|
||||||
|
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
|
||||||
|
{ _, _ -> }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
||||||
@@ -512,6 +524,42 @@ class ChatHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a provisional post-interim segment back into its sealed
|
||||||
|
* assistant bubble when the terminal text proves they are one response.
|
||||||
|
* Tool/card state accumulated after the interim remains attached.
|
||||||
|
*/
|
||||||
|
fun reconcileInterimMessage(
|
||||||
|
interimMessageId: String,
|
||||||
|
currentMessageId: String,
|
||||||
|
content: String,
|
||||||
|
) {
|
||||||
|
_messages.update { messages ->
|
||||||
|
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||||
|
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||||
|
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||||
|
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||||
|
val merged = interim.copy(
|
||||||
|
content = content,
|
||||||
|
isStreaming = true,
|
||||||
|
toolCalls = mergedTools,
|
||||||
|
thinkingContent = current?.thinkingContent
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: interim.thinkingContent,
|
||||||
|
isThinkingStreaming = current?.isThinkingStreaming
|
||||||
|
?: interim.isThinkingStreaming,
|
||||||
|
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
|
||||||
|
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
|
||||||
|
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
|
||||||
|
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
|
||||||
|
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||||
|
)
|
||||||
|
messages
|
||||||
|
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||||
|
.map { if (it.id == interimMessageId) merged else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a provisional client-side message that never became a real turn. */
|
/** Remove a provisional client-side message that never became a real turn. */
|
||||||
fun removeMessage(messageId: String) {
|
fun removeMessage(messageId: String) {
|
||||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||||
@@ -973,6 +1021,27 @@ class ChatHandler {
|
|||||||
startedAt = task.startedAt,
|
startedAt = task.startedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val checkpointMoaReferences = assistant.moaReferences
|
||||||
|
.filter { it.index in 1..MAX_MOA_REFERENCES }
|
||||||
|
.distinctBy { it.index }
|
||||||
|
.sortedBy { it.index }
|
||||||
|
.take(MAX_MOA_REFERENCES)
|
||||||
|
.map { reference ->
|
||||||
|
MoaReference(
|
||||||
|
index = reference.index,
|
||||||
|
count = reference.count,
|
||||||
|
label = reference.label.take(MAX_MOA_LABEL_CHARS),
|
||||||
|
text = if (reference.available) {
|
||||||
|
reference.text.take(MAX_MOA_REFERENCE_CHARS)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
available = reference.available,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val restoredMoaReferences = currentAssistant?.moaReferences
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?: checkpointMoaReferences
|
||||||
val restoredAssistant = ChatMessage(
|
val restoredAssistant = ChatMessage(
|
||||||
id = assistant.id,
|
id = assistant.id,
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
@@ -996,6 +1065,7 @@ class ChatHandler {
|
|||||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||||
?: assistant.cardDispatches,
|
?: assistant.cardDispatches,
|
||||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||||
|
moaReferences = restoredMoaReferences,
|
||||||
)
|
)
|
||||||
|
|
||||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||||
@@ -1153,11 +1223,20 @@ class ChatHandler {
|
|||||||
// so we can attach results back to the originating assistant message's ToolCall
|
// so we can attach results back to the originating assistant message's ToolCall
|
||||||
val toolResults = items.filter { it.role == "tool" }
|
val toolResults = items.filter { it.role == "tool" }
|
||||||
.associateBy { it.toolCallId }
|
.associateBy { it.toolCallId }
|
||||||
|
// A reconnect/rejoin history response can repeat a persisted message row.
|
||||||
|
// Chat's LazyColumn renders domain ids as stable keys (via ChatMessage.uiKey),
|
||||||
|
// so allowing both copies through would crash Compose before either copy
|
||||||
|
// could be reconciled. A domain id identifies one persisted message: retain
|
||||||
|
// its first transcript position while adopting the latest repeated snapshot.
|
||||||
|
// Rows without ids remain independent, and tool/hidden rows keep their
|
||||||
|
// separate handling above/below.
|
||||||
|
val renderedItems = coalesceRenderedHistoryItems(items)
|
||||||
|
|
||||||
// Accumulator for media markers we find in loaded content — fired AFTER
|
// Accumulator for media markers we find in loaded content — fired AFTER
|
||||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||||
// mutateMessage lookups find the newly-loaded messages.
|
// mutateMessage lookups find the newly-loaded messages.
|
||||||
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||||
|
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
|
||||||
|
|
||||||
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
||||||
// building the carry map, so the id-keyed delta-merge updates rows in
|
// building the carry map, so the id-keyed delta-merge updates rows in
|
||||||
@@ -1169,8 +1248,8 @@ class ChatHandler {
|
|||||||
// silently misses those rows, so a gateway turn's tokens/badges survived
|
// silently misses those rows, so a gateway turn's tokens/badges survived
|
||||||
// only if a content match happened to cover them. See
|
// only if a content match happened to cover them. See
|
||||||
// [reconcileLiveIdsToServer].
|
// [reconcileLiveIdsToServer].
|
||||||
val serverItemIds = items.mapNotNullTo(HashSet()) { it.id }
|
val serverItemIds = renderedItems.mapNotNullTo(HashSet()) { it.id }
|
||||||
val idRemap = reconcileLiveIdsToServer(items, serverItemIds)
|
val idRemap = reconcileLiveIdsToServer(renderedItems, serverItemIds)
|
||||||
|
|
||||||
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
// Carry CLIENT-ONLY enrichment forward across the reload, keyed by the
|
||||||
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
// RECONCILED message id. The server transcript (MessageItem) rebuilds
|
||||||
@@ -1207,11 +1286,16 @@ class ChatHandler {
|
|||||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||||
|
|
||||||
val loaded = items.mapNotNull { item ->
|
val loaded = renderedItems.mapNotNull { item ->
|
||||||
val role = when (item.role) {
|
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||||
"user" -> MessageRole.USER
|
if (displayKind == "hidden") return@mapNotNull null
|
||||||
"assistant" -> MessageRole.ASSISTANT
|
val role = when {
|
||||||
"system" ->
|
displayKind == "model_switch" ||
|
||||||
|
displayKind == "async_delegation_complete" ||
|
||||||
|
displayKind == "auto_continue" -> MessageRole.SYSTEM
|
||||||
|
item.role == "user" -> MessageRole.USER
|
||||||
|
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||||
|
item.role == "system" ->
|
||||||
// Upstream injects role:system STEERING markers into the
|
// Upstream injects role:system STEERING markers into the
|
||||||
// session history on model/personality change — e.g.
|
// session history on model/personality change — e.g.
|
||||||
// "[System: The active model for this chat has changed to …]"
|
// "[System: The active model for this chat has changed to …]"
|
||||||
@@ -1227,9 +1311,11 @@ class ChatHandler {
|
|||||||
} else {
|
} else {
|
||||||
MessageRole.SYSTEM
|
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
|
else -> return@mapNotNull null
|
||||||
}
|
}
|
||||||
|
val displayContent = displayEventContent(displayKind, item.displayMetadata)
|
||||||
|
val rawServerContent = displayContent ?: item.contentText ?: ""
|
||||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||||
val ts = item.timestamp ?: 0.0
|
val ts = item.timestamp ?: 0.0
|
||||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||||
@@ -1241,15 +1327,21 @@ class ChatHandler {
|
|||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
|
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||||
val rawContent = item.contentText ?: ""
|
val rawContent = rawServerContent
|
||||||
|
|
||||||
|
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
|
||||||
|
PersistedImageReferenceParser.parse(rawContent)
|
||||||
|
} else {
|
||||||
|
PersistedImageReferences(rawContent, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
// Run the media marker parser on assistant content; strip matched
|
// Run the media marker parser on assistant content; strip matched
|
||||||
// lines and queue hits for post-assignment dispatch.
|
// lines and queue hits for post-assignment dispatch.
|
||||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||||
} else {
|
} else {
|
||||||
rawContent
|
persistedImages.cleanedText
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards are synchronous (no async fetch) so we attach them
|
// Cards are synchronous (no async fetch) so we attach them
|
||||||
@@ -1287,7 +1379,14 @@ class ChatHandler {
|
|||||||
// content-keyed queue. Inbound attachments are intentionally
|
// content-keyed queue. Inbound attachments are intentionally
|
||||||
// excluded — they come back via the marker re-dispatch.
|
// excluded — they come back via the marker re-dispatch.
|
||||||
val carriedAttachments = run {
|
val carriedAttachments = run {
|
||||||
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
|
val persistedImagePaths = persistedImages.paths.toHashSet()
|
||||||
|
val byId = prior?.attachments.orEmpty().filter { attachment ->
|
||||||
|
attachment.relayToken == null ||
|
||||||
|
(
|
||||||
|
role == MessageRole.USER &&
|
||||||
|
attachment.relayToken in persistedImagePaths
|
||||||
|
)
|
||||||
|
}
|
||||||
when {
|
when {
|
||||||
byId.isNotEmpty() -> byId
|
byId.isNotEmpty() -> byId
|
||||||
role == MessageRole.USER ->
|
role == MessageRole.USER ->
|
||||||
@@ -1295,6 +1394,15 @@ class ChatHandler {
|
|||||||
else -> emptyList()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
role == MessageRole.USER &&
|
||||||
|
carriedAttachments.isEmpty() &&
|
||||||
|
persistedImages.paths.isNotEmpty()
|
||||||
|
) {
|
||||||
|
persistedImages.paths.forEach { path ->
|
||||||
|
pendingPersistedUserImages += messageId to path
|
||||||
|
}
|
||||||
|
}
|
||||||
// Server reasoning is authoritative when present; absent, keep the
|
// Server reasoning is authoritative when present; absent, keep the
|
||||||
// live-streamed thinking rather than blanking it on reload.
|
// live-streamed thinking rather than blanking it on reload.
|
||||||
val serverThinking =
|
val serverThinking =
|
||||||
@@ -1339,6 +1447,10 @@ class ChatHandler {
|
|||||||
} else {
|
} else {
|
||||||
prior.badges
|
prior.badges
|
||||||
},
|
},
|
||||||
|
// Keep sanitized advisor state while reconciling a still-live
|
||||||
|
// row, but clear it once completion made history authoritative.
|
||||||
|
// The server transcript never becomes the source of these blocks.
|
||||||
|
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// INSERT — a server message with no local row yet. Built from
|
// INSERT — a server message with no local row yet. Built from
|
||||||
@@ -1431,6 +1543,37 @@ class ChatHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for ((messageId, path) in pendingPersistedUserImages) {
|
||||||
|
onPersistedUserImageRequested(messageId, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse replayed visible history rows by their authoritative message id.
|
||||||
|
*
|
||||||
|
* Replacing the value at its first-seen slot preserves transcript ordering;
|
||||||
|
* the last repeated value wins so a later, more complete snapshot is not lost.
|
||||||
|
* Null ids cannot be proven identical and therefore remain separate rows.
|
||||||
|
*/
|
||||||
|
private fun coalesceRenderedHistoryItems(items: List<MessageItem>): List<MessageItem> {
|
||||||
|
val firstSlotById = HashMap<String, Int>()
|
||||||
|
val coalesced = ArrayList<MessageItem>(items.size)
|
||||||
|
for (item in items) {
|
||||||
|
if (renderedRoleOf(item) == null) continue
|
||||||
|
val id = item.id
|
||||||
|
if (id == null) {
|
||||||
|
coalesced += item
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val existingSlot = firstSlotById[id]
|
||||||
|
if (existingSlot == null) {
|
||||||
|
firstSlotById[id] = coalesced.size
|
||||||
|
coalesced += item
|
||||||
|
} else {
|
||||||
|
coalesced[existingSlot] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coalesced
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||||
@@ -1496,19 +1639,54 @@ class ChatHandler {
|
|||||||
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
|
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
|
||||||
* actually render.
|
* actually render.
|
||||||
*/
|
*/
|
||||||
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
|
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||||
"user" -> MessageRole.USER
|
when (item.displayKind?.trim()?.lowercase()) {
|
||||||
"assistant" -> MessageRole.ASSISTANT
|
"hidden" -> null
|
||||||
"system" ->
|
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
|
||||||
if (!showSystemMarkers &&
|
else -> when (item.role) {
|
||||||
item.contentText?.trimStart()?.startsWith("[System:") == true
|
"user" -> MessageRole.USER
|
||||||
) {
|
"assistant" -> MessageRole.ASSISTANT
|
||||||
null
|
"system" ->
|
||||||
} else {
|
if (!showSystemMarkers &&
|
||||||
MessageRole.SYSTEM
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"auto_continue" -> "Continued after an interrupted turn"
|
||||||
|
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
|
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
|
||||||
@@ -1525,6 +1703,7 @@ class ChatHandler {
|
|||||||
val t = line.trim()
|
val t = line.trim()
|
||||||
if (t.isEmpty()) continue
|
if (t.isEmpty()) continue
|
||||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||||
|
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||||
if (sb.isNotEmpty()) sb.append('\n')
|
if (sb.isNotEmpty()) sb.append('\n')
|
||||||
sb.append(t)
|
sb.append(t)
|
||||||
@@ -1682,7 +1861,10 @@ class ChatHandler {
|
|||||||
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
|
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
|
||||||
// local title whenever the server hasn't supplied a non-blank one.
|
// local title whenever the server hasn't supplied a non-blank one.
|
||||||
val existingById = _sessions.value.associateBy { it.sessionId }
|
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 startedAtMs = timestampToMillis(item.startedAt)
|
||||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||||
@@ -1710,6 +1892,7 @@ class ChatHandler {
|
|||||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||||
source = item.source,
|
source = item.source,
|
||||||
|
hasModelConfig = item.hasModelConfig,
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.activityTimestamp }
|
}.sortedByDescending { it.activityTimestamp }
|
||||||
// Preserve the active session's optimistic row when the server list
|
// Preserve the active session's optimistic row when the server list
|
||||||
@@ -1760,7 +1943,15 @@ class ChatHandler {
|
|||||||
* Add a newly created session to the list.
|
* Add a newly created session to the list.
|
||||||
*/
|
*/
|
||||||
fun addSession(session: ChatSession) {
|
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 ---
|
// --- SSE streaming event entry points ---
|
||||||
@@ -2542,6 +2733,44 @@ class ChatHandler {
|
|||||||
|
|
||||||
// --- Gateway subagent lanes ---
|
// --- Gateway subagent lanes ---
|
||||||
|
|
||||||
|
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||||
|
_messages.update { messages ->
|
||||||
|
val targetIndex = messages.indexOfLast {
|
||||||
|
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||||
|
}
|
||||||
|
if (targetIndex < 0) return@update messages
|
||||||
|
_isStreaming.value = true
|
||||||
|
|
||||||
|
val message = messages[targetIndex]
|
||||||
|
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
|
||||||
|
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
|
||||||
|
val reference = MoaReference(
|
||||||
|
index = nextIndex,
|
||||||
|
count = event.count,
|
||||||
|
label = event.label.take(MAX_MOA_LABEL_CHARS),
|
||||||
|
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||||
|
available = event.available,
|
||||||
|
)
|
||||||
|
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
|
||||||
|
val exactReplay = existingAtIndex == reference
|
||||||
|
val base = if (nextIndex == 1 && !exactReplay) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
message.moaReferences
|
||||||
|
}
|
||||||
|
if (exactReplay) {
|
||||||
|
messages
|
||||||
|
} else {
|
||||||
|
val upserted = (base.filterNot { it.index == nextIndex } + reference)
|
||||||
|
.sortedBy(MoaReference::index)
|
||||||
|
.take(MAX_MOA_REFERENCES)
|
||||||
|
messages.toMutableList().also {
|
||||||
|
it[targetIndex] = message.copy(moaReferences = upserted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lane labels by task index, captured from `subagent.start` (goal
|
* Lane labels by task index, captured from `subagent.start` (goal
|
||||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||||
@@ -2867,13 +3096,22 @@ class ChatHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_messages.update { messages ->
|
_messages.update { messages ->
|
||||||
messages.map { msg ->
|
messages
|
||||||
if (msg.id == messageId || msg.isStreaming) {
|
.filterNot { msg ->
|
||||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
msg.id == messageId &&
|
||||||
} else {
|
msg.role == MessageRole.ASSISTANT &&
|
||||||
msg
|
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
|
// Post-stream reconciliation: re-scan final content for any annotation
|
||||||
@@ -3073,3 +3311,15 @@ internal fun formatPhoneActionResult(
|
|||||||
append("Status ${result.status}.")
|
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
|
||||||
|
}
|
||||||
|
|||||||
+395
-16
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -49,8 +50,40 @@ data class DashboardStatus(
|
|||||||
val authRequired: Boolean,
|
val authRequired: Boolean,
|
||||||
val authProviders: List<String> = emptyList(),
|
val authProviders: List<String> = emptyList(),
|
||||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||||
|
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||||
val version: String? = null,
|
val version: String? = null,
|
||||||
val message: 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
|
@Serializable
|
||||||
@@ -92,6 +125,54 @@ data class DashboardChatDisplaySettings(
|
|||||||
val toolDisplay: String? = null,
|
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 models: List<String> = emptyList(),
|
||||||
|
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. */
|
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
|
||||||
data class ElevenLabsVoice(
|
data class ElevenLabsVoice(
|
||||||
val voiceId: String,
|
val voiceId: String,
|
||||||
@@ -255,6 +336,14 @@ class DashboardApiClient(
|
|||||||
*/
|
*/
|
||||||
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
|
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`
|
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
|
||||||
* writes the WHOLE document, so [config] MUST be the full values tree
|
* writes the WHOLE document, so [config] MUST be the full values tree
|
||||||
@@ -466,25 +555,100 @@ class DashboardApiClient(
|
|||||||
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
|
||||||
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
|
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(
|
putJsonObject(
|
||||||
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
|
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
|
||||||
payload = buildJsonObject { put("enabled", enabled) },
|
payload = buildJsonObject { put("enabled", enabled) },
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun testMcpServer(name: String): Result<JsonObject> =
|
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||||
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
|
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
|
||||||
|
|
||||||
suspend fun removeMcpServer(name: String): Result<JsonObject> =
|
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
|
||||||
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
|
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(
|
suspend fun installMcpCatalogEntry(
|
||||||
name: String,
|
name: String,
|
||||||
env: Map<String, String> = emptyMap(),
|
env: Map<String, String> = emptyMap(),
|
||||||
enable: Boolean = true,
|
enable: Boolean = true,
|
||||||
|
profile: String? = null,
|
||||||
): Result<JsonObject> =
|
): Result<JsonObject> =
|
||||||
postJsonObject(
|
postJsonObject(
|
||||||
path = "/api/mcp/catalog/install",
|
path = "/api/mcp/catalog/install${profileQuery(profile)}",
|
||||||
payload = buildJsonObject {
|
payload = buildJsonObject {
|
||||||
put("name", name)
|
put("name", name)
|
||||||
put(
|
put(
|
||||||
@@ -929,17 +1093,87 @@ class DashboardApiClient(
|
|||||||
return params.joinToString(prefix = "?", separator = "&")
|
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.models
|
||||||
|
.asSequence()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.distinct()
|
||||||
|
.take(MAX_CUSTOM_ENDPOINT_MODELS)
|
||||||
|
.map(::JsonPrimitive)
|
||||||
|
.toList()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put("models", JsonArray(it)) }
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||||
|
|
||||||
fun defaultClient(
|
fun defaultClient(
|
||||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||||
): OkHttpClient = OkHttpClient.Builder()
|
bearerAuth: DashboardBearerAuth? = null,
|
||||||
.cookieJar(DashboardCookieJar(cookieStore))
|
): OkHttpClient {
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
val builder = OkHttpClient.Builder()
|
||||||
// Skills-hub search fans out server-side with a 30s overall
|
.cookieJar(DashboardCookieJar(cookieStore))
|
||||||
// timeout; keep the read window above it so a slow-but-successful
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
// search doesn't die client-side at the edge.
|
// Skills-hub search fans out server-side with a 30s overall
|
||||||
.readTimeout(45, TimeUnit.SECONDS)
|
// timeout; keep the read window above it so a slow-but-successful
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
// search doesn't die client-side at the edge.
|
||||||
.build()
|
.readTimeout(45, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
bearerAuth?.let {
|
||||||
|
builder.addInterceptor(it)
|
||||||
|
builder.authenticator(it)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||||
val authObject = root["auth"] as? JsonObject
|
val authObject = root["auth"] as? JsonObject
|
||||||
@@ -947,14 +1181,70 @@ class DashboardApiClient(
|
|||||||
?: root["providers"]
|
?: root["providers"]
|
||||||
?: authObject?.get("providers")
|
?: authObject?.get("providers")
|
||||||
val providers = parseProviders(providersElement)
|
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(
|
return DashboardStatus(
|
||||||
authRequired = root.booleanField("auth_required")
|
authRequired = root.booleanField("auth_required")
|
||||||
?: authObject.booleanField("required")
|
?: authObject.booleanField("required")
|
||||||
?: false,
|
?: false,
|
||||||
authProviders = providers.map { it.name },
|
authProviders = providers.map { it.name },
|
||||||
authProviderDetails = providers,
|
authProviderDetails = providers,
|
||||||
|
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||||
|
(it as? JsonPrimitive)?.contentOrNull
|
||||||
|
},
|
||||||
version = root.stringField("version"),
|
version = root.stringField("version"),
|
||||||
message = root.stringField("message") ?: root.stringField("detail"),
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,6 +1362,35 @@ class DashboardApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer credentials are scoped to the exact saved dashboard base, including
|
||||||
|
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
|
||||||
|
* not sufficient authority to receive the active connection's token.
|
||||||
|
*/
|
||||||
|
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||||
|
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||||
|
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||||
|
return candidateUrl.scheme == trustedUrl.scheme &&
|
||||||
|
candidateUrl.host == trustedUrl.host &&
|
||||||
|
candidateUrl.port == trustedUrl.port &&
|
||||||
|
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
|
||||||
|
candidateUrl.query == null &&
|
||||||
|
trustedUrl.query == null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trustedDashboardBearerAuthOrNull(
|
||||||
|
candidate: String,
|
||||||
|
trusted: String,
|
||||||
|
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||||
|
): DashboardBearerAuth? =
|
||||||
|
if (isNativeDashboardTransportEligible(candidate) &&
|
||||||
|
sameDashboardBase(candidate, trusted)
|
||||||
|
) {
|
||||||
|
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardCookieStore {
|
interface DashboardCookieStore {
|
||||||
fun load(): List<StoredDashboardCookie>
|
fun load(): List<StoredDashboardCookie>
|
||||||
fun save(cookies: List<StoredDashboardCookie>)
|
fun save(cookies: List<StoredDashboardCookie>)
|
||||||
@@ -1203,6 +1522,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.
|
* Cookie jar that resolves the backing per-connection store at request time.
|
||||||
*
|
*
|
||||||
@@ -1353,3 +1727,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
|
|||||||
|
|
||||||
private fun JsonObject?.intField(name: String): Int? =
|
private fun JsonObject?.intField(name: String): Int? =
|
||||||
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
|
(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.
|
* `category` field so it is robust to upstream's category-merging.
|
||||||
*/
|
*/
|
||||||
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
|
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. */
|
/** Read the value at a dot-path from the nested config values tree, or null. */
|
||||||
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
||||||
|
|||||||
+649
-49
File diff suppressed because it is too large
Load Diff
+242
-84
@@ -31,13 +31,25 @@ class GatewayEventMapper(
|
|||||||
var turnEnded: Boolean = false
|
var turnEnded: Boolean = false
|
||||||
private set
|
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 sawMessageStart = false
|
||||||
private var previousEventType: String? = null
|
private var previousEventType: String? = null
|
||||||
private var sawTextDelta = false
|
private var sawTextDelta = false
|
||||||
private var sawThinkingDelta = false
|
private var sawThinkingDelta = false
|
||||||
|
private var previewedText: String? = null
|
||||||
private var syntheticToolCounter = 0
|
private var syntheticToolCounter = 0
|
||||||
private var providerWaitStatusActive = false
|
private var providerWaitStatusActive = false
|
||||||
private var compactionStatusActive = false
|
private var compactionStatusActive = false
|
||||||
|
private var moaStatusActive = false
|
||||||
|
private var pendingInteraction: GatewayAsk? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||||
@@ -56,6 +68,30 @@ class GatewayEventMapper(
|
|||||||
|
|
||||||
fun onEvent(type: String, payload: JsonObject?) {
|
fun onEvent(type: String, payload: JsonObject?) {
|
||||||
if (turnEnded) return
|
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) {
|
when (type) {
|
||||||
"reasoning.delta" -> {
|
"reasoning.delta" -> {
|
||||||
val text = payload.string("text")
|
val text = payload.string("text")
|
||||||
@@ -95,13 +131,30 @@ class GatewayEventMapper(
|
|||||||
|
|
||||||
"message.delta" -> {
|
"message.delta" -> {
|
||||||
val text = payload.string("text")
|
val text = payload.string("text")
|
||||||
if (!text.isNullOrEmpty()) {
|
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
|
||||||
clearActivityStatuses()
|
clearActivityStatuses()
|
||||||
sawTextDelta = true
|
sawTextDelta = true
|
||||||
|
previewedText = null
|
||||||
callbacks.onTextDelta(text)
|
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" -> {
|
"message.start" -> {
|
||||||
// The upstream background-completion poller currently emits
|
// The upstream background-completion poller currently emits
|
||||||
// message.start immediately before _run_prompt_submit(), which
|
// message.start immediately before _run_prompt_submit(), which
|
||||||
@@ -114,6 +167,7 @@ class GatewayEventMapper(
|
|||||||
// assistant message began — close out the previous one.
|
// assistant message began — close out the previous one.
|
||||||
if (sawMessageStart) callbacks.onTurnComplete()
|
if (sawMessageStart) callbacks.onTurnComplete()
|
||||||
sawMessageStart = true
|
sawMessageStart = true
|
||||||
|
previewedText = null
|
||||||
callbacks.onStart()
|
callbacks.onStart()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +217,21 @@ class GatewayEventMapper(
|
|||||||
"message.complete" -> {
|
"message.complete" -> {
|
||||||
// Non-streaming servers (or error turns) deliver everything
|
// Non-streaming servers (or error turns) deliver everything
|
||||||
// here; backfill whatever never streamed.
|
// here; backfill whatever never streamed.
|
||||||
|
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
|
||||||
|
val error = payload.string("error")
|
||||||
val text = payload.string("text")
|
val text = payload.string("text")
|
||||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||||
|
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||||
|
previewedText?.let { preview ->
|
||||||
|
preview.isNotEmpty() &&
|
||||||
|
(text.startsWith(preview) || preview.startsWith(text))
|
||||||
|
} == true
|
||||||
|
if (reconcilesInterim) {
|
||||||
|
callbacks.onInterimReconciled(text)
|
||||||
|
} else if (!text.isNullOrEmpty() &&
|
||||||
|
!isIntentionalSilenceMarker(text) &&
|
||||||
|
(!sawTextDelta || previewedText != null)
|
||||||
|
) {
|
||||||
callbacks.onTextDelta(text)
|
callbacks.onTextDelta(text)
|
||||||
}
|
}
|
||||||
val reasoning = payload.string("reasoning")
|
val reasoning = payload.string("reasoning")
|
||||||
@@ -172,6 +239,12 @@ class GatewayEventMapper(
|
|||||||
callbacks.onThinkingDelta(reasoning)
|
callbacks.onThinkingDelta(reasoning)
|
||||||
}
|
}
|
||||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||||
|
if (failed) {
|
||||||
|
callbacks.onStatusUpdate(
|
||||||
|
ERROR_STATUS_KIND,
|
||||||
|
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
|
||||||
|
)
|
||||||
|
}
|
||||||
turnEnded = true
|
turnEnded = true
|
||||||
callbacks.onComplete()
|
callbacks.onComplete()
|
||||||
}
|
}
|
||||||
@@ -209,36 +282,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" -> {
|
"tool.output_risk" -> {
|
||||||
val toolId = payload.string("tool_id")
|
val toolId = payload.string("tool_id")
|
||||||
val risk = payload.string("risk")?.lowercase() ?: return
|
val risk = payload.string("risk")?.lowercase() ?: return
|
||||||
@@ -259,52 +302,57 @@ class GatewayEventMapper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoA activity proves auto-compaction has resumed even though
|
"moa.reference" -> {
|
||||||
// Android does not currently render these upstream events.
|
clearProviderWaitAndCompaction()
|
||||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
val text = payload.string("text")?.trim().orEmpty()
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
val available = !isFailedMoaReference(text)
|
||||||
|
callbacks.onMoaReference(
|
||||||
|
GatewayMoaReference(
|
||||||
|
index = payload.int("index")?.takeIf { it > 0 },
|
||||||
|
count = payload.int("count")
|
||||||
|
?.takeIf { it > 0 },
|
||||||
|
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
|
||||||
|
.ifBlank { "Advisor" },
|
||||||
|
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||||
|
available = available,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"sudo.request" -> callbacks.onInteractionRequest(
|
"moa.progress" -> {
|
||||||
GatewayAsk(
|
clearProviderWaitAndCompaction()
|
||||||
kind = GatewayAsk.Kind.SUDO,
|
val total = payload.int("refs_total")
|
||||||
requestId = payload.string("request_id"),
|
?.takeIf { it > 0 }
|
||||||
// Payload carries request_id ONLY — no command to show.
|
val done = payload.int("refs_done")
|
||||||
text = "Elevated permissions requested",
|
if (total != null && done != null) {
|
||||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||||
),
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
"secret.request" -> callbacks.onInteractionRequest(
|
"moa.phase" -> {
|
||||||
GatewayAsk(
|
clearProviderWaitAndCompaction()
|
||||||
kind = GatewayAsk.Kind.SECRET,
|
when (payload.string("phase")?.lowercase()) {
|
||||||
requestId = payload.string("request_id"),
|
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
|
||||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
"reference", "references" -> {
|
||||||
envVar = payload.string("env_var"),
|
val total = payload.int("refs_total")
|
||||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
?.takeIf { it > 0 }
|
||||||
),
|
val done = payload.int("refs_done")
|
||||||
)
|
if (total != null && done != null) {
|
||||||
|
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"sudo.expire" -> callbacks.onInteractionExpired(
|
// Legacy phase marker retained by upstream for older consumers.
|
||||||
GatewayAskExpiry(
|
"moa.aggregating" -> {
|
||||||
kind = GatewayAsk.Kind.SUDO,
|
clearProviderWaitAndCompaction()
|
||||||
requestId = payload.string("request_id"),
|
setMoaStatus("MoA: aggregating…")
|
||||||
),
|
}
|
||||||
)
|
|
||||||
|
|
||||||
"secret.expire" -> callbacks.onInteractionExpired(
|
"tool.progress" -> clearActivityStatuses()
|
||||||
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" -> {
|
"status.update" -> {
|
||||||
val text = payload.string("text")
|
val text = payload.string("text")
|
||||||
@@ -336,27 +384,120 @@ class GatewayEventMapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun clearActivityStatuses() {
|
private fun clearActivityStatuses() {
|
||||||
|
clearProviderWaitAndCompaction()
|
||||||
|
if (!moaStatusActive) return
|
||||||
|
moaStatusActive = false
|
||||||
|
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearProviderWaitAndCompaction() {
|
||||||
clearProviderWaitStatus()
|
clearProviderWaitStatus()
|
||||||
if (!compactionStatusActive) return
|
if (!compactionStatusActive) return
|
||||||
compactionStatusActive = false
|
compactionStatusActive = false
|
||||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
private fun setMoaStatus(text: String) {
|
||||||
(this?.get("choices") as? JsonArray)
|
moaStatusActive = true
|
||||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||||
?.filter { it in APPROVAL_CHOICES }
|
}
|
||||||
?.distinct()
|
|
||||||
?.takeIf { it.isNotEmpty() }
|
|
||||||
|
|
||||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
|
||||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||||
const val COMPACTION_STATUS_KIND = "compacting"
|
const val COMPACTION_STATUS_KIND = "compacting"
|
||||||
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
|
const val ERROR_STATUS_KIND = "error"
|
||||||
|
const val MOA_STATUS_KIND = "moa"
|
||||||
|
private const val MAX_MOA_LABEL_CHARS = 120
|
||||||
|
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun isFailedMoaReference(text: String): Boolean {
|
||||||
|
val normalized = text.trimStart().lowercase()
|
||||||
|
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
* Hermes 2026-07-15 emits these operational wait lines through the
|
||||||
@@ -422,3 +563,20 @@ private fun JsonObject?.int(key: String): Int? =
|
|||||||
|
|
||||||
private fun JsonObject?.double(key: String): Double? =
|
private fun JsonObject?.double(key: String): Double? =
|
||||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
(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)
|
||||||
|
|||||||
+93
-16
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opt-in foreground service that holds the app process up so the app's
|
* Foreground service that holds the app process up so work the user already
|
||||||
* connection to Hermes survives Android's background-freeze / Doze — i.e.
|
* started survives Android's background-freeze / Doze. It runs automatically
|
||||||
|
* while one or more turns are active, or continuously when the user enables
|
||||||
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
||||||
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||||
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
||||||
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
|
|||||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||||
* at submission. Off by default; only runs while the user enables the toggle.
|
* at submission. Continuous idle retention is off by default; active work is
|
||||||
|
* protected automatically and releases its lease on terminal settlement.
|
||||||
*
|
*
|
||||||
* # It does NOT own the socket
|
* # It does NOT own the socket
|
||||||
*
|
*
|
||||||
* The service's only job is to hold the process in the foreground. The socket
|
* The service's only job is to hold the process in the foreground. The socket
|
||||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
* idle-close timer while retention is required. On task removal (user swipes the app
|
||||||
* away) the ViewModel + socket die with the process, so the service stops
|
* away) the ViewModel + socket die with the process, so the service stops
|
||||||
* itself rather than leave a notification that lies about being connected.
|
* itself rather than leave a notification that lies about being connected.
|
||||||
*
|
*
|
||||||
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
|
|||||||
private const val CHANNEL_NAME = "Persistent connection"
|
private const val CHANNEL_NAME = "Persistent connection"
|
||||||
const val NOTIFICATION_ID = 4713
|
const val NOTIFICATION_ID = 4713
|
||||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||||
|
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
|
||||||
|
private const val EXTRA_PERSISTENT = "persistent"
|
||||||
|
private const val EXTRA_ACTIVE_TURNS = "active_turns"
|
||||||
|
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
|
||||||
|
@Volatile private var runningInstance: GatewayKeepAliveService? = null
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun update(
|
||||||
|
context: Context,
|
||||||
|
persistent: Boolean,
|
||||||
|
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||||
|
) {
|
||||||
|
if (!persistent && !activeTurns.required) {
|
||||||
|
stop(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runningInstance?.let { service ->
|
||||||
|
service.applyState(persistent, activeTurns)
|
||||||
|
service.startForegroundNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||||
|
.setAction(ACTION_REFRESH)
|
||||||
|
.putExtra(EXTRA_PERSISTENT, persistent)
|
||||||
|
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
|
||||||
|
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.applicationContext.startForegroundService(intent)
|
context.applicationContext.startForegroundService(intent)
|
||||||
} else {
|
} else {
|
||||||
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var persistent = false
|
||||||
|
private var activeTurns = 0
|
||||||
|
private var waitingSessions = 0
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
runningInstance = this
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_REFRESH) {
|
||||||
|
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
|
||||||
|
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
|
||||||
|
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
|
||||||
|
.coerceIn(0, activeTurns)
|
||||||
|
}
|
||||||
startForegroundNotification()
|
startForegroundNotification()
|
||||||
if (intent?.action == ACTION_STOP) {
|
if (intent?.action == ACTION_STOP) {
|
||||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
|
||||||
// Flip the pref off so ConnectionViewModel's collector won't
|
|
||||||
// restart us on the next foreground.
|
|
||||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
persistent = false
|
||||||
stopSelf()
|
if (activeTurns == 0) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
startForegroundNotification()
|
||||||
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
|
|||||||
// The socket lives in the ViewModel, which dies when the task is
|
// The socket lives in the ViewModel, which dies when the task is
|
||||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||||
|
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
if (runningInstance === this) runningInstance = null
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyState(
|
||||||
|
persistent: Boolean,
|
||||||
|
turns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||||
|
) {
|
||||||
|
this.persistent = persistent
|
||||||
|
activeTurns = turns.activeTurnCount
|
||||||
|
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
|
||||||
|
}
|
||||||
|
|
||||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||||
// are all declared in the main manifest (both flavors), so the type is
|
// are all declared in the main manifest (both flavors), so the type is
|
||||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||||
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
|
|||||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
val (title, body) = when {
|
||||||
|
waitingSessions > 0 -> {
|
||||||
|
val title = if (waitingSessions == 1) {
|
||||||
|
"Hermes is waiting for input"
|
||||||
|
} else {
|
||||||
|
"$waitingSessions Hermes sessions need input"
|
||||||
|
}
|
||||||
|
title to if (activeTurns > waitingSessions) {
|
||||||
|
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
|
||||||
|
} else {
|
||||||
|
"Open the requested session to review and continue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTurns > 0 -> {
|
||||||
|
val title = if (activeTurns == 1) {
|
||||||
|
"Hermes is finishing a turn"
|
||||||
|
} else {
|
||||||
|
"Hermes is finishing $activeTurns turns"
|
||||||
|
}
|
||||||
|
title to "The connection stays active until this work completes."
|
||||||
|
}
|
||||||
|
else -> getString(R.string.gateway_keepalive_title) to
|
||||||
|
getString(R.string.gateway_keepalive_body)
|
||||||
|
}
|
||||||
|
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(getString(R.string.gateway_keepalive_title))
|
.setContentTitle(title)
|
||||||
.setContentText(getString(R.string.gateway_keepalive_body))
|
.setContentText(body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
.setContentIntent(tapPending)
|
.setContentIntent(tapPending)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.addAction(0, "Turn off", stopPending)
|
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
|
||||||
.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureChannel() {
|
private fun ensureChannel() {
|
||||||
|
|||||||
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
|
|||||||
Ready,
|
Ready,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
|
||||||
|
enum class GatewayApprovalMode(val wireValue: String) {
|
||||||
|
Manual("manual"),
|
||||||
|
Smart("smart"),
|
||||||
|
Off("off");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
|
||||||
|
"manual" -> Manual
|
||||||
|
"smart" -> Smart
|
||||||
|
"off" -> Off
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
|
||||||
|
enum class GatewayApprovalModeCapability {
|
||||||
|
Unknown,
|
||||||
|
Supported,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||||
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
|
|||||||
*
|
*
|
||||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
* gateway while the dashboard probe is unresolved or ready. A capability-
|
||||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
* preferred SSE fallback is selected only after a definitive unavailable,
|
||||||
|
* unsupported, or sign-in-required verdict.
|
||||||
*/
|
*/
|
||||||
fun resolveStreamingEndpointPreference(
|
fun resolveStreamingEndpointPreference(
|
||||||
preference: String,
|
preference: String,
|
||||||
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
|
|||||||
capabilities: ServerCapabilities,
|
capabilities: ServerCapabilities,
|
||||||
): String = when (preference) {
|
): String = when (preference) {
|
||||||
"sessions", "completions", "runs", "gateway" -> preference
|
"sessions", "completions", "runs", "gateway" -> preference
|
||||||
else -> if (gateway == GatewayAvailability.Ready) {
|
else -> if (
|
||||||
|
gateway == GatewayAvailability.Ready ||
|
||||||
|
gateway == GatewayAvailability.Unknown
|
||||||
|
) {
|
||||||
"gateway"
|
"gateway"
|
||||||
} else {
|
} else {
|
||||||
capabilities.preferredChatEndpoint()
|
capabilities.preferredChatEndpoint()
|
||||||
@@ -95,6 +122,28 @@ data class GatewayInflightTurn(
|
|||||||
val user: String,
|
val user: String,
|
||||||
val assistant: String,
|
val assistant: String,
|
||||||
val streaming: Boolean,
|
val streaming: Boolean,
|
||||||
|
val status: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val recoverable: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||||
|
data class GatewayQueuedTurn(
|
||||||
|
val user: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
|
||||||
|
data class GatewayAutoContinue(
|
||||||
|
val attempt: Int,
|
||||||
|
val interruptedAt: Double?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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. */
|
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||||
@@ -104,9 +153,15 @@ data class GatewaySessionRecovery(
|
|||||||
val running: Boolean,
|
val running: Boolean,
|
||||||
val status: String?,
|
val status: String?,
|
||||||
val inflight: GatewayInflightTurn?,
|
val inflight: GatewayInflightTurn?,
|
||||||
|
val queued: GatewayQueuedTurn?,
|
||||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||||
val handle: ActiveTurnHandle?,
|
val handle: ActiveTurnHandle?,
|
||||||
)
|
val autoContinue: GatewayAutoContinue? = null,
|
||||||
|
) {
|
||||||
|
/** Whether upstream still owes this client live turn events. */
|
||||||
|
val hasPendingWork: Boolean
|
||||||
|
get() = running || queued != null || autoContinue != null
|
||||||
|
}
|
||||||
|
|
||||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||||
data class GatewayBackgroundTurnCompletion(
|
data class GatewayBackgroundTurnCompletion(
|
||||||
@@ -115,6 +170,25 @@ data class GatewayBackgroundTurnCompletion(
|
|||||||
val expectedAssistantText: String?,
|
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
|
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||||
@@ -273,6 +347,14 @@ data class GatewayModelProvider(
|
|||||||
val totalModels: Int = 0,
|
val totalModels: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class GatewayMoaReference(
|
||||||
|
val index: Int?,
|
||||||
|
val count: Int?,
|
||||||
|
val label: String,
|
||||||
|
val text: String,
|
||||||
|
val available: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
/** Result of the gateway `model.options` RPC. */
|
/** Result of the gateway `model.options` RPC. */
|
||||||
data class GatewayModelOptions(
|
data class GatewayModelOptions(
|
||||||
val providers: List<GatewayModelProvider>,
|
val providers: List<GatewayModelProvider>,
|
||||||
@@ -280,6 +362,15 @@ data class GatewayModelOptions(
|
|||||||
val currentProvider: String,
|
val currentProvider: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||||
|
internal fun isCurrentModelOptionsResponse(
|
||||||
|
requestGeneration: Long,
|
||||||
|
currentGeneration: Long,
|
||||||
|
requestProfileKey: String,
|
||||||
|
currentProfileKey: String,
|
||||||
|
): Boolean =
|
||||||
|
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
||||||
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||||
@@ -296,7 +387,9 @@ data class GatewayModelOptions(
|
|||||||
*
|
*
|
||||||
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
|
* [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
|
* 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
|
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
|
||||||
* accept it as a per-session override, so it is applied post-create instead.
|
* accept it as a per-session override, so it is applied post-create instead.
|
||||||
*/
|
*/
|
||||||
@@ -328,6 +421,19 @@ class GatewayTurnCallbacks(
|
|||||||
/** A gateway `message.start` opened an assistant response for this turn. */
|
/** A gateway `message.start` opened an assistant response for this turn. */
|
||||||
val onStart: () -> Unit,
|
val onStart: () -> Unit,
|
||||||
val onTextDelta: (String) -> 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 = { _, _ -> },
|
||||||
|
/**
|
||||||
|
* The terminal text is equal/prefix-related to the sealed interim, so the
|
||||||
|
* existing segment should be replaced in place instead of opening a second
|
||||||
|
* assistant bubble.
|
||||||
|
*/
|
||||||
|
val onInterimReconciled: (text: String) -> Unit = { _ -> },
|
||||||
val onThinkingDelta: (String) -> Unit,
|
val onThinkingDelta: (String) -> Unit,
|
||||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||||
@@ -352,6 +458,8 @@ class GatewayTurnCallbacks(
|
|||||||
val onToolGenerating: (toolName: String?) -> Unit,
|
val onToolGenerating: (toolName: String?) -> Unit,
|
||||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||||
|
/** Successful MoA advisor output for a transient labelled reference block. */
|
||||||
|
val onMoaReference: (GatewayMoaReference) -> Unit,
|
||||||
/**
|
/**
|
||||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||||
* the turn until answered via the matching respond RPC or the turn is
|
* the turn until answered via the matching respond RPC or the turn is
|
||||||
@@ -360,6 +468,8 @@ class GatewayTurnCallbacks(
|
|||||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||||
/** Server declared a pending interaction expired; clear only the matching card. */
|
/** Server declared a pending interaction expired; clear only the matching card. */
|
||||||
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
|
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
|
* Gateway `status.update` lifecycle line — model fallback, retries, and
|
||||||
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
|
* 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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -28,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||||||
import kotlinx.serialization.json.booleanOrNull
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
import kotlinx.serialization.json.contentOrNull
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -76,6 +78,10 @@ data class ServerCapabilities(
|
|||||||
val portable: Boolean,
|
val portable: Boolean,
|
||||||
/** `/health` — basic reachability. */
|
/** `/health` — basic reachability. */
|
||||||
val healthy: Boolean,
|
val healthy: Boolean,
|
||||||
|
/** Authenticated provider/model inventory at `/api/model/options`. */
|
||||||
|
val modelOptions: Boolean = false,
|
||||||
|
/** Backend-acknowledged per-session model lock. */
|
||||||
|
val sessionModelLock: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||||
fun preferredChatEndpoint(): String = when {
|
fun preferredChatEndpoint(): String = when {
|
||||||
@@ -99,6 +105,8 @@ data class ServerCapabilities(
|
|||||||
runs = false,
|
runs = false,
|
||||||
portable = false,
|
portable = false,
|
||||||
healthy = false,
|
healthy = false,
|
||||||
|
modelOptions = false,
|
||||||
|
sessionModelLock = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
|||||||
feature("chat_completions") ||
|
feature("chat_completions") ||
|
||||||
endpoint("chat_completions"),
|
endpoint("chat_completions"),
|
||||||
healthy = true,
|
healthy = true,
|
||||||
|
modelOptions = feature("model_options") || endpoint("model_options"),
|
||||||
|
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +169,269 @@ 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" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
|
||||||
|
data class ApiProviderModelOptions(
|
||||||
|
val providers: List<GatewayModelProvider>,
|
||||||
|
val currentModel: String,
|
||||||
|
val currentProvider: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun parseApiProviderModelOptionsBody(
|
||||||
|
json: Json,
|
||||||
|
body: String,
|
||||||
|
): ApiProviderModelOptions? {
|
||||||
|
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||||
|
?: return null
|
||||||
|
val rows = root["providers"] as? JsonArray ?: return null
|
||||||
|
val providers = rows.mapNotNull { element ->
|
||||||
|
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||||
|
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||||
|
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||||
|
GatewayModelProvider(
|
||||||
|
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||||
|
slug = slug,
|
||||||
|
models = (obj["models"] as? JsonArray).orEmpty()
|
||||||
|
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||||
|
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||||
|
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||||
|
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||||
|
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||||
|
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||||
|
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||||
|
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||||
|
?.toIntOrNull() ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ApiProviderModelOptions(
|
||||||
|
providers = providers,
|
||||||
|
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||||
|
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ApiModelRoutingErrorCode {
|
||||||
|
INVENTORY_UNSUPPORTED,
|
||||||
|
INVENTORY_UNAVAILABLE,
|
||||||
|
PROVIDER_NOT_AUTHENTICATED,
|
||||||
|
MODEL_NOT_AVAILABLE,
|
||||||
|
MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||||
|
LOCK_CAPABILITY_INCOMPLETE,
|
||||||
|
LOCK_REJECTED,
|
||||||
|
LOCK_ACK_MISMATCH,
|
||||||
|
LEGACY_PROVIDER_UNSUPPORTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiModelRoutingException(
|
||||||
|
val code: ApiModelRoutingErrorCode,
|
||||||
|
message: String,
|
||||||
|
) : IOException(message)
|
||||||
|
|
||||||
|
sealed interface ApiModelSelectionAck {
|
||||||
|
data object ServerDefault : ApiModelSelectionAck
|
||||||
|
data class Locked(
|
||||||
|
val sessionId: String,
|
||||||
|
val model: String,
|
||||||
|
val provider: String?,
|
||||||
|
val effectiveModel: String = model,
|
||||||
|
val effectiveProvider: String? = provider,
|
||||||
|
) : ApiModelSelectionAck
|
||||||
|
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
|
||||||
|
|
||||||
|
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
|
||||||
|
when {
|
||||||
|
capabilities.sessionModelLock && capabilities.modelOptions ->
|
||||||
|
ApiModelRoutingStrategy.LOCKED
|
||||||
|
capabilities.sessionModelLock ->
|
||||||
|
ApiModelRoutingStrategy.INCOMPLETE
|
||||||
|
else ->
|
||||||
|
ApiModelRoutingStrategy.LEGACY_HINT
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sessionTurnModelHint(
|
||||||
|
acknowledgement: ApiModelSelectionAck,
|
||||||
|
requestedModel: String?,
|
||||||
|
): String? =
|
||||||
|
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
|
||||||
|
|
||||||
|
internal data class ParsedApiModelLockAck(
|
||||||
|
val sessionId: String?,
|
||||||
|
val model: String?,
|
||||||
|
val provider: String?,
|
||||||
|
val state: String?,
|
||||||
|
val effectiveModel: String?,
|
||||||
|
val effectiveProvider: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
|
||||||
|
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||||
|
?: return null
|
||||||
|
val runtime = root["runtime"] as? JsonObject ?: return null
|
||||||
|
val requested = runtime["requested"] as? JsonObject
|
||||||
|
val effective = runtime["effective"] as? JsonObject
|
||||||
|
return ParsedApiModelLockAck(
|
||||||
|
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||||
|
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||||
|
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||||
|
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
|
||||||
|
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||||
|
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun confirmedRuntimeMatches(
|
||||||
|
runtime: JsonObject?,
|
||||||
|
expected: ApiModelSelectionAck.Locked,
|
||||||
|
): Boolean {
|
||||||
|
runtime ?: return false
|
||||||
|
val effective = runtime["effective"] as? JsonObject ?: return false
|
||||||
|
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
|
||||||
|
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
|
||||||
|
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
* Direct HTTP/SSE client for the Hermes API Server.
|
||||||
*
|
*
|
||||||
@@ -174,6 +447,8 @@ class HermesApiClient(
|
|||||||
isLenient = true
|
isLenient = true
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var lastCapabilities: ServerCapabilities? = null
|
||||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -199,8 +474,13 @@ class HermesApiClient(
|
|||||||
|
|
||||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||||
response != null && !response.isSuccessful ->
|
response != null && !response.isSuccessful -> streamHttpFailureMessage(
|
||||||
"API error ${response.code}: ${response.message}"
|
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 is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||||
t != null -> "Stream error: ${t.message}"
|
t != null -> "Stream error: ${t.message}"
|
||||||
else -> "Unknown stream error"
|
else -> "Unknown stream error"
|
||||||
@@ -445,6 +725,24 @@ class HermesApiClient(
|
|||||||
emptyList()
|
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 ---
|
// --- Available models ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -453,17 +751,13 @@ class HermesApiClient(
|
|||||||
* picker. Returns ids in server order; empty on any failure (the picker
|
* picker. Returns ids in server order; empty on any failure (the picker
|
||||||
* then offers only "Server default").
|
* then offers only "Server default").
|
||||||
*/
|
*/
|
||||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) return@withContext emptyList()
|
if (!response.isSuccessful) return@withContext emptyList()
|
||||||
val body = response.body?.string() ?: return@withContext emptyList()
|
val body = response.body?.string() ?: return@withContext emptyList()
|
||||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
parseModelOptionsBody(json, body).orEmpty()
|
||||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
|
||||||
data.mapNotNull {
|
|
||||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||||
@@ -471,6 +765,208 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compatibility view for callers that only need request ids. */
|
||||||
|
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||||
|
|
||||||
|
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
|
||||||
|
suspend fun getProviderModelOptions(
|
||||||
|
refresh: Boolean = false,
|
||||||
|
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val suffix = if (refresh) "?refresh=true" else ""
|
||||||
|
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
if (response.code == 404) {
|
||||||
|
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||||
|
} else {
|
||||||
|
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
|
||||||
|
},
|
||||||
|
if (response.code == 401 || response.code == 403) {
|
||||||
|
"Model inventory authorization failed (HTTP ${response.code})."
|
||||||
|
} else {
|
||||||
|
"Model inventory unavailable (HTTP ${response.code})."
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
|
||||||
|
?: return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||||
|
"Model inventory returned an invalid response.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Result.success(parsed)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(
|
||||||
|
if (e is ApiModelRoutingException) e else {
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||||
|
"Model inventory could not be loaded.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and, on capable servers, persist a model/provider lock before a
|
||||||
|
* session turn is submitted. This never writes global config.
|
||||||
|
*/
|
||||||
|
suspend fun acknowledgeSessionModelSelection(
|
||||||
|
sessionId: String,
|
||||||
|
model: String?,
|
||||||
|
provider: String?,
|
||||||
|
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
|
||||||
|
val selectedModel = AgentDisplay.requestModelName(model)
|
||||||
|
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
|
||||||
|
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
// Capability snapshots can be populated by a disconnected startup
|
||||||
|
// probe. Re-probe at the lock boundary instead of trusting a stale
|
||||||
|
// false forever after the connection recovers.
|
||||||
|
val capabilities = probeCapabilities()
|
||||||
|
|
||||||
|
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
|
||||||
|
val inventory = getProviderModelOptions().getOrElse {
|
||||||
|
return@withContext Result.failure(it)
|
||||||
|
}
|
||||||
|
val aliases = getModelOptions()
|
||||||
|
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
val providerModel = selectedRoot ?: selectedModel
|
||||||
|
val providerRow = when {
|
||||||
|
selectedProvider != null ->
|
||||||
|
inventory.providers.firstOrNull { it.slug == selectedProvider }
|
||||||
|
else -> inventory.providers.singleOrNull { providerModel in it.models }
|
||||||
|
?: inventory.providers.firstOrNull {
|
||||||
|
it.isCurrent && providerModel in it.models
|
||||||
|
}
|
||||||
|
} ?: return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||||
|
"The selected model is not in the API server's authenticated inventory.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!providerRow.authenticated) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
|
||||||
|
"The selected provider is not authenticated on this profile.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (providerModel !in providerRow.models) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||||
|
"The selected model is not available from ${providerRow.name}.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (providerModel in providerRow.unavailableModels) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||||
|
"The selected model is not available on the authenticated account.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = kotlinx.serialization.json.buildJsonObject {
|
||||||
|
put("model", selectedModel)
|
||||||
|
put("provider", providerRow.slug)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
|
||||||
|
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
|
||||||
|
.build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val responseBody = response.body.string()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||||
|
streamHttpFailureMessage(
|
||||||
|
response.code,
|
||||||
|
response.message,
|
||||||
|
response.header("Retry-After"),
|
||||||
|
responseBody,
|
||||||
|
json,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val ack = parseApiModelLockAck(json, responseBody)
|
||||||
|
if (
|
||||||
|
ack?.sessionId != sessionId ||
|
||||||
|
ack?.model != selectedModel ||
|
||||||
|
ack?.provider != providerRow.slug ||
|
||||||
|
ack?.state != "accepted" ||
|
||||||
|
ack?.effectiveModel.isNullOrBlank() ||
|
||||||
|
ack?.effectiveProvider.isNullOrBlank()
|
||||||
|
) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
|
||||||
|
"Server did not acknowledge the requested model lock.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val confirmedAck = requireNotNull(ack)
|
||||||
|
Result.success(
|
||||||
|
ApiModelSelectionAck.Locked(
|
||||||
|
sessionId = sessionId,
|
||||||
|
model = selectedModel,
|
||||||
|
provider = providerRow.slug,
|
||||||
|
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
|
||||||
|
effectiveProvider = confirmedAck.effectiveProvider,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(
|
||||||
|
if (e is ApiModelRoutingException) e else {
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||||
|
"Model lock request failed before the message was sent.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
|
||||||
|
"Server advertises an incomplete model-routing contract.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (selectedProvider != null) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
|
||||||
|
"This Hermes version cannot safely preserve a provider selection on API fallback.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val advertised = getModelOptions().map { it.id }
|
||||||
|
if (selectedModel !in advertised) {
|
||||||
|
return@withContext Result.failure(
|
||||||
|
ApiModelRoutingException(
|
||||||
|
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||||
|
"This Hermes version did not advertise the selected model for API fallback.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Server personalities ---
|
// --- Server personalities ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -593,6 +1089,7 @@ class HermesApiClient(
|
|||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
modelOverride: String? = null,
|
modelOverride: String? = null,
|
||||||
profileName: String? = null,
|
profileName: String? = null,
|
||||||
|
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||||
): EventSource {
|
): EventSource {
|
||||||
if (!modelOverride.isNullOrBlank()) {
|
if (!modelOverride.isNullOrBlank()) {
|
||||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||||
@@ -623,6 +1120,10 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val completeCalled = AtomicBoolean(false)
|
val completeCalled = AtomicBoolean(false)
|
||||||
|
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||||
|
val receivedEvent = AtomicBoolean(false)
|
||||||
|
val drainRetryScheduled = AtomicBoolean(false)
|
||||||
|
val turnSource = RetryingEventSource(request, mainHandler)
|
||||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||||
val tracer = TurnLatencyTracer("sessions")
|
val tracer = TurnLatencyTracer("sessions")
|
||||||
|
|
||||||
@@ -636,10 +1137,17 @@ class HermesApiClient(
|
|||||||
type: String?,
|
type: String?,
|
||||||
data: String
|
data: String
|
||||||
) {
|
) {
|
||||||
|
receivedEvent.set(true)
|
||||||
tracer.mark("ttfe")
|
tracer.mark("ttfe")
|
||||||
if (data == "[DONE]") {
|
if (data == "[DONE]") {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
mainHandler.post { onComplete() }
|
mainHandler.post {
|
||||||
|
if (runtimeConfirmed.get()) {
|
||||||
|
onComplete()
|
||||||
|
} else {
|
||||||
|
onError("Server ended the turn without confirming the selected model route.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -715,9 +1223,17 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||||
"assistant.completed" -> {
|
"assistant.completed" -> {
|
||||||
|
val runtimeMatches = expectedModelLock?.let {
|
||||||
|
confirmedRuntimeMatches(event.runtime, it)
|
||||||
|
} ?: true
|
||||||
|
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
onUsage(event.usage)
|
onUsage(event.usage)
|
||||||
if (event.interrupted == true) {
|
if (!runtimeMatches) {
|
||||||
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
|
onError("Server response did not confirm the selected model route.")
|
||||||
|
}
|
||||||
|
} else if (event.interrupted == true) {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
onError("Response interrupted")
|
onError("Response interrupted")
|
||||||
}
|
}
|
||||||
@@ -729,9 +1245,15 @@ class HermesApiClient(
|
|||||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||||
"run.completed" -> {
|
"run.completed" -> {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
|
val runtimeMatches = expectedModelLock?.let {
|
||||||
|
confirmedRuntimeMatches(event.runtime, it)
|
||||||
|
} ?: true
|
||||||
|
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
onUsage(event.usage)
|
onUsage(event.usage)
|
||||||
if (event.interrupted == true) {
|
if (!runtimeMatches) {
|
||||||
|
onError("Server response did not confirm the selected model route.")
|
||||||
|
} else if (event.interrupted == true) {
|
||||||
onError("Run interrupted")
|
onError("Run interrupted")
|
||||||
} else {
|
} else {
|
||||||
onComplete()
|
onComplete()
|
||||||
@@ -741,7 +1263,13 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
"done" -> {
|
"done" -> {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
mainHandler.post { onComplete() }
|
mainHandler.post {
|
||||||
|
if (runtimeConfirmed.get()) {
|
||||||
|
onComplete()
|
||||||
|
} else {
|
||||||
|
onError("Server ended the turn without confirming the selected model route.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"error" -> {
|
"error" -> {
|
||||||
@@ -799,9 +1327,20 @@ class HermesApiClient(
|
|||||||
t: Throwable?,
|
t: Throwable?,
|
||||||
response: Response?
|
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")
|
tracer.done("error")
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
val msg = streamFailureMessage(t, response)
|
|
||||||
mainHandler.post { onError(msg) }
|
mainHandler.post { onError(msg) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,12 +1348,19 @@ class HermesApiClient(
|
|||||||
override fun onClosed(eventSource: EventSource) {
|
override fun onClosed(eventSource: EventSource) {
|
||||||
tracer.done()
|
tracer.done()
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
mainHandler.post { onComplete() }
|
mainHandler.post {
|
||||||
|
if (runtimeConfirmed.get()) {
|
||||||
|
onComplete()
|
||||||
|
} else {
|
||||||
|
onError("Server closed the turn without confirming the selected model route.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sseFactory.newEventSource(request, listener)
|
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||||
|
return turnSource
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||||
@@ -876,6 +1422,9 @@ class HermesApiClient(
|
|||||||
|
|
||||||
val completeCalled = AtomicBoolean(false)
|
val completeCalled = AtomicBoolean(false)
|
||||||
val messageStarted = 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.
|
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||||
val tracer = TurnLatencyTracer("completions")
|
val tracer = TurnLatencyTracer("completions")
|
||||||
|
|
||||||
@@ -886,6 +1435,7 @@ class HermesApiClient(
|
|||||||
type: String?,
|
type: String?,
|
||||||
data: String
|
data: String
|
||||||
) {
|
) {
|
||||||
|
receivedEvent.set(true)
|
||||||
tracer.mark("ttfe")
|
tracer.mark("ttfe")
|
||||||
if (data == "[DONE]") {
|
if (data == "[DONE]") {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
@@ -941,9 +1491,20 @@ class HermesApiClient(
|
|||||||
t: Throwable?,
|
t: Throwable?,
|
||||||
response: Response?
|
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")
|
tracer.done("error")
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
val msg = streamFailureMessage(t, response)
|
|
||||||
mainHandler.post { onError(msg) }
|
mainHandler.post { onError(msg) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,7 +1517,8 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sseFactory.newEventSource(request, listener)
|
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||||
|
return turnSource
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||||
@@ -1070,6 +1632,9 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val completeCalled = AtomicBoolean(false)
|
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.
|
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||||
val tracer = TurnLatencyTracer("runs")
|
val tracer = TurnLatencyTracer("runs")
|
||||||
|
|
||||||
@@ -1080,6 +1645,7 @@ class HermesApiClient(
|
|||||||
type: String?,
|
type: String?,
|
||||||
data: String
|
data: String
|
||||||
) {
|
) {
|
||||||
|
receivedEvent.set(true)
|
||||||
tracer.mark("ttfe")
|
tracer.mark("ttfe")
|
||||||
if (data == "[DONE]") {
|
if (data == "[DONE]") {
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
@@ -1246,9 +1812,20 @@ class HermesApiClient(
|
|||||||
t: Throwable?,
|
t: Throwable?,
|
||||||
response: Response?
|
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")
|
tracer.done("error")
|
||||||
if (completeCalled.compareAndSet(false, true)) {
|
if (completeCalled.compareAndSet(false, true)) {
|
||||||
val msg = streamFailureMessage(t, response)
|
|
||||||
mainHandler.post { onError(msg) }
|
mainHandler.post { onError(msg) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1261,7 +1838,8 @@ class HermesApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sseFactory.newEventSource(request, listener)
|
turnSource.attach(sseFactory.newEventSource(request, listener))
|
||||||
|
return turnSource
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Capability detection ---
|
// --- Capability detection ---
|
||||||
@@ -1319,7 +1897,10 @@ class HermesApiClient(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
if (!healthy) {
|
||||||
|
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||||
|
return@withContext ServerCapabilities.DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
val advertisedCapabilities = try {
|
val advertisedCapabilities = try {
|
||||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||||
@@ -1333,7 +1914,10 @@ class HermesApiClient(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
if (advertisedCapabilities != null) {
|
||||||
|
lastCapabilities = advertisedCapabilities
|
||||||
|
return@withContext advertisedCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
// Reusable HEAD probe — returns true if the route is registered
|
// Reusable HEAD probe — returns true if the route is registered
|
||||||
// (any status except 404 + network errors). Already inside the
|
// (any status except 404 + network errors). Already inside the
|
||||||
@@ -1378,7 +1962,7 @@ class HermesApiClient(
|
|||||||
runs = runs,
|
runs = runs,
|
||||||
portable = portable,
|
portable = portable,
|
||||||
healthy = true,
|
healthy = true,
|
||||||
)
|
).also { lastCapabilities = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lifecycle ---
|
// --- Lifecycle ---
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
|||||||
*
|
*
|
||||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||||
* consumes `message` (or `input`) and `system_message` (or
|
* consumes `message` (or `input`) and `system_message` (or
|
||||||
* `instructions`, string only). `message` accepts either a plain string
|
* `instructions`, string only). Newer servers also parse per-request model
|
||||||
* or OpenAI-style content parts (text + `image_url`) via
|
* fields and reuse a backend-acknowledged session model lock when those
|
||||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
* fields are omitted. Android therefore acknowledges a lock first and
|
||||||
* `model`, and `profile` are NOT parsed.
|
* omits `model` on that turn; the builder's model field remains only for
|
||||||
|
* older-server compatibility. Top-level `messages`, `attachments`, and
|
||||||
|
* `profile` are not parsed.
|
||||||
*
|
*
|
||||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||||
* array), `instructions`, `conversation_history` (array of
|
* array), `instructions`, `conversation_history` (array of
|
||||||
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
|
|||||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||||
* Top-level `attachments` and `profile` are NOT parsed.
|
* Top-level `attachments` and `profile` are NOT parsed.
|
||||||
*
|
*
|
||||||
* Legacy hint fields we deliberately keep sending although current native
|
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||||
* `profile` on runs/completions, and `stream` on runs. They are
|
|
||||||
* configuration hints (never user content, so they cannot mask data
|
* configuration hints (never user content, so they cannot mask data
|
||||||
* loss) honored by legacy fork builds — the runs path in particular only
|
* loss) honored by legacy fork builds — the runs path in particular only
|
||||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package com.hermesandroid.relay.network.upstream
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||||
|
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||||
|
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Authenticator
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.Route
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
|
||||||
|
private const val NATIVE_PKCE_FLOW = "native_pkce"
|
||||||
|
private const val CALLBACK_PATH = "/callback"
|
||||||
|
private const val TOKEN_KEY = "dashboard_native_tokens_json"
|
||||||
|
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NativeDashboardTokens(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String = "",
|
||||||
|
@SerialName("expires_at") val expiresAt: Long = 0L,
|
||||||
|
val provider: String = "",
|
||||||
|
@SerialName("user_id") val userId: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
interface NativeDashboardTokenStore {
|
||||||
|
/** Stable, non-secret identity used to serialize refresh-token rotation. */
|
||||||
|
val coordinationKey: String
|
||||||
|
fun load(): NativeDashboardTokens?
|
||||||
|
fun save(tokens: NativeDashboardTokens)
|
||||||
|
fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
|
||||||
|
NativeTokenRefreshCoordinator.clear(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptedNativeDashboardTokenStore(
|
||||||
|
context: Context,
|
||||||
|
tokenStoreKey: String,
|
||||||
|
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||||
|
) : NativeDashboardTokenStore {
|
||||||
|
override val coordinationKey: String = tokenStoreKey
|
||||||
|
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
|
||||||
|
buildRawTokenStore(context.applicationContext, tokenStoreKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): NativeDashboardTokens? =
|
||||||
|
store.getString(TOKEN_KEY)?.let { raw ->
|
||||||
|
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(tokens: NativeDashboardTokens) {
|
||||||
|
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
store.remove(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
|
||||||
|
* its verifier and CSRF state must never be persisted, logged, or copied into
|
||||||
|
* Compose/SavedState UI state.
|
||||||
|
*/
|
||||||
|
class NativeDashboardAuthorization internal constructor(
|
||||||
|
val authorizationUrl: String,
|
||||||
|
internal val verifier: String,
|
||||||
|
internal val state: String,
|
||||||
|
internal val generation: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NativeDashboardAuthClient(
|
||||||
|
baseUrl: String,
|
||||||
|
private val tokenStore: NativeDashboardTokenStore,
|
||||||
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build(),
|
||||||
|
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||||
|
private val random: SecureRandom = SecureRandom(),
|
||||||
|
) {
|
||||||
|
private val baseUrl = baseUrl.trim().trimEnd('/')
|
||||||
|
|
||||||
|
fun supportsNativePkce(status: DashboardStatus): Boolean =
|
||||||
|
NATIVE_PKCE_FLOW in status.authFlows
|
||||||
|
|
||||||
|
fun beginAuthorization(
|
||||||
|
redirectUri: String,
|
||||||
|
provider: String? = null,
|
||||||
|
): NativeDashboardAuthorization {
|
||||||
|
requireStrictLoopbackRedirect(redirectUri)
|
||||||
|
// RFC 7636 uses unpadded Base64URL. Okio's base64Url() preserves
|
||||||
|
// trailing "=", which makes Hermes' standards-compliant S256
|
||||||
|
// comparison fail even though both sides hashed the same bytes.
|
||||||
|
val verifier = randomBytes(32).base64Url().trimEnd('=')
|
||||||
|
val challenge = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||||
|
.toByteString()
|
||||||
|
.base64Url()
|
||||||
|
.trimEnd('=')
|
||||||
|
val state = randomBytes(24).base64Url()
|
||||||
|
val authorizationBaseUrl = resolveAuthorizationBaseUrl(provider)
|
||||||
|
val root = "$authorizationBaseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||||
|
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||||
|
val url = root.newBuilder()
|
||||||
|
.addQueryParameter("code_challenge", challenge)
|
||||||
|
.addQueryParameter("code_challenge_method", "S256")
|
||||||
|
.addQueryParameter("redirect_uri", redirectUri)
|
||||||
|
.addQueryParameter("state", state)
|
||||||
|
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||||
|
tokenStore.coordinationKey,
|
||||||
|
)
|
||||||
|
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A private-route dashboard may be configured with a canonical HTTPS
|
||||||
|
* callback origin for its provider. Starting the browser on the private
|
||||||
|
* origin would scope Hermes' temporary PKCE cookie to the wrong host, so
|
||||||
|
* discover the provider's declared callback and start native auth there.
|
||||||
|
* Token exchange still uses [baseUrl], keeping the resulting bearer bound
|
||||||
|
* to the active connection route.
|
||||||
|
*/
|
||||||
|
private fun resolveAuthorizationBaseUrl(provider: String?): String {
|
||||||
|
val configured = baseUrl.toHttpUrlOrNull() ?: return baseUrl
|
||||||
|
if (
|
||||||
|
!provider.equals("nous", ignoreCase = true) ||
|
||||||
|
configured.scheme != "http" ||
|
||||||
|
!isPrivateNetworkLiteral(configured.host)
|
||||||
|
) {
|
||||||
|
return baseUrl
|
||||||
|
}
|
||||||
|
val loginUrl = configured.newBuilder()
|
||||||
|
.addPathSegments("auth/login")
|
||||||
|
.addQueryParameter("provider", provider)
|
||||||
|
.addQueryParameter("next", "/")
|
||||||
|
.build()
|
||||||
|
val discoveryClient = client.newBuilder()
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.build()
|
||||||
|
val location = discoveryClient.newCall(
|
||||||
|
Request.Builder().url(loginUrl).get().build(),
|
||||||
|
).execute().use { response ->
|
||||||
|
if (response.code !in 300..399) null else response.header("Location")
|
||||||
|
}
|
||||||
|
return canonicalDashboardBaseFromNousRedirect(location)
|
||||||
|
?: throw IOException("Dashboard did not advertise a secure Nous callback origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exchangeCallback(
|
||||||
|
authorization: NativeDashboardAuthorization,
|
||||||
|
callbackTarget: String,
|
||||||
|
commitAllowed: () -> Boolean = { true },
|
||||||
|
): NativeDashboardTokens {
|
||||||
|
val callback = callbackTarget.toHttpUrlOrNull()
|
||||||
|
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
|
||||||
|
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
|
||||||
|
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
|
||||||
|
throw NativeDashboardCallbackException(
|
||||||
|
"Native sign-in callback did not use the expected loopback path",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (callback.queryParameter("state") != authorization.state) {
|
||||||
|
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
|
||||||
|
}
|
||||||
|
callback.queryParameter("error")?.let {
|
||||||
|
throw NativeDashboardCallbackException(
|
||||||
|
message = "Gateway rejected native sign-in",
|
||||||
|
retryable = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val code = callback.queryParameter("code")
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
?: throw NativeDashboardCallbackException(
|
||||||
|
"Native sign-in callback did not include an authorization code",
|
||||||
|
)
|
||||||
|
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
|
||||||
|
return postTokens(
|
||||||
|
path = "/auth/native/token",
|
||||||
|
payload = json.encodeToString(payload),
|
||||||
|
clearOnAuthFailure = false,
|
||||||
|
expectedGeneration = authorization.generation,
|
||||||
|
commitAllowed = commitAllowed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
|
||||||
|
NativeTokenRefreshCoordinator.cancelAuthorization(
|
||||||
|
tokenStore.coordinationKey,
|
||||||
|
authorization.generation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearStoredSession() {
|
||||||
|
clearNativeDashboardTokens(tokenStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
|
||||||
|
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||||
|
val current = tokenStore.load()
|
||||||
|
?: tokens
|
||||||
|
?: throw IOException("No native dashboard session is stored")
|
||||||
|
// A sibling client may already have rotated the single-use refresh
|
||||||
|
// token while this caller was waiting. Adopt that winner instead
|
||||||
|
// of replaying the stale token.
|
||||||
|
if (tokens != null && current != tokens) return@synchronized current
|
||||||
|
if (current.refreshToken.isBlank()) {
|
||||||
|
clearIfUnchanged(current)
|
||||||
|
throw IOException("Native dashboard session cannot be refreshed")
|
||||||
|
}
|
||||||
|
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
|
||||||
|
val generation = NativeTokenRefreshCoordinator.currentGeneration(
|
||||||
|
tokenStore.coordinationKey,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
postTokens(
|
||||||
|
path = "/auth/native/refresh",
|
||||||
|
payload = json.encodeToString(payload),
|
||||||
|
clearOnAuthFailure = false,
|
||||||
|
expectedGeneration = generation,
|
||||||
|
)
|
||||||
|
} catch (error: NativeDashboardAuthHttpException) {
|
||||||
|
if (error.statusCode == 400 || error.statusCode == 401) {
|
||||||
|
clearIfUnchanged(current)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postTokens(
|
||||||
|
path: String,
|
||||||
|
payload: String,
|
||||||
|
clearOnAuthFailure: Boolean,
|
||||||
|
expectedGeneration: Long,
|
||||||
|
commitAllowed: () -> Boolean = { true },
|
||||||
|
): NativeDashboardTokens {
|
||||||
|
val url = "$baseUrl$path".toHttpUrlOrNull()
|
||||||
|
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(payload.toRequestBody(JSON_MEDIA))
|
||||||
|
.build()
|
||||||
|
val tokens = client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
|
||||||
|
tokenStore.clear()
|
||||||
|
}
|
||||||
|
throw NativeDashboardAuthHttpException(response.code)
|
||||||
|
}
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
|
||||||
|
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
|
||||||
|
.also {
|
||||||
|
if (it.accessToken.isBlank()) {
|
||||||
|
throw IOException("Dashboard token response did not include an access token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||||
|
if (!commitAllowed() ||
|
||||||
|
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
|
||||||
|
expectedGeneration
|
||||||
|
) {
|
||||||
|
throw IOException("Dashboard sign-in is no longer active")
|
||||||
|
}
|
||||||
|
tokenStore.save(tokens)
|
||||||
|
}
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
|
||||||
|
|
||||||
|
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
|
||||||
|
if (tokenStore.load() == expected) tokenStore.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun requireStrictLoopbackRedirect(redirectUri: String) {
|
||||||
|
val url = redirectUri.toHttpUrlOrNull()
|
||||||
|
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
|
||||||
|
require(url.scheme == "http" && url.host == "127.0.0.1") {
|
||||||
|
"Native redirect must use the 127.0.0.1 loopback address"
|
||||||
|
}
|
||||||
|
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
|
||||||
|
"Native redirect must use an ephemeral port and the exact /callback path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class NativeDashboardCallbackException(
|
||||||
|
message: String,
|
||||||
|
val retryable: Boolean = true,
|
||||||
|
) : IOException(message)
|
||||||
|
|
||||||
|
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||||
|
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||||
|
return url.scheme == "https" ||
|
||||||
|
(
|
||||||
|
url.scheme == "http" &&
|
||||||
|
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hermes already permits explicitly configured HTTP dashboard sessions on
|
||||||
|
* local routes. The brokered flow is no less protected than that cookie flow,
|
||||||
|
* but remains unavailable to arbitrary cleartext Internet hosts.
|
||||||
|
*/
|
||||||
|
private fun isPrivateNetworkLiteral(host: String): Boolean {
|
||||||
|
val octets = host.split('.').mapNotNull(String::toIntOrNull)
|
||||||
|
if (octets.size != 4 || octets.any { it !in 0..255 }) return false
|
||||||
|
val first = octets[0]
|
||||||
|
val second = octets[1]
|
||||||
|
return first == 10 ||
|
||||||
|
(first == 172 && second in 16..31) ||
|
||||||
|
(first == 192 && second == 168) ||
|
||||||
|
(first == 100 && second in 64..127)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun canonicalDashboardBaseFromNousRedirect(location: String?): String? {
|
||||||
|
val providerUrl = location?.toHttpUrlOrNull() ?: return null
|
||||||
|
if (
|
||||||
|
providerUrl.scheme != "https" ||
|
||||||
|
!providerUrl.host.equals("portal.nousresearch.com", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val callback = providerUrl.queryParameter("redirect_uri")
|
||||||
|
?.toHttpUrlOrNull()
|
||||||
|
?: return null
|
||||||
|
if (callback.scheme != "https") return null
|
||||||
|
val callbackSuffix = "/auth/callback"
|
||||||
|
if (!callback.encodedPath.endsWith(callbackSuffix)) return null
|
||||||
|
val basePath = callback.encodedPath
|
||||||
|
.removeSuffix(callbackSuffix)
|
||||||
|
.ifBlank { "/" }
|
||||||
|
return callback.newBuilder()
|
||||||
|
.encodedPath(basePath)
|
||||||
|
.query(null)
|
||||||
|
.fragment(null)
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
.trimEnd('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the native bearer to dashboard REST calls and rotates it before expiry
|
||||||
|
* or after one 401. Refresh requests use a separate bare client, so neither a
|
||||||
|
* stale bearer nor the authenticator can recurse into token rotation.
|
||||||
|
*/
|
||||||
|
class DashboardBearerAuth(
|
||||||
|
baseUrl: String,
|
||||||
|
private val tokenStore: NativeDashboardTokenStore,
|
||||||
|
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||||
|
) : Interceptor, Authenticator {
|
||||||
|
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||||
|
val request = tokens?.let {
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.header("Authorization", "Bearer ${it.accessToken}")
|
||||||
|
.build()
|
||||||
|
} ?: chain.request()
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun authenticate(route: Route?, response: Response): Request? {
|
||||||
|
if (responseCount(response) >= 2) return null
|
||||||
|
val previous = response.request.header("Authorization") ?: return null
|
||||||
|
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
|
||||||
|
val tokens = usableTokens(
|
||||||
|
forceRefresh = true,
|
||||||
|
failedAccessToken = failedAccessToken,
|
||||||
|
) ?: return null
|
||||||
|
val next = "Bearer ${tokens.accessToken}"
|
||||||
|
if (next == previous) return null
|
||||||
|
return response.request.newBuilder().header("Authorization", next).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun usableTokens(
|
||||||
|
forceRefresh: Boolean,
|
||||||
|
failedAccessToken: String?,
|
||||||
|
): NativeDashboardTokens? =
|
||||||
|
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||||
|
val current = tokenStore.load() ?: return@synchronized null
|
||||||
|
// A request can receive its 401 after another client already
|
||||||
|
// rotated the token. Retry with the winner; do not rotate again.
|
||||||
|
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
|
||||||
|
return@synchronized current
|
||||||
|
}
|
||||||
|
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
|
||||||
|
if (!forceRefresh && !nearExpiry) return@synchronized current
|
||||||
|
runCatching { authClient.refresh(current) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun responseCount(response: Response): Int {
|
||||||
|
var count = 1
|
||||||
|
var prior = response.priorResponse
|
||||||
|
while (prior != null) {
|
||||||
|
count += 1
|
||||||
|
prior = prior.priorResponse
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NativeDashboardAuthHttpException(
|
||||||
|
val statusCode: Int,
|
||||||
|
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||||
|
|
||||||
|
private object NativeTokenRefreshCoordinator {
|
||||||
|
private val locks = ConcurrentHashMap<String, Any>()
|
||||||
|
private val generations = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
|
||||||
|
|
||||||
|
fun currentGeneration(key: String): Long =
|
||||||
|
synchronized(lockFor(key)) { generations[key] ?: 0L }
|
||||||
|
|
||||||
|
fun beginAuthorization(key: String): Long =
|
||||||
|
synchronized(lockFor(key)) {
|
||||||
|
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAuthorization(key: String, expectedGeneration: Long) {
|
||||||
|
synchronized(lockFor(key)) {
|
||||||
|
if ((generations[key] ?: 0L) == expectedGeneration) {
|
||||||
|
generations[key] = expectedGeneration + 1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(store: NativeDashboardTokenStore) {
|
||||||
|
synchronized(lockFor(store.coordinationKey)) {
|
||||||
|
generations[store.coordinationKey] =
|
||||||
|
(generations[store.coordinationKey] ?: 0L) + 1L
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NativeTokenExchange(
|
||||||
|
val code: String,
|
||||||
|
@SerialName("code_verifier") val codeVerifier: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class NativeTokenRefresh(
|
||||||
|
@SerialName("refresh_token") val refreshToken: String,
|
||||||
|
val provider: String,
|
||||||
|
)
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
package com.hermesandroid.relay.network.upstream
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
|
||||||
|
private const val CALLBACK_PATH = "/callback"
|
||||||
|
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
|
||||||
|
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||||
|
private const val ACCEPT_POLL_MILLIS = 500
|
||||||
|
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
|
||||||
|
|
||||||
|
internal enum class DashboardRedirectAuthMode {
|
||||||
|
NativePkce,
|
||||||
|
WebView,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
|
||||||
|
if ("native_pkce" in authFlows) {
|
||||||
|
DashboardRedirectAuthMode.NativePkce
|
||||||
|
} else {
|
||||||
|
DashboardRedirectAuthMode.WebView
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nous Portal uses Cloudflare Turnstile and does not support embedded Android
|
||||||
|
* WebViews. Keep self-hosted OIDC on the dashboard cookie flow, but use the
|
||||||
|
* gateway's brokered system-browser flow for Nous when it is advertised.
|
||||||
|
*/
|
||||||
|
internal fun androidDashboardRedirectAuthMode(
|
||||||
|
providerName: String,
|
||||||
|
authFlows: List<String>,
|
||||||
|
): DashboardRedirectAuthMode =
|
||||||
|
if (
|
||||||
|
providerName.equals("nous", ignoreCase = true) &&
|
||||||
|
dashboardRedirectAuthMode(authFlows) == DashboardRedirectAuthMode.NativePkce
|
||||||
|
) {
|
||||||
|
DashboardRedirectAuthMode.NativePkce
|
||||||
|
} else {
|
||||||
|
DashboardRedirectAuthMode.WebView
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one native dashboard sign-in attempt.
|
||||||
|
*
|
||||||
|
* The listener and PKCE authorization are both local to [signIn], so leaving
|
||||||
|
* the screen, cancellation, timeout, or callback completion closes the port
|
||||||
|
* and discards verifier/state. Nothing secret enters Compose or saved state.
|
||||||
|
*/
|
||||||
|
class NativeDashboardSignInCoordinator(
|
||||||
|
private val authClient: NativeDashboardAuthClient,
|
||||||
|
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
|
||||||
|
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
|
||||||
|
) {
|
||||||
|
suspend fun signIn(
|
||||||
|
provider: String?,
|
||||||
|
launchAuthorization: suspend (String) -> Unit,
|
||||||
|
): NativeDashboardTokens =
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
withTimeout(timeoutMillis) {
|
||||||
|
serverSocketFactory().use { server ->
|
||||||
|
server.reuseAddress = false
|
||||||
|
server.bind(
|
||||||
|
InetSocketAddress(
|
||||||
|
InetAddress.getByName("127.0.0.1"),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
server.soTimeout = ACCEPT_POLL_MILLIS
|
||||||
|
check(server.inetAddress.hostAddress == "127.0.0.1") {
|
||||||
|
"Native sign-in listener did not bind to IPv4 loopback"
|
||||||
|
}
|
||||||
|
|
||||||
|
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
|
||||||
|
val authorization = authClient.beginAuthorization(redirectUri, provider)
|
||||||
|
val attemptContext = currentCoroutineContext()
|
||||||
|
var completed = false
|
||||||
|
try {
|
||||||
|
launchAuthorization(authorization.authorizationUrl)
|
||||||
|
awaitValidCallback(
|
||||||
|
server = server,
|
||||||
|
authorization = authorization,
|
||||||
|
commitAllowed = { attemptContext.isActive },
|
||||||
|
).also { completed = true }
|
||||||
|
} finally {
|
||||||
|
if (!completed) {
|
||||||
|
authClient.cancelAuthorization(authorization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: TimeoutCancellationException) {
|
||||||
|
throw IOException("Dashboard sign-in timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitValidCallback(
|
||||||
|
server: ServerSocket,
|
||||||
|
authorization: NativeDashboardAuthorization,
|
||||||
|
commitAllowed: () -> Boolean,
|
||||||
|
): NativeDashboardTokens {
|
||||||
|
while (true) {
|
||||||
|
val callback = acceptCallback(server)
|
||||||
|
val tokens = callback.use { socket ->
|
||||||
|
if (socket.inetAddress.hostAddress != "127.0.0.1") {
|
||||||
|
writeResponse(
|
||||||
|
socket,
|
||||||
|
status = "403 Forbidden",
|
||||||
|
body = "This sign-in callback was not accepted.",
|
||||||
|
)
|
||||||
|
return@use null
|
||||||
|
}
|
||||||
|
val target = try {
|
||||||
|
readCallbackTarget(
|
||||||
|
input = socket.getInputStream(),
|
||||||
|
expectedPort = server.localPort,
|
||||||
|
)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
writeResponse(
|
||||||
|
socket,
|
||||||
|
status = "400 Bad Request",
|
||||||
|
body = "This sign-in callback was not accepted.",
|
||||||
|
)
|
||||||
|
return@use null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
authClient.exchangeCallback(
|
||||||
|
authorization,
|
||||||
|
target,
|
||||||
|
commitAllowed = commitAllowed,
|
||||||
|
).also {
|
||||||
|
writeResponse(
|
||||||
|
socket,
|
||||||
|
status = "200 OK",
|
||||||
|
body = "Sign-in complete. You can return to Hermes Relay.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: NativeDashboardCallbackException) {
|
||||||
|
if (error.retryable) {
|
||||||
|
writeResponse(
|
||||||
|
socket,
|
||||||
|
status = "400 Bad Request",
|
||||||
|
body = "This sign-in callback was not accepted.",
|
||||||
|
)
|
||||||
|
return@use null
|
||||||
|
}
|
||||||
|
writeResponse(
|
||||||
|
socket,
|
||||||
|
status = "400 Bad Request",
|
||||||
|
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
writeResponse(
|
||||||
|
socket,
|
||||||
|
status = "400 Bad Request",
|
||||||
|
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens != null) return tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acceptCallback(server: ServerSocket): Socket {
|
||||||
|
while (true) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
try {
|
||||||
|
return server.accept().apply { soTimeout = 5_000 }
|
||||||
|
} catch (_: SocketTimeoutException) {
|
||||||
|
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
|
||||||
|
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
|
||||||
|
?: throw IOException("Native sign-in callback was empty")
|
||||||
|
val requestParts = requestLine.split(' ')
|
||||||
|
if (requestParts.size != 3 || requestParts[0] != "GET" ||
|
||||||
|
!requestParts[1].startsWith("/") ||
|
||||||
|
!requestParts[2].startsWith("HTTP/1.")
|
||||||
|
) {
|
||||||
|
throw IOException("Native sign-in callback request was malformed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var headerBytes = 0
|
||||||
|
var host: String? = null
|
||||||
|
while (true) {
|
||||||
|
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
|
||||||
|
?: throw IOException("Native sign-in callback headers were incomplete")
|
||||||
|
headerBytes += line.length + 2
|
||||||
|
if (line.isEmpty()) break
|
||||||
|
if (line.startsWith("Host:", ignoreCase = true)) {
|
||||||
|
host = line.substringAfter(':').trim()
|
||||||
|
}
|
||||||
|
if (headerBytes >= MAX_HEADER_BYTES) {
|
||||||
|
throw IOException("Native sign-in callback headers were too large")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (host != "127.0.0.1:$expectedPort") {
|
||||||
|
throw IOException("Native sign-in callback host was not accepted")
|
||||||
|
}
|
||||||
|
return requestParts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAsciiLine(input: InputStream, limit: Int): String? {
|
||||||
|
if (limit <= 0) throw IOException("Native sign-in callback was too large")
|
||||||
|
val bytes = ArrayList<Byte>(minOf(limit, 128))
|
||||||
|
var previous = -1
|
||||||
|
while (bytes.size < limit) {
|
||||||
|
val current = input.read()
|
||||||
|
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
|
||||||
|
"Native sign-in callback ended unexpectedly",
|
||||||
|
)
|
||||||
|
if (previous == '\r'.code && current == '\n'.code) {
|
||||||
|
bytes.removeAt(bytes.lastIndex)
|
||||||
|
return bytes.toByteArray().toString(Charsets.US_ASCII)
|
||||||
|
}
|
||||||
|
bytes += current.toByte()
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
throw IOException("Native sign-in callback line was too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeResponse(socket: Socket, status: String, body: String) {
|
||||||
|
val html = """
|
||||||
|
<!doctype html>
|
||||||
|
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body><p>${escapeHtml(body)}</p></body></html>
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||||
|
val headers = buildString {
|
||||||
|
append("HTTP/1.1 ").append(status).append("\r\n")
|
||||||
|
append("Content-Type: text/html; charset=utf-8\r\n")
|
||||||
|
append("Content-Length: ").append(html.size).append("\r\n")
|
||||||
|
append("Cache-Control: no-store\r\n")
|
||||||
|
append("Connection: close\r\n\r\n")
|
||||||
|
}.toByteArray(Charsets.US_ASCII)
|
||||||
|
runCatching {
|
||||||
|
socket.getOutputStream().apply {
|
||||||
|
write(headers)
|
||||||
|
write(html)
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escapeHtml(value: String): String =
|
||||||
|
value.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package com.hermesandroid.relay.network.upstream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed form of upstream's persisted user-image directives.
|
||||||
|
*
|
||||||
|
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
|
||||||
|
* Unknown or malformed directives stay visible as text. Valid directives are
|
||||||
|
* removed from the bubble so a host-local path is never exposed in the UI.
|
||||||
|
*/
|
||||||
|
internal data class PersistedImageReferences(
|
||||||
|
val cleanedText: String,
|
||||||
|
val paths: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object PersistedImageReferenceParser {
|
||||||
|
private const val MAX_INPUT_CHARS = 256 * 1024
|
||||||
|
private const val MAX_PATH_CHARS = 2_048
|
||||||
|
private const val MAX_ATTACHMENTS = 8
|
||||||
|
|
||||||
|
private val imageExtensions = setOf(
|
||||||
|
"avif",
|
||||||
|
"bmp",
|
||||||
|
"gif",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"jpeg",
|
||||||
|
"jpg",
|
||||||
|
"png",
|
||||||
|
"webp",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun parse(content: String): PersistedImageReferences {
|
||||||
|
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
|
||||||
|
return PersistedImageReferences(content, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val keptLines = ArrayList<String>()
|
||||||
|
val paths = ArrayList<String>()
|
||||||
|
|
||||||
|
for (line in content.lines()) {
|
||||||
|
val path = parseDirectiveLine(line)
|
||||||
|
if (path == null) {
|
||||||
|
keptLines += line
|
||||||
|
} else if (paths.size < MAX_ATTACHMENTS) {
|
||||||
|
paths += path
|
||||||
|
}
|
||||||
|
// Recognized refs beyond the attachment cap are still removed:
|
||||||
|
// exposing a server-local path is worse than omitting an excessive
|
||||||
|
// attachment from a deliberately bounded gallery.
|
||||||
|
}
|
||||||
|
|
||||||
|
return PersistedImageReferences(
|
||||||
|
cleanedText = keptLines.joinToString("\n").trim(),
|
||||||
|
paths = paths,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDirectiveLine(line: String): String? {
|
||||||
|
if (!line.startsWith("@image:")) return null
|
||||||
|
val rawValue = line.removePrefix("@image:")
|
||||||
|
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
|
||||||
|
|
||||||
|
val path = unwrapCanonicalValue(rawValue) ?: return null
|
||||||
|
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
|
||||||
|
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unwrapCanonicalValue(value: String): String? {
|
||||||
|
val first = value.first()
|
||||||
|
if (first !in charArrayOf('`', '"', '\'')) {
|
||||||
|
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
|
||||||
|
}
|
||||||
|
if (value.length < 3 || value.last() != first) return null
|
||||||
|
val inner = value.substring(1, value.lastIndex)
|
||||||
|
return inner.takeIf { first !in it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAbsolutePath(path: String): Boolean =
|
||||||
|
path.startsWith("/") ||
|
||||||
|
(
|
||||||
|
path.length >= 3 &&
|
||||||
|
path[0].isLetter() &&
|
||||||
|
path[1] == ':' &&
|
||||||
|
(path[2] == '\\' || path[2] == '/')
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun hasImageExtension(path: String): Boolean {
|
||||||
|
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
|
||||||
|
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
|
||||||
|
return extension in imageExtensions
|
||||||
|
}
|
||||||
|
}
|
||||||
+269
-12
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||||
import com.hermesandroid.relay.network.shared.VoiceAudioClient
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
@@ -32,15 +41,15 @@ import java.util.concurrent.TimeUnit
|
|||||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||||
* on the API server (:8642) — current upstream api_server advertises
|
* on the API server (:8642) — current upstream api_server advertises
|
||||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
* must carry the same exact-origin cookie or native bearer session used by
|
||||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
|
||||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
||||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||||
*/
|
*/
|
||||||
class StandardHermesVoiceClient(
|
class StandardHermesVoiceClient(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||||
private val dashboardUrlProvider: () -> String?,
|
private val dashboardUrlProvider: () -> String?,
|
||||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||||
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
|
|||||||
// upstream ever adds profile-aware TTS. Until then, standard voice remains
|
// upstream ever adds profile-aware TTS. Until then, standard voice remains
|
||||||
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
|
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
|
||||||
private val profileProvider: () -> String? = { null },
|
private val profileProvider: () -> String? = { null },
|
||||||
|
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
|
||||||
private val json: Json = Json {
|
private val json: Json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
isLenient = true
|
isLenient = true
|
||||||
@@ -56,14 +66,10 @@ class StandardHermesVoiceClient(
|
|||||||
) : VoiceAudioClient {
|
) : VoiceAudioClient {
|
||||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||||
|
|
||||||
private val callClient: OkHttpClient =
|
|
||||||
okHttpClient.newBuilder()
|
|
||||||
.callTimeout(90, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||||
val baseUrl = dashboardBaseUrl()
|
val baseUrl = dashboardBaseUrl()
|
||||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||||
|
val callClient = callClient(baseUrl)
|
||||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||||
}
|
}
|
||||||
@@ -94,7 +100,7 @@ class StandardHermesVoiceClient(
|
|||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||||
val transcript = root.stringField("transcript")
|
val transcript = root.stringField("transcript")
|
||||||
?: root.stringField("text")
|
?: root.stringField("text")
|
||||||
?: root.stringField("message")
|
?: root.stringField("message")
|
||||||
@@ -108,6 +114,7 @@ class StandardHermesVoiceClient(
|
|||||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||||
val baseUrl = dashboardBaseUrl()
|
val baseUrl = dashboardBaseUrl()
|
||||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||||
|
val callClient = callClient(baseUrl)
|
||||||
val cleanText = text.trim()
|
val cleanText = text.trim()
|
||||||
if (cleanText.isBlank()) {
|
if (cleanText.isBlank()) {
|
||||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||||
@@ -130,7 +137,7 @@ class StandardHermesVoiceClient(
|
|||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
|
||||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||||
if (dataUrl.isNullOrBlank()) {
|
if (dataUrl.isNullOrBlank()) {
|
||||||
throw IOException("Hermes audio speak returned no audio")
|
throw IOException("Hermes audio speak returned no audio")
|
||||||
@@ -148,10 +155,54 @@ 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 callClient = callClient(baseUrl)
|
||||||
|
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", callClient)
|
||||||
|
.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? =
|
private fun dashboardBaseUrl(): String? =
|
||||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
private fun callClient(baseUrl: String): OkHttpClient =
|
||||||
|
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
|
||||||
|
|
||||||
|
private fun executeJson(
|
||||||
|
request: Request,
|
||||||
|
operation: String,
|
||||||
|
callClient: OkHttpClient,
|
||||||
|
): Result<JsonObject> {
|
||||||
return try {
|
return try {
|
||||||
callClient.newCall(request).execute().use { response ->
|
callClient.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
@@ -242,3 +293,209 @@ class StandardHermesVoiceClient(
|
|||||||
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
|
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()
|
||||||
|
}
|
||||||
|
|||||||
+7
-2
@@ -163,7 +163,8 @@ data class SessionItem(
|
|||||||
@SerialName("message_count") val messageCount: Int? = null,
|
@SerialName("message_count") val messageCount: Int? = null,
|
||||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||||
|
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val resolvedLastActivity: Double?
|
val resolvedLastActivity: Double?
|
||||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||||
@@ -259,6 +260,8 @@ data class MessageItem(
|
|||||||
val toolCallId: String? = null,
|
val toolCallId: String? = null,
|
||||||
val timestamp: Double? = null,
|
val timestamp: Double? = null,
|
||||||
@SerialName("finish_reason") val finishReason: String? = 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
|
// Reasoning persisted with the assistant message (upstream serializes
|
||||||
// both names; reasoning is the canonical one). Restored into
|
// both names; reasoning is the canonical one). Restored into
|
||||||
// ChatMessage.thinkingContent so the Thought-process block survives a
|
// ChatMessage.thinkingContent so the Thought-process block survives a
|
||||||
@@ -389,7 +392,9 @@ data class HermesSseEvent(
|
|||||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||||
val text: String? = null, // /v1/runs reasoning.available text
|
val text: String? = null, // /v1/runs reasoning.available text
|
||||||
// Usage/token fields (on assistant.completed / run.completed)
|
// Usage/token fields (on assistant.completed / run.completed)
|
||||||
val usage: UsageInfo? = null
|
val usage: UsageInfo? = null,
|
||||||
|
// Native session routing proof on run.started and terminal events.
|
||||||
|
val runtime: JsonObject? = null,
|
||||||
) {
|
) {
|
||||||
/** Resolve the event type from whichever field is populated. */
|
/** Resolve the event type from whichever field is populated. */
|
||||||
val resolvedType: String?
|
val resolvedType: String?
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
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"
|
||||||
|
private const val GROUP_KEY = "gateway-interactions"
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun safeExpandedBody(
|
||||||
|
sessionId: String,
|
||||||
|
ask: GatewayAsk,
|
||||||
|
profile: String? = null,
|
||||||
|
): String {
|
||||||
|
val action = when (ask.kind) {
|
||||||
|
GatewayAsk.Kind.APPROVAL ->
|
||||||
|
"Review the requested action. Nothing is approved from the notification."
|
||||||
|
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
|
||||||
|
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
|
||||||
|
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
|
||||||
|
}
|
||||||
|
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
|
||||||
|
val sessionLabel = sessionId.takeLast(12)
|
||||||
|
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
|
||||||
|
GatewayAsk.Kind.APPROVAL -> "Review approval"
|
||||||
|
GatewayAsk.Kind.CLARIFY -> "Answer"
|
||||||
|
GatewayAsk.Kind.SUDO,
|
||||||
|
GatewayAsk.Kind.SECRET,
|
||||||
|
-> "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 expandedBody = safeExpandedBody(sessionId, ask, profile)
|
||||||
|
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)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
|
||||||
|
.setContentIntent(tapPending)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(publicVersion)
|
||||||
|
.setGroup(GROUP_KEY)
|
||||||
|
.addAction(0, actionLabel(ask), tapPending)
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
@@ -103,10 +104,14 @@ import com.hermesandroid.relay.data.AgentDisplay
|
|||||||
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
import com.hermesandroid.relay.data.BridgePreferencesRepository
|
||||||
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
|
||||||
import com.hermesandroid.relay.data.BuildFlavor
|
import com.hermesandroid.relay.data.BuildFlavor
|
||||||
|
import com.hermesandroid.relay.data.Connection
|
||||||
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
import com.hermesandroid.relay.data.EnhancedVoiceOverrides
|
||||||
|
import com.hermesandroid.relay.data.EndpointCandidate
|
||||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||||
|
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||||
import com.hermesandroid.relay.data.VoiceSettings
|
import com.hermesandroid.relay.data.VoiceSettings
|
||||||
|
import com.hermesandroid.relay.data.capabilities
|
||||||
import com.hermesandroid.relay.data.displayLabel
|
import com.hermesandroid.relay.data.displayLabel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -128,6 +133,7 @@ import com.hermesandroid.relay.ui.screens.BridgeSafetySettingsScreen
|
|||||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||||
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
|
||||||
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
|
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.DeveloperSettingsScreen
|
||||||
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
|
import com.hermesandroid.relay.ui.screens.MediaSettingsScreen
|
||||||
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
|
import com.hermesandroid.relay.ui.screens.PairedDevicesScreen
|
||||||
@@ -150,13 +156,17 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
|||||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
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.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.ChatViewModel
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||||
|
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||||
import com.hermesandroid.relay.audio.VoicePlayer
|
import com.hermesandroid.relay.audio.VoicePlayer
|
||||||
import com.hermesandroid.relay.audio.VoiceRecorder
|
import com.hermesandroid.relay.audio.VoiceRecorder
|
||||||
import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||||
@@ -174,14 +184,76 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
|
|||||||
|
|
||||||
// Short-lived snackbar by default; retryable errors get Long so users have
|
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||||
// time to tap the action before it auto-dismisses.
|
// time to tap the action before it auto-dismisses.
|
||||||
suspend fun SnackbarHostState.showHumanError(err: HumanError) {
|
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||||
showSnackbar(
|
return showSnackbar(
|
||||||
message = err.body,
|
message = err.body,
|
||||||
actionLabel = err.actionLabel,
|
actionLabel = err.actionLabel,
|
||||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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(
|
sealed class Screen(
|
||||||
val route: String,
|
val route: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
@@ -203,17 +275,44 @@ sealed class Screen(
|
|||||||
// NavHost, and the NavigationBarItem click must navigate via [route]()
|
// NavHost, and the NavigationBarItem click must navigate via [route]()
|
||||||
// so no unresolved `{openAgentSheet}` leaks into the destination.
|
// so no unresolved `{openAgentSheet}` leaks into the destination.
|
||||||
data object Chat : Screen(
|
data object Chat : Screen(
|
||||||
"chat?openAgentSheet={openAgentSheet}",
|
"chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
|
||||||
"Chat",
|
"Chat",
|
||||||
Icons.AutoMirrored.Filled.Chat,
|
Icons.AutoMirrored.Filled.Chat,
|
||||||
) {
|
) {
|
||||||
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
|
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
|
||||||
fun route(openAgentSheet: Boolean = false): String =
|
const val ARG_SESSION_ID: String = "sessionId"
|
||||||
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
|
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 Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||||
data object Manage : Screen("manage", "Manage", Icons.Filled.Settings)
|
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)
|
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||||
|
|
||||||
// Non-bottom-nav destinations — reached by explicit navigation, not the
|
// Non-bottom-nav destinations — reached by explicit navigation, not the
|
||||||
@@ -359,6 +458,12 @@ fun RelayApp() {
|
|||||||
// ConnectionStore's mutations are all suspend fns and we don't want to
|
// ConnectionStore's mutations are all suspend fns and we don't want to
|
||||||
// block the main dispatcher from inside the composable body.
|
// block the main dispatcher from inside the composable body.
|
||||||
val connectionSwitchScope = rememberCoroutineScope()
|
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
|
// One-time init: the terminal channel ViewModel registers with the shared
|
||||||
// multiplexer and observes the relay connection state so it can attach/
|
// multiplexer and observes the relay connection state so it can attach/
|
||||||
@@ -434,11 +539,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 chatApiClient by connectionViewModel.chatApiClient.collectAsState()
|
||||||
|
val chatTransportReady by connectionViewModel.chatReady.collectAsState()
|
||||||
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
|
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
|
||||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||||
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
val effectiveSessionProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
|
||||||
|
val effectiveDisplayProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||||
@@ -493,15 +600,9 @@ fun RelayApp() {
|
|||||||
val standardVoiceClient = remember {
|
val standardVoiceClient = remember {
|
||||||
StandardHermesVoiceClient(
|
StandardHermesVoiceClient(
|
||||||
context = mediaContext,
|
context = mediaContext,
|
||||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
dashboardHttpClientProvider = { dashboardUrl ->
|
||||||
.cookieJar(
|
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
|
||||||
DynamicDashboardCookieJar {
|
},
|
||||||
connectionViewModel.activeDashboardCookieStore()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
|
||||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build(),
|
|
||||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||||
// Live read (null for the default profile) — sent defensively on
|
// Live read (null for the default profile) — sent defensively on
|
||||||
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
||||||
@@ -633,9 +734,10 @@ fun RelayApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(chatApiClient) {
|
var boundCatalogConnectionId by remember { mutableStateOf<String?>(null) }
|
||||||
val client = chatApiClient ?: return@LaunchedEffect
|
LaunchedEffect(chatApiClient, activeConnectionId) {
|
||||||
val handler = connectionViewModel.chatHandler
|
val handler = connectionViewModel.chatHandler
|
||||||
|
val connectionChanged = boundCatalogConnectionId != activeConnectionId
|
||||||
// A route handoff / reconnect rebuilds the API client (new instance)
|
// A route handoff / reconnect rebuilds the API client (new instance)
|
||||||
// while the chat is unchanged — the bound handler is the same. Take the
|
// 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
|
// cheap path: swap the client reference only, no re-init. This is what
|
||||||
@@ -643,11 +745,24 @@ fun RelayApp() {
|
|||||||
// switch or a reconnect. A genuine re-bind (different handler) falls
|
// switch or a reconnect. A genuine re-bind (different handler) falls
|
||||||
// through to the full one-time wiring below.
|
// through to the full one-time wiring below.
|
||||||
if (chatViewModel.boundHandler === handler) {
|
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
|
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
|
// Wire inbound-media dependencies. Idempotent rewire of the
|
||||||
// ChatHandler callbacks.
|
// ChatHandler callbacks.
|
||||||
@@ -665,6 +780,9 @@ fun RelayApp() {
|
|||||||
chatViewModel.setSelectedProfileProvider {
|
chatViewModel.setSelectedProfileProvider {
|
||||||
connectionViewModel.selectedProfile.value
|
connectionViewModel.selectedProfile.value
|
||||||
}
|
}
|
||||||
|
chatViewModel.setIsolatedProfileApiProvider {
|
||||||
|
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
|
||||||
|
}
|
||||||
chatViewModel.setSessionProfileNameProvider {
|
chatViewModel.setSessionProfileNameProvider {
|
||||||
connectionViewModel.effectiveSessionProfileName.value
|
connectionViewModel.effectiveSessionProfileName.value
|
||||||
}
|
}
|
||||||
@@ -675,10 +793,7 @@ fun RelayApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
chatViewModel.setDisplayProfileProvider {
|
chatViewModel.setDisplayProfileProvider {
|
||||||
AgentDisplay.effectiveDisplayProfile(
|
connectionViewModel.effectiveDisplayProfile.value
|
||||||
selectedProfile = connectionViewModel.selectedProfile.value,
|
|
||||||
profiles = connectionViewModel.agentProfiles.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
chatViewModel.setDisplayAliasProvider {
|
chatViewModel.setDisplayAliasProvider {
|
||||||
connectionViewModel.profileDisplayAlias.value
|
connectionViewModel.profileDisplayAlias.value
|
||||||
@@ -694,6 +809,12 @@ fun RelayApp() {
|
|||||||
chatViewModel.setProfileMessageLoader { sessionId ->
|
chatViewModel.setProfileMessageLoader { sessionId ->
|
||||||
connectionViewModel.loadProfileScopedMessages(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
|
// …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.
|
// session can't be resurrected by the next profile-scoped list.
|
||||||
chatViewModel.profileSessionDeleter = { sessionId ->
|
chatViewModel.profileSessionDeleter = { sessionId ->
|
||||||
@@ -710,6 +831,7 @@ fun RelayApp() {
|
|||||||
chatViewModel.onSessionChanged = { sessionId ->
|
chatViewModel.onSessionChanged = { sessionId ->
|
||||||
connectionViewModel.saveLastSessionId(sessionId)
|
connectionViewModel.saveLastSessionId(sessionId)
|
||||||
}
|
}
|
||||||
|
boundCatalogConnectionId = activeConnectionId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload sessions / switch profile context only on a SEMANTIC change
|
// Reload sessions / switch profile context only on a SEMANTIC change
|
||||||
@@ -718,16 +840,15 @@ fun RelayApp() {
|
|||||||
// means a route handoff (which churns the client) no longer triggers a
|
// means a route handoff (which churns the client) no longer triggers a
|
||||||
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
|
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
|
||||||
// already no-ops when the context key + session are unchanged.
|
// already no-ops when the context key + session are unchanged.
|
||||||
val chatClientReady = chatApiClient != null
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
chatClientReady,
|
chatTransportReady,
|
||||||
activeConnectionId,
|
activeConnectionId,
|
||||||
selectedProfile?.name,
|
selectedProfile?.name,
|
||||||
effectiveSessionProfileName,
|
effectiveSessionProfileName,
|
||||||
lastSessionId,
|
lastSessionId,
|
||||||
profileSelectionSettled,
|
profileSelectionSettled,
|
||||||
) {
|
) {
|
||||||
if (!chatClientReady) return@LaunchedEffect
|
if (!chatTransportReady) return@LaunchedEffect
|
||||||
// Cold-start profile-isolation guard: hold the first profile-scoped load
|
// Cold-start profile-isolation guard: hold the first profile-scoped load
|
||||||
// until the persisted profile selection has SETTLED, so the session
|
// until the persisted profile selection has SETTLED, so the session
|
||||||
// drawer (and the restored session context) don't briefly load the
|
// drawer (and the restored session context) don't briefly load the
|
||||||
@@ -770,7 +891,7 @@ fun RelayApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedProfile?.name, agentProfiles, profileDisplayAlias) {
|
LaunchedEffect(selectedProfile?.name, effectiveDisplayProfile?.name, agentProfiles, profileDisplayAlias) {
|
||||||
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
|
chatViewModel.refreshAgentDisplayName(relabelGenericMessages = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,10 +968,10 @@ fun RelayApp() {
|
|||||||
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
|
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
|
||||||
// client to follow the new dashboard route instead of stranding the turn on
|
// client to follow the new dashboard route instead of stranding the turn on
|
||||||
// the dead one.
|
// 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
|
// Debounce a route FLIP before re-acquiring the gateway chat client. The
|
||||||
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
|
// 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
|
// not flap — this is belt-and-suspenders against any residual sub-second
|
||||||
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
|
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
|
||||||
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
|
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
|
||||||
@@ -859,9 +980,17 @@ fun RelayApp() {
|
|||||||
// unaffected; only a genuine url change waits for a settle window, and if
|
// 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
|
// the url flips back within it the LaunchedEffect cancels + restarts so no
|
||||||
// rebuild happens.
|
// rebuild happens.
|
||||||
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) }
|
var lastAcquiredDashboardUrl by remember { mutableStateOf<String?>(null) }
|
||||||
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
|
LaunchedEffect(
|
||||||
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) {
|
streamingEndpoint,
|
||||||
|
serverCapabilities,
|
||||||
|
gatewayAvailability,
|
||||||
|
effectiveDashboardUrl,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
lastAcquiredDashboardUrl != null &&
|
||||||
|
lastAcquiredDashboardUrl != effectiveDashboardUrl
|
||||||
|
) {
|
||||||
delay(750L)
|
delay(750L)
|
||||||
}
|
}
|
||||||
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||||
@@ -870,7 +999,7 @@ fun RelayApp() {
|
|||||||
chatViewModel.updateGatewayClient(
|
chatViewModel.updateGatewayClient(
|
||||||
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
|
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
|
||||||
)
|
)
|
||||||
lastAcquiredApiUrl = effectiveApiUrl
|
lastAcquiredDashboardUrl = effectiveDashboardUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// What's New auto-show
|
// What's New auto-show
|
||||||
@@ -961,7 +1090,6 @@ fun RelayApp() {
|
|||||||
CrashReportGate()
|
CrashReportGate()
|
||||||
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
var postOnboardingRoute by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||||
// Collect navigation requests posted by external launchers (e.g., the
|
// Collect navigation requests posted by external launchers (e.g., the
|
||||||
@@ -1026,17 +1154,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
|
// startDestination uses the route TEMPLATE so it matches the
|
||||||
// composable registered below; optional args default to null/false.
|
// composable registered below; optional args default to null/false.
|
||||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||||
@@ -1127,11 +1244,8 @@ fun RelayApp() {
|
|||||||
// bottom navigation bar so the voice overlay can own the entire screen
|
// bottom navigation bar so the voice overlay can own the entire screen
|
||||||
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
|
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
|
||||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||||
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
|
|
||||||
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
|
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
|
||||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
|
||||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
|
||||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||||
@@ -1166,17 +1280,16 @@ fun RelayApp() {
|
|||||||
startupGateTimedOut = true
|
startupGateTimedOut = true
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasStartupConnection = activeConnection?.apiServerUrl?.isNotBlank() == true
|
val hasStartupConnection = hasConfiguredStartupChat(activeConnection)
|
||||||
// A published activeEndpoint counts as "hermes online": the route
|
val appChatRuntimeStatus = resolveAppChatRuntimeStatus(
|
||||||
// resolver only publishes a winner after a successful HEAD /health
|
connection = activeConnection,
|
||||||
// probe against that route's API URL. At cold start this evidence
|
gatewayAvailability = gatewayAvailability,
|
||||||
// lands within ~1s — long before the client-based health probe,
|
apiHealth = apiHealth,
|
||||||
// which can't run until the API client exists (the client build
|
)
|
||||||
// used to queue behind the Keystore decrypt; see
|
// A Dashboard/Gateway-only connection is a complete standard Hermes
|
||||||
// apiKeyForClientBuild in ConnectionViewModel).
|
// connection. Startup readiness follows the same transport-neutral
|
||||||
val startupApiUp = apiReachable ||
|
// priority as the footer instead of waiting for an optional API probe.
|
||||||
apiHealth == ConnectionViewModel.HealthStatus.Reachable ||
|
val startupChatUp = appChatRuntimeStatus is ChatRuntimeStatus.Connected
|
||||||
activeEndpoint != null
|
|
||||||
|
|
||||||
// An Unreachable verdict only counts after it SURVIVES a settle
|
// An Unreachable verdict only counts after it SURVIVES a settle
|
||||||
// window: the first health probe often runs against the persisted
|
// window: the first health probe often runs against the persisted
|
||||||
@@ -1185,9 +1298,9 @@ fun RelayApp() {
|
|||||||
// first verdict was what flashed the disconnected chat UI at users
|
// first verdict was what flashed the disconnected chat UI at users
|
||||||
// who were connected-just-waiting. The keyed effect restarts on
|
// who were connected-just-waiting. The keyed effect restarts on
|
||||||
// every health flip, cancelling a pending settle.
|
// every health flip, cancelling a pending settle.
|
||||||
LaunchedEffect(apiHealth, startupGateReleased) {
|
LaunchedEffect(appChatRuntimeStatus, startupGateReleased) {
|
||||||
if (startupGateReleased) return@LaunchedEffect
|
if (startupGateReleased) return@LaunchedEffect
|
||||||
if (apiHealth == ConnectionViewModel.HealthStatus.Unreachable) {
|
if (hasStartupConnection && appChatRuntimeStatus is ChatRuntimeStatus.Unavailable) {
|
||||||
delay(3_000L)
|
delay(3_000L)
|
||||||
startupUnreachableSettled = true
|
startupUnreachableSettled = true
|
||||||
} else {
|
} else {
|
||||||
@@ -1211,16 +1324,16 @@ fun RelayApp() {
|
|||||||
StartupCheckState.Done,
|
StartupCheckState.Done,
|
||||||
"route · ${startupEndpoint.displayLabel()}",
|
"route · ${startupEndpoint.displayLabel()}",
|
||||||
)
|
)
|
||||||
startupApiUp ->
|
startupChatUp ->
|
||||||
StartupCheck(StartupCheckState.Done, "route · direct")
|
StartupCheck(StartupCheckState.Done, "route · direct")
|
||||||
appReady ->
|
appReady ->
|
||||||
StartupCheck(StartupCheckState.Active, "resolving route")
|
StartupCheck(StartupCheckState.Active, "resolving route")
|
||||||
else -> StartupCheck(StartupCheckState.Pending, "route")
|
else -> StartupCheck(StartupCheckState.Pending, "route")
|
||||||
},
|
},
|
||||||
when {
|
when {
|
||||||
startupApiUp ->
|
startupChatUp ->
|
||||||
StartupCheck(StartupCheckState.Done, "hermes online")
|
StartupCheck(StartupCheckState.Done, "hermes online")
|
||||||
apiHealth == ConnectionViewModel.HealthStatus.Unreachable ->
|
appChatRuntimeStatus is ChatRuntimeStatus.Unavailable ->
|
||||||
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
|
StartupCheck(StartupCheckState.Failed, "hermes unreachable")
|
||||||
appReady ->
|
appReady ->
|
||||||
StartupCheck(StartupCheckState.Active, "contacting hermes")
|
StartupCheck(StartupCheckState.Active, "contacting hermes")
|
||||||
@@ -1232,7 +1345,7 @@ fun RelayApp() {
|
|||||||
when {
|
when {
|
||||||
chatReady && initialChatSettled ->
|
chatReady && initialChatSettled ->
|
||||||
StartupCheck(StartupCheckState.Done, "conversation ready")
|
StartupCheck(StartupCheckState.Done, "conversation ready")
|
||||||
startupApiUp ->
|
startupChatUp ->
|
||||||
StartupCheck(StartupCheckState.Active, "loading conversation")
|
StartupCheck(StartupCheckState.Active, "loading conversation")
|
||||||
else -> StartupCheck(StartupCheckState.Pending, "conversation")
|
else -> StartupCheck(StartupCheckState.Pending, "conversation")
|
||||||
},
|
},
|
||||||
@@ -1346,7 +1459,8 @@ fun RelayApp() {
|
|||||||
// the previous run). The pre-warm fills cold keys and refreshes
|
// the previous run). The pre-warm fills cold keys and refreshes
|
||||||
// stale (disk-hydrated) ones, then mirrors results back to disk.
|
// stale (disk-hydrated) ones, then mirrors results back to disk.
|
||||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
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
|
val connection = activeConnection ?: return@LaunchedEffect
|
||||||
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
|
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
|
||||||
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
|
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
|
||||||
@@ -1357,12 +1471,13 @@ fun RelayApp() {
|
|||||||
// The VM's cached per-connection store — the prewarm must NOT
|
// The VM's cached per-connection store — the prewarm must NOT
|
||||||
// construct its own (each instance lazily pays a multi-second
|
// construct its own (each instance lazily pays a multi-second
|
||||||
// Keystore keyset build under a process-global Tink lock).
|
// Keystore keyset build under a process-global Tink lock).
|
||||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
|
||||||
?: return@LaunchedEffect
|
|
||||||
prewarmDashboardManage(
|
prewarmDashboardManage(
|
||||||
cookieStore = cookieStore,
|
clientFactory = {
|
||||||
|
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
|
||||||
|
},
|
||||||
connectionId = connection.id,
|
connectionId = connection.id,
|
||||||
dashboardUrl = effectiveDashboardUrl,
|
dashboardUrl = effectiveDashboardUrl,
|
||||||
|
effectiveProfileName = effectiveManageProfile,
|
||||||
cacheDir = hydrateContext.cacheDir,
|
cacheDir = hydrateContext.cacheDir,
|
||||||
context = hydrateContext,
|
context = hydrateContext,
|
||||||
)
|
)
|
||||||
@@ -1460,7 +1575,7 @@ fun RelayApp() {
|
|||||||
// stays fully silent (the health "Connecting" cue used to flash here for a
|
// stays fully silent (the health "Connecting" cue used to flash here for a
|
||||||
// few seconds and then clear with no "Connected" toast).
|
// few seconds and then clear with no "Connected" toast).
|
||||||
val connectionReconnecting =
|
val connectionReconnecting =
|
||||||
globalConnectionStatus?.active == true && !postResumeQuiet &&
|
appChatRuntimeStatus is ChatRuntimeStatus.Connecting && !postResumeQuiet &&
|
||||||
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
|
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
|
||||||
// === END v0.4.1 polish ===
|
// === END v0.4.1 polish ===
|
||||||
|
|
||||||
@@ -1600,7 +1715,13 @@ fun RelayApp() {
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (!suppressGlobalChrome && !isKeyboardVisible && !showStartupSphere && !voiceUiState.voiceMode) {
|
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
|
?: activeConnection?.label
|
||||||
?: stringResource(R.string.status_no_route)
|
?: stringResource(R.string.status_no_route)
|
||||||
val transportStatus = resolveChatTransportStatus(
|
val transportStatus = resolveChatTransportStatus(
|
||||||
@@ -1613,12 +1734,9 @@ fun RelayApp() {
|
|||||||
} else {
|
} else {
|
||||||
routeLabel
|
routeLabel
|
||||||
}
|
}
|
||||||
val profileLabel = selectedProfile?.name?.takeIf { it.isNotBlank() }
|
val profileLabel = AgentDisplay.profileDisplayName(effectiveDisplayProfile)
|
||||||
?: stringResource(R.string.status_profile_default)
|
?: stringResource(R.string.status_profile_default)
|
||||||
val displayProfile = AgentDisplay.effectiveDisplayProfile(
|
val displayProfile = effectiveDisplayProfile
|
||||||
selectedProfile = selectedProfile,
|
|
||||||
profiles = agentProfiles,
|
|
||||||
)
|
|
||||||
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
|
val modelLabel = AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||||
?: AgentDisplay.displayModelName(displayProfile?.model)
|
?: AgentDisplay.displayModelName(displayProfile?.model)
|
||||||
?: AgentDisplay.displayModelName(serverModelName)
|
?: AgentDisplay.displayModelName(serverModelName)
|
||||||
@@ -1701,8 +1819,9 @@ fun RelayApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onManageSignIn = {
|
onManageSignIn = {
|
||||||
postOnboardingRoute = Screen.Manage.route
|
navController.navigate(
|
||||||
connectionViewModel.completeOnboarding()
|
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_ONBOARDING),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onOpenPermissions = {
|
onOpenPermissions = {
|
||||||
navController.navigate(Screen.PermissionsSettings.route)
|
navController.navigate(Screen.PermissionsSettings.route)
|
||||||
@@ -1717,6 +1836,16 @@ fun RelayApp() {
|
|||||||
type = NavType.BoolType
|
type = NavType.BoolType
|
||||||
defaultValue = false
|
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 ->
|
) { backStackEntry ->
|
||||||
// Responsive bubble width based on screen width. The "Blend"
|
// Responsive bubble width based on screen width. The "Blend"
|
||||||
@@ -1741,6 +1870,48 @@ fun RelayApp() {
|
|||||||
// sheet.
|
// sheet.
|
||||||
val openAgentSheetArg = backStackEntry.arguments
|
val openAgentSheetArg = backStackEntry.arguments
|
||||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
?.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)
|
val screenChatLabel = stringResource(R.string.screen_chat_label)
|
||||||
|
|
||||||
@@ -1750,6 +1921,14 @@ fun RelayApp() {
|
|||||||
voiceViewModel = voiceViewModel,
|
voiceViewModel = voiceViewModel,
|
||||||
voiceClient = voiceClient,
|
voiceClient = voiceClient,
|
||||||
maxBubbleWidth = maxBubbleWidth,
|
maxBubbleWidth = maxBubbleWidth,
|
||||||
|
voicePresentationMode = VoicePresentationMode.fromStorage(
|
||||||
|
voiceSettings.presentationMode,
|
||||||
|
),
|
||||||
|
onVoicePresentationModeChange = { mode ->
|
||||||
|
connectionSwitchScope.launch {
|
||||||
|
voicePreferences.setPresentationMode(mode)
|
||||||
|
}
|
||||||
|
},
|
||||||
openAgentSheetOnEntry = openAgentSheetArg,
|
openAgentSheetOnEntry = openAgentSheetArg,
|
||||||
onAgentSheetArgConsumed = {
|
onAgentSheetArgConsumed = {
|
||||||
backStackEntry.arguments?.putBoolean(
|
backStackEntry.arguments?.putBoolean(
|
||||||
@@ -1767,6 +1946,16 @@ fun RelayApp() {
|
|||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRepairConnection = {
|
||||||
|
navController.navigate(
|
||||||
|
Screen.Pair.route(
|
||||||
|
connectionId = activeConnectionId,
|
||||||
|
autoStart = "relay",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
// Empty-chat "needs connection" card also offers the offline
|
// Empty-chat "needs connection" card also offers the offline
|
||||||
// demo, so a skipped / never-connected first run can explore
|
// demo, so a skipped / never-connected first run can explore
|
||||||
// without leaving Chat. Safe here — this state only shows when
|
// without leaving Chat. Safe here — this state only shows when
|
||||||
@@ -1832,6 +2021,11 @@ fun RelayApp() {
|
|||||||
onNavigateToConnections = {
|
onNavigateToConnections = {
|
||||||
navController.navigate(Screen.ConnectionsSettings.route)
|
navController.navigate(Screen.ConnectionsSettings.route)
|
||||||
},
|
},
|
||||||
|
onNavigateToSignIn = {
|
||||||
|
navController.navigate(Screen.DashboardSignIn.route()) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
// Standard back: return to wherever Manage was opened
|
// Standard back: return to wherever Manage was opened
|
||||||
// from (Settings → Hermes management, the agent sheet,
|
// from (Settings → Hermes management, the agent sheet,
|
||||||
// etc.). The prior forced navigate(Chat) with
|
// etc.). The prior forced navigate(Chat) with
|
||||||
@@ -1865,6 +2059,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) {
|
composable(Screen.Terminal.route) {
|
||||||
if (coldStartAuthState is AuthState.Paired) {
|
if (coldStartAuthState is AuthState.Paired) {
|
||||||
TerminalScreen(
|
TerminalScreen(
|
||||||
@@ -2082,12 +2309,13 @@ fun RelayApp() {
|
|||||||
voiceClient = voiceClient,
|
voiceClient = voiceClient,
|
||||||
connectionId = activeConnectionId,
|
connectionId = activeConnectionId,
|
||||||
selectedProfile = selectedProfile,
|
selectedProfile = selectedProfile,
|
||||||
|
displayProfile = effectiveDisplayProfile,
|
||||||
standardVoiceAvailability = standardVoiceAvailability,
|
standardVoiceAvailability = standardVoiceAvailability,
|
||||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||||
relayVoiceReady = relayVoiceReady,
|
relayVoiceReady = relayVoiceReady,
|
||||||
dashboardUrl = voiceDashboardUrl,
|
dashboardUrl = voiceDashboardUrl,
|
||||||
dashboardCookieStoreProvider = {
|
dashboardClientProvider = { dashboardUrl ->
|
||||||
connectionViewModel.activeDashboardCookieStore()
|
connectionViewModel.dashboardClientForActive(dashboardUrl)
|
||||||
},
|
},
|
||||||
onOpenManage = {
|
onOpenManage = {
|
||||||
navController.navigate(Screen.Manage.route) {
|
navController.navigate(Screen.Manage.route) {
|
||||||
@@ -2230,18 +2458,20 @@ fun RelayApp() {
|
|||||||
navController.navigate(Screen.ConnectionDetail.route(id))
|
navController.navigate(Screen.ConnectionDetail.route(id))
|
||||||
},
|
},
|
||||||
onAddConnection = {
|
onAddConnection = {
|
||||||
connectionSwitchScope.launch {
|
val id = java.util.UUID.randomUUID().toString()
|
||||||
// Create and switch to the placeholder before
|
// Draw step 1 immediately. Placeholder persistence
|
||||||
// opening the wizard. Otherwise a fast scan or
|
// and the heavy connection-context switch continue
|
||||||
// standard save can write into the outgoing
|
// underneath the discovery UI instead of blocking
|
||||||
// connection's auth store.
|
// navigation on encrypted-store/client setup.
|
||||||
val id = connectionViewModel.beginAddConnection(
|
navController.navigate(Screen.Pair.route(connectionId = id))
|
||||||
preAllocatedId = java.util.UUID.randomUUID().toString(),
|
val job = connectionSwitchScope.launch {
|
||||||
)
|
try {
|
||||||
navController.navigate(
|
connectionViewModel.beginAddConnection(preAllocatedId = id)
|
||||||
Screen.Pair.route(connectionId = id)
|
} finally {
|
||||||
)
|
pendingAddConnectionJobs.remove(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
pendingAddConnectionJobs[id] = job
|
||||||
},
|
},
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
// Pass the VM so the list cards can read live status
|
// Pass the VM so the list cards can read live status
|
||||||
@@ -2283,7 +2513,7 @@ fun RelayApp() {
|
|||||||
onRepair = { id ->
|
onRepair = { id ->
|
||||||
connectionSwitchScope.launch {
|
connectionSwitchScope.launch {
|
||||||
connectionViewModel.switchConnection(id).join()
|
connectionViewModel.switchConnection(id).join()
|
||||||
navController.navigate(Screen.Pair.route(id))
|
navController.navigate(Screen.Pair.route(id, autoStart = "relay"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRevoke = { id ->
|
onRevoke = { id ->
|
||||||
@@ -2333,9 +2563,14 @@ fun RelayApp() {
|
|||||||
?.getString(Screen.Pair.ARG_CONNECTION_ID)
|
?.getString(Screen.Pair.ARG_CONNECTION_ID)
|
||||||
val autoStartArg = backStackEntry.arguments
|
val autoStartArg = backStackEntry.arguments
|
||||||
?.getString(Screen.Pair.ARG_AUTO_START)
|
?.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(
|
com.hermesandroid.relay.ui.screens.PairScreen(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
autoStart = autoStartArg,
|
autoStart = autoStartArg,
|
||||||
|
setupReady = pairSetupReady,
|
||||||
// Offer demo only on the bare "Connect" entry (the
|
// Offer demo only on the bare "Connect" entry (the
|
||||||
// "No Hermes connection" path) — not on add-connection /
|
// "No Hermes connection" path) — not on add-connection /
|
||||||
// re-pair flows, which have a placeholder connection in
|
// re-pair flows, which have a placeholder connection in
|
||||||
@@ -2354,8 +2589,9 @@ fun RelayApp() {
|
|||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
onManageSignIn = {
|
onManageSignIn = {
|
||||||
navController.popBackStack()
|
navController.navigate(
|
||||||
navController.navigate(Screen.Manage.route) {
|
Screen.DashboardSignIn.route(Screen.DashboardSignIn.SOURCE_PAIR),
|
||||||
|
) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2367,6 +2603,10 @@ fun RelayApp() {
|
|||||||
// never got a pairedAt stamp.
|
// never got a pairedAt stamp.
|
||||||
if (connectionIdArg != null) {
|
if (connectionIdArg != null) {
|
||||||
connectionSwitchScope.launch {
|
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)
|
connectionViewModel.discardPlaceholderConnection(connectionIdArg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2413,6 +2653,17 @@ fun RelayApp() {
|
|||||||
onNavigateToRealtimeVoice = {
|
onNavigateToRealtimeVoice = {
|
||||||
navController.navigate(Screen.RealtimeVoiceTest.route)
|
navController.navigate(Screen.RealtimeVoiceTest.route)
|
||||||
},
|
},
|
||||||
|
onNavigateToImageGenerationLab = {
|
||||||
|
terminalAppContext.startActivity(
|
||||||
|
android.content.Intent().apply {
|
||||||
|
setClassName(
|
||||||
|
terminalAppContext,
|
||||||
|
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
|
||||||
|
)
|
||||||
|
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.RealtimeVoiceTest.route) {
|
composable(Screen.RealtimeVoiceTest.route) {
|
||||||
@@ -2502,9 +2753,9 @@ fun RelayApp() {
|
|||||||
// doesn't happen to match the one we're inspecting
|
// doesn't happen to match the one we're inspecting
|
||||||
// (shouldn't normally happen since the entry is
|
// (shouldn't normally happen since the entry is
|
||||||
// keyed off the same Profile).
|
// keyed off the same Profile).
|
||||||
val selectedProfile by connectionViewModel
|
val inspectorDisplayProfile by connectionViewModel
|
||||||
.selectedProfile.collectAsState()
|
.effectiveDisplayProfile.collectAsState()
|
||||||
val modelLabel = selectedProfile
|
val modelLabel = inspectorDisplayProfile
|
||||||
?.takeIf { it.name == profileNameArg }
|
?.takeIf { it.name == profileNameArg }
|
||||||
?.model
|
?.model
|
||||||
|
|
||||||
|
|||||||
+749
-180
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
@@ -261,8 +262,12 @@ fun AgentTextFlow(
|
|||||||
motionEnabled: Boolean,
|
motionEnabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val flowStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)
|
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
val flowColor = MaterialTheme.colorScheme.onSurfaceVariant
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
)
|
||||||
|
val flowColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
// Readable, non-faded mirror of the visible tail — used as the live-region
|
// Readable, non-faded mirror of the visible tail — used as the live-region
|
||||||
// text on both paths so assistive tech hears the words.
|
// text on both paths so assistive tech hears the words.
|
||||||
|
|||||||
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
|
|||||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||||
if (!content.contains("![")) return content to emptyList()
|
if (!content.contains("![")) return content to emptyList()
|
||||||
val images = mutableListOf<ChatInlineImage>()
|
val images = mutableListOf<ChatInlineImage>()
|
||||||
|
var inlineDataImages = 0
|
||||||
|
var inlineDataBytes = 0L
|
||||||
|
var inlineOverflowNoticeAdded = false
|
||||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||||
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
|
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
|
||||||
val alt = m.groupValues[2].trim()
|
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(
|
images += ChatInlineImage(
|
||||||
alt = alt,
|
alt = alt,
|
||||||
src = normalizeImageSrc(m.groupValues[3].trim()),
|
src = src,
|
||||||
sensitive = spoilerWrapped || isSensitiveAltText(alt),
|
sensitive = spoilerWrapped || isSensitiveAltText(alt),
|
||||||
)
|
)
|
||||||
|
if (inlineDataSize != null) {
|
||||||
|
inlineDataImages += 1
|
||||||
|
inlineDataBytes += inlineDataSize
|
||||||
|
}
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
if (images.isEmpty()) return content to emptyList()
|
if (images.isEmpty()) return content to emptyList()
|
||||||
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
|
|||||||
return s.startsWith("http://") || s.startsWith("https://")
|
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
|
* 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.
|
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
|
||||||
@@ -232,6 +258,7 @@ fun ChatInlineImages(
|
|||||||
images.forEach { image ->
|
images.forEach { image ->
|
||||||
when {
|
when {
|
||||||
image.isRemote() -> RemoteChatImage(image, maxWidth)
|
image.isRemote() -> RemoteChatImage(image, maxWidth)
|
||||||
|
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
|
||||||
// A server-local file the agent referenced — fetch it through
|
// A server-local file the agent referenced — fetch it through
|
||||||
// /media/by-path and render inline; on failure the notice shows
|
// /media/by-path and render inline; on failure the notice shows
|
||||||
// the ACTUAL reason (for debugging) instead of a generic message.
|
// 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
|
@Composable
|
||||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||||
var viewerOpen by remember { mutableStateOf(false) }
|
var viewerOpen by remember { mutableStateOf(false) }
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
|
|||||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||||
* !isStreaming -> VOICE // GraphicEq, primary
|
* !isStreaming -> VOICE // GraphicEq, primary
|
||||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
* 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
|
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
|
|||||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||||
* reflows the bar, the overline doesn't.
|
* reflows the bar, the overline doesn't.
|
||||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
* - [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.
|
* is STEER, muted otherwise. Null collapses the row.
|
||||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||||
@@ -191,7 +191,7 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
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) {
|
AnimatedVisibility(visible = caption != null) {
|
||||||
Text(
|
Text(
|
||||||
text = caption ?: lastCaption.orEmpty(),
|
text = caption ?: lastCaption.orEmpty(),
|
||||||
|
|||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
package com.hermesandroid.relay.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.hermesandroid.relay.R
|
||||||
|
import com.hermesandroid.relay.data.Attachment
|
||||||
|
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable, presentation-only summary for a message's attachment group.
|
||||||
|
*
|
||||||
|
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
|
||||||
|
* by the existing attachment pipeline; this helper only chooses the compact
|
||||||
|
* label shown while that pipeline is folded away.
|
||||||
|
*/
|
||||||
|
internal data class AttachmentGroupSummary(
|
||||||
|
val count: Int,
|
||||||
|
val firstName: String?,
|
||||||
|
val firstType: AttachmentRenderMode,
|
||||||
|
val remainingCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
|
||||||
|
val first = attachments.firstOrNull() ?: return null
|
||||||
|
return AttachmentGroupSummary(
|
||||||
|
count = attachments.size,
|
||||||
|
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
|
||||||
|
firstType = first.renderMode,
|
||||||
|
remainingCount = (attachments.size - 1).coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slack-style disclosure for all attachments belonging to one message.
|
||||||
|
*
|
||||||
|
* The fold state is saveable and keyed by the message's stable Compose
|
||||||
|
* identity, so attachment lifecycle updates do not unexpectedly reopen a
|
||||||
|
* group the user collapsed. The compact header always remains available,
|
||||||
|
* making preview, retry, download, and file actions recoverable with one tap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CollapsibleAttachmentGroup(
|
||||||
|
messageKey: String,
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val summary = attachmentGroupSummary(attachments) ?: return
|
||||||
|
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
|
||||||
|
val stateLabel = stringResource(
|
||||||
|
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
|
||||||
|
)
|
||||||
|
val actionLabel = stringResource(
|
||||||
|
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
|
||||||
|
)
|
||||||
|
val typeLabel = stringResource(summary.firstType.labelResource())
|
||||||
|
val detail = when {
|
||||||
|
summary.firstName != null && summary.remainingCount > 0 ->
|
||||||
|
stringResource(
|
||||||
|
R.string.attachment_group_named_more,
|
||||||
|
summary.firstName,
|
||||||
|
typeLabel,
|
||||||
|
summary.remainingCount,
|
||||||
|
)
|
||||||
|
summary.firstName != null ->
|
||||||
|
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
|
||||||
|
summary.remainingCount > 0 ->
|
||||||
|
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
|
||||||
|
else -> typeLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.animateContentSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("attachment-group-toggle-$messageKey")
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
contentDescription = actionLabel
|
||||||
|
role = Role.Button
|
||||||
|
stateDescription = stateLabel
|
||||||
|
},
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = pluralStringResource(
|
||||||
|
R.plurals.attachment_group_count,
|
||||||
|
summary.count,
|
||||||
|
summary.count,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = expanded) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("attachment-group-content-$messageKey"),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
|
||||||
|
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
|
||||||
|
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
|
||||||
|
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
|
||||||
|
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
|
||||||
|
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
|
||||||
|
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
|
||||||
|
}
|
||||||
+1353
-60
File diff suppressed because it is too large
Load Diff
@@ -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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Lan
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Shield
|
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.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.data.Connection
|
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.isEncryptedOverlayRoute
|
||||||
import com.hermesandroid.relay.data.isKnownRole
|
import com.hermesandroid.relay.data.isKnownRole
|
||||||
import com.hermesandroid.relay.data.isTlsUrl
|
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.network.shared.RouteProbeOutcome
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
* 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
|
* on app start, tried first on every resolve; cleared by
|
||||||
* [onClearPreferred].
|
* [onClearPreferred].
|
||||||
*
|
*
|
||||||
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
|
* Each route stays visible, while its Dashboard/API/Relay topology uses
|
||||||
* do NOT collapse into a master row. The card itself is wrapped in a
|
* progressive disclosure: the active route opens by default and fallback
|
||||||
* [SettingsExpandableCard] by the caller for page layout hygiene.
|
* routes keep a compact surface-and-port summary.
|
||||||
*
|
*
|
||||||
* Not visible on legacy installs: when [endpoints] is empty we render a
|
* Not visible on legacy installs: when [endpoints] is empty we render a
|
||||||
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
||||||
@@ -106,6 +114,9 @@ fun EndpointsCard(
|
|||||||
* resolver's cache-key scheme.
|
* resolver's cache-key scheme.
|
||||||
*/
|
*/
|
||||||
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
|
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
|
* Route management — the standard path's manual equivalent of a v3 QR's
|
||||||
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
* `endpoints` array. Null callbacks hide the corresponding affordance.
|
||||||
@@ -170,11 +181,17 @@ fun EndpointsCard(
|
|||||||
candidate = candidate,
|
candidate = candidate,
|
||||||
isActive = activeEndpoint != null &&
|
isActive = activeEndpoint != null &&
|
||||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
|
||||||
activeEndpoint.api.port == candidate.api.port,
|
|
||||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||||
isProbing = isProbing,
|
isProbing = isProbing,
|
||||||
outcome = outcomeFor(candidate),
|
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) },
|
onUseNow = { onUseNow(candidate) },
|
||||||
onPrefer = { onPreferEndpoint(candidate) },
|
onPrefer = { onPreferEndpoint(candidate) },
|
||||||
onClearPrefer = onClearPreferred,
|
onClearPrefer = onClearPreferred,
|
||||||
@@ -219,6 +236,8 @@ private fun EndpointRow(
|
|||||||
isPreferred: Boolean,
|
isPreferred: Boolean,
|
||||||
isProbing: Boolean = false,
|
isProbing: Boolean = false,
|
||||||
outcome: RouteProbeOutcome? = null,
|
outcome: RouteProbeOutcome? = null,
|
||||||
|
dashboardAuthenticated: Boolean? = null,
|
||||||
|
dashboardSignInRequired: Boolean = false,
|
||||||
onUseNow: () -> Unit,
|
onUseNow: () -> Unit,
|
||||||
onPrefer: () -> Unit,
|
onPrefer: () -> Unit,
|
||||||
onClearPrefer: () -> Unit,
|
onClearPrefer: () -> Unit,
|
||||||
@@ -230,6 +249,7 @@ private fun EndpointRow(
|
|||||||
var menuOpen by remember { mutableStateOf(false) }
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||||
var confirmRemove by remember { mutableStateOf(false) }
|
var confirmRemove by remember { mutableStateOf(false) }
|
||||||
|
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
|
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 {
|
when {
|
||||||
isProbing -> Text(
|
isProbing -> Text(
|
||||||
text = stringResource(R.string.endpoints_checking),
|
text = stringResource(R.string.endpoints_checking),
|
||||||
@@ -307,6 +316,47 @@ private fun EndpointRow(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
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
|
// 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) {
|
if (confirmRemove && onRemove != null) {
|
||||||
@@ -400,7 +459,7 @@ private fun EndpointRow(
|
|||||||
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
|
title = { Text(stringResource(R.string.endpoints_remove_route_title, candidate.displayLabel())) },
|
||||||
text = {
|
text = {
|
||||||
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -421,7 +480,7 @@ private fun EndpointRow(
|
|||||||
pinDialogText?.let { body ->
|
pinDialogText?.let { body ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { pinDialogText = null },
|
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(
|
Text(
|
||||||
text = body,
|
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
|
@Composable
|
||||||
private fun ActiveChip(label: String) {
|
private fun ActiveChip(label: String) {
|
||||||
Row(
|
Row(
|
||||||
@@ -521,7 +680,7 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
|||||||
* be classified independently before it's the active route.
|
* be classified independently before it's the active route.
|
||||||
*/
|
*/
|
||||||
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||||
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
|
isTlsUrl(primaryRouteUrl().orEmpty()) -> SurfaceSecurityKind.Tls
|
||||||
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
|
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
|
||||||
else -> SurfaceSecurityKind.Plain
|
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
|
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||||
* set up LAN ↔ Tailscale roaming without the plugin.
|
* set up LAN ↔ Tailscale roaming without the plugin.
|
||||||
*
|
*
|
||||||
* The relay URL is derived from the API URL (same `:8767` convention the
|
* The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
|
||||||
* wizard uses); routes that need a custom relay URL still come from a QR.
|
* 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 +
|
* @param original null = add a new route; non-null = edit (pre-fills role +
|
||||||
* URL, keeps the stored priority).
|
* 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
|
* receives a user-facing error string to render inline, or null on
|
||||||
* success (the dialog then closes itself).
|
* success (the dialog then closes itself).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RouteEditorDialog(
|
fun RouteEditorDialog(
|
||||||
original: EndpointCandidate?,
|
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,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
val knownRoles = listOf("tailscale", "public")
|
val knownRoles = listOf("tailscale", "public")
|
||||||
var selectedRole by remember {
|
var selectedRole by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -561,7 +723,7 @@ fun RouteEditorDialog(
|
|||||||
original?.role?.takeIf { it.lowercase() !in knownRoles }.orEmpty(),
|
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 errorText by remember { mutableStateOf<String?>(null) }
|
||||||
var saving by remember { mutableStateOf(false) }
|
var saving by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -615,13 +777,20 @@ fun RouteEditorDialog(
|
|||||||
// Live preview of what will actually be saved — scheme and
|
// Live preview of what will actually be saved — scheme and
|
||||||
// port defaults applied — so "what, which port, http or
|
// port defaults applied — so "what, which port, http or
|
||||||
// https?" is answered before Save, not after a failed probe.
|
// 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 {
|
url.takeIf { it.isNotBlank() }?.let {
|
||||||
Connection.endpointCandidateFromApiUrl(
|
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
|
||||||
|
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
|
||||||
|
Connection.endpointCandidateFromDashboardUrl(
|
||||||
role = effectiveRole.ifBlank { "custom" },
|
role = effectiveRole.ifBlank { "custom" },
|
||||||
priority = original?.priority ?: 1,
|
priority = original?.priority ?: 1,
|
||||||
apiServerUrl = Connection.normalizeApiUrlInput(it),
|
dashboardUrl = dashboardUrl,
|
||||||
relayUrl = "",
|
apiServerUrl = apiUrl,
|
||||||
|
relayUrl = if (relayEnabled) {
|
||||||
|
apiUrl?.let(Connection::deriveDefaultRelayUrl)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,7 +812,7 @@ fun RouteEditorDialog(
|
|||||||
url.isBlank() ->
|
url.isBlank() ->
|
||||||
supportingBlank
|
supportingBlank
|
||||||
previewCandidate != null ->
|
previewCandidate != null ->
|
||||||
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.api.url)
|
stringResource(R.string.endpoints_url_supporting_preview, previewCandidate.primaryRouteUrl().orEmpty())
|
||||||
else ->
|
else ->
|
||||||
supportingEnter
|
supportingEnter
|
||||||
},
|
},
|
||||||
@@ -652,6 +821,21 @@ fun RouteEditorDialog(
|
|||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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 = {
|
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__"
|
private const val CUSTOM_ROLE = "__custom__"
|
||||||
|
|||||||
+1107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
package com.hermesandroid.relay.ui.components
|
||||||
|
|
||||||
|
import java.util.Base64
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
|
||||||
|
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
|
||||||
|
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
|
||||||
|
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
|
||||||
|
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
||||||
|
private const val MAX_HEADER_CHARS = 64
|
||||||
|
private val inlineImageDecodeMutex = Mutex()
|
||||||
|
private val DATA_IMAGE_HEADER = Regex(
|
||||||
|
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class DecodedInlineImageData(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val mime: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict decoder for upstream API-server inline images.
|
||||||
|
*
|
||||||
|
* Only the five raster formats upstream emits for this path are accepted. The
|
||||||
|
* encoded input is bounded before allocation, strict base64 is required, and
|
||||||
|
* the decoded magic bytes must agree with the declared MIME type.
|
||||||
|
*/
|
||||||
|
internal fun decodeInlineImageDataUrl(
|
||||||
|
source: String,
|
||||||
|
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
|
||||||
|
): DecodedInlineImageData? {
|
||||||
|
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
|
||||||
|
val comma = source.indexOf(',')
|
||||||
|
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||||
|
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
|
||||||
|
?.groupValues?.get(1)?.lowercase() ?: return null
|
||||||
|
val encoded = source.substring(comma + 1)
|
||||||
|
if (encoded.isEmpty()) return null
|
||||||
|
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
|
||||||
|
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
|
||||||
|
if (encoded.length.toLong() > maxEncoded) return null
|
||||||
|
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
|
||||||
|
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
|
||||||
|
return DecodedInlineImageData(bytes, mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conservative decoded-size estimate that does not allocate a byte array. */
|
||||||
|
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
|
||||||
|
if (!source.startsWith("data:image/", ignoreCase = true)) return null
|
||||||
|
val comma = source.indexOf(',')
|
||||||
|
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
|
||||||
|
val encodedLength = source.length.toLong() - comma - 1L
|
||||||
|
if (encodedLength <= 0) return null
|
||||||
|
val padding = when {
|
||||||
|
source.endsWith("==") -> 2L
|
||||||
|
source.endsWith('=') -> 1L
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
return ((encodedLength + 3L) / 4L) * 3L - padding
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
|
||||||
|
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
|
||||||
|
* allocations from overlapping.
|
||||||
|
*/
|
||||||
|
internal suspend fun <T> withInlineImageDecodeLock(
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
block: () -> T,
|
||||||
|
): T = withContext(dispatcher) {
|
||||||
|
inlineImageDecodeMutex.withLock { block() }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun decodeInlineImageDataUrlOffMain(
|
||||||
|
source: String,
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
|
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
|
||||||
|
decodeInlineImageDataUrl(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
|
||||||
|
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
|
||||||
|
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
|
||||||
|
)
|
||||||
|
"image/jpeg" -> bytes.size >= 3 &&
|
||||||
|
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
|
||||||
|
"image/webp" -> bytes.size >= 12 &&
|
||||||
|
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
|
||||||
|
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
|
||||||
|
"image/gif" -> bytes.size >= 6 &&
|
||||||
|
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
|
||||||
|
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
|
||||||
|
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
|
||||||
|
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
|
||||||
|
*/
|
||||||
|
internal fun inlineImageSampleSize(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
|
||||||
|
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
|
||||||
|
): Int? {
|
||||||
|
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
|
||||||
|
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
|
||||||
|
) return null
|
||||||
|
var sample = 1
|
||||||
|
while (true) {
|
||||||
|
val sampledWidth = (width + sample - 1L) / sample
|
||||||
|
val sampledHeight = (height + sample - 1L) / sample
|
||||||
|
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
|
||||||
|
sampledWidth <= maxPixels / sampledHeight
|
||||||
|
) return sample
|
||||||
|
if (sample >= 1 shl 15) return null
|
||||||
|
sample *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
|||||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
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.MarkdownTable
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
||||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||||
@@ -69,6 +67,11 @@ fun MarkdownContent(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = LocalBrand.current.isDark
|
val isDarkTheme = LocalBrand.current.isDark
|
||||||
|
val chatBodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
color = textColor,
|
||||||
|
)
|
||||||
val highlightsBuilder = remember(isDarkTheme) {
|
val highlightsBuilder = remember(isDarkTheme) {
|
||||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||||
}
|
}
|
||||||
@@ -89,7 +92,8 @@ fun MarkdownContent(
|
|||||||
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
||||||
// live font-picker still applies) and is capped so the largest heading is
|
// live font-picker still applies) and is capped so the largest heading is
|
||||||
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings.
|
// proportionate to the 15sp body, matching Discord / GitHub-mobile
|
||||||
|
// in-message headings.
|
||||||
typography = markdownTypography(
|
typography = markdownTypography(
|
||||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||||
@@ -110,16 +114,17 @@ fun MarkdownContent(
|
|||||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||||
color = textColor.copy(alpha = 0.85f),
|
color = textColor.copy(alpha = 0.85f),
|
||||||
),
|
),
|
||||||
// Prose, list items, and quotes all sit at the 14sp body size so a
|
// Prose, list items, and quotes share a 15sp/21sp reading rhythm.
|
||||||
// paragraph and the bullet list under it share one rhythm — the library
|
// The library default 'text'/list role is bodyLarge (16sp), while
|
||||||
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
|
// bodyMedium was previously 14sp and unnecessarily small for long chat.
|
||||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
paragraph = chatBodyStyle,
|
||||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
text = chatBodyStyle,
|
||||||
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
bullet = chatBodyStyle,
|
||||||
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
ordered = chatBodyStyle,
|
||||||
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
list = chatBodyStyle,
|
||||||
quote = MaterialTheme.typography.bodyMedium.copy(
|
quote = chatBodyStyle.copy(
|
||||||
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = textColor.copy(alpha = 0.9f),
|
||||||
),
|
),
|
||||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||||
// + the tinted chip already signal "code" without also shrinking it, and
|
// + the tinted chip already signal "code" without also shrinking it, and
|
||||||
@@ -127,7 +132,7 @@ fun MarkdownContent(
|
|||||||
code = MaterialTheme.typography.bodySmall.copy(
|
code = MaterialTheme.typography.bodySmall.copy(
|
||||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = textColor,
|
||||||
),
|
),
|
||||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||||
@@ -152,7 +157,7 @@ fun MarkdownContent(
|
|||||||
),
|
),
|
||||||
components = markdownComponents(
|
components = markdownComponents(
|
||||||
codeBlock = {
|
codeBlock = {
|
||||||
MarkdownHighlightedCodeBlock(
|
SafeMarkdownHighlightedCodeBlock(
|
||||||
content = it.content,
|
content = it.content,
|
||||||
node = it.node,
|
node = it.node,
|
||||||
highlightsBuilder = highlightsBuilder,
|
highlightsBuilder = highlightsBuilder,
|
||||||
@@ -160,7 +165,7 @@ fun MarkdownContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
codeFence = {
|
codeFence = {
|
||||||
MarkdownHighlightedCodeFence(
|
SafeMarkdownHighlightedCodeFence(
|
||||||
content = it.content,
|
content = it.content,
|
||||||
node = it.node,
|
node = it.node,
|
||||||
highlightsBuilder = highlightsBuilder,
|
highlightsBuilder = highlightsBuilder,
|
||||||
@@ -304,7 +309,10 @@ fun StreamingMarkdownContent(
|
|||||||
// code and deliberately spaced prose are not altered.
|
// code and deliberately spaced prose are not altered.
|
||||||
text = content.withoutLeadingBlankLines(),
|
text = content.withoutLeadingBlankLines(),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ fun MessageBubble(
|
|||||||
* happening.
|
* happening.
|
||||||
*/
|
*/
|
||||||
recoveringAnswer: Boolean = false,
|
recoveringAnswer: Boolean = false,
|
||||||
|
imageGenerationStylePreference: String = "rotate",
|
||||||
|
imageGenerationRotationIndex: Int = 0,
|
||||||
) {
|
) {
|
||||||
val isUser = message.role == MessageRole.USER
|
val isUser = message.role == MessageRole.USER
|
||||||
val isSystem = message.role == MessageRole.SYSTEM
|
val isSystem = message.role == MessageRole.SYSTEM
|
||||||
@@ -172,7 +174,7 @@ fun MessageBubble(
|
|||||||
|
|
||||||
val textColor = when (message.role) {
|
val textColor = when (message.role) {
|
||||||
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
||||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
|
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurface
|
||||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +207,28 @@ fun MessageBubble(
|
|||||||
extractChatInlineImages(message.content)
|
extractChatInlineImages(message.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||||
|
toolCalls = message.toolCalls,
|
||||||
|
isStreaming = message.isStreaming,
|
||||||
|
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||||
|
)
|
||||||
|
val hasImageGenerationCall = remember(message.toolCalls) {
|
||||||
|
message.toolCalls.any {
|
||||||
|
it.name.trim().lowercase() == "image_generate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val imageGenerationStartMillis = remember(message.toolCalls) {
|
||||||
|
imageGenerationStartedAt(message.toolCalls)
|
||||||
|
}
|
||||||
|
val imageGenerationVisualStyle = remember(
|
||||||
|
imageGenerationStylePreference,
|
||||||
|
imageGenerationRotationIndex,
|
||||||
|
) {
|
||||||
|
resolveImageGenerationVisualStyle(
|
||||||
|
preference = imageGenerationStylePreference,
|
||||||
|
rotationIndex = imageGenerationRotationIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||||
// renderers below, sourced as locally as possible (here, not threaded
|
// renderers below, sourced as locally as possible (here, not threaded
|
||||||
@@ -291,6 +315,30 @@ fun MessageBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isUser && !isSystem && showThinking) {
|
||||||
|
message.moaReferences.forEach { reference ->
|
||||||
|
ThinkingBlock(
|
||||||
|
thinkingContent = if (reference.available) {
|
||||||
|
reference.text
|
||||||
|
} else {
|
||||||
|
"Advisor unavailable."
|
||||||
|
},
|
||||||
|
isStreaming = false,
|
||||||
|
headerText = buildString {
|
||||||
|
append("Advisor ")
|
||||||
|
append(reference.index)
|
||||||
|
reference.count?.let { append("/").append(it) }
|
||||||
|
append(" · ")
|
||||||
|
append(reference.label)
|
||||||
|
},
|
||||||
|
accessibilityLabel = "Mixture of Agents advisor response",
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = maxBubbleWidth)
|
||||||
|
.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Message bubble.
|
// Message bubble.
|
||||||
//
|
//
|
||||||
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
||||||
@@ -309,6 +357,7 @@ fun MessageBubble(
|
|||||||
val showBubble = isUser || isSystem ||
|
val showBubble = isUser || isSystem ||
|
||||||
message.content.isNotBlank() ||
|
message.content.isNotBlank() ||
|
||||||
message.isStreaming ||
|
message.isStreaming ||
|
||||||
|
showImageGeneration ||
|
||||||
message.cards.isNotEmpty() ||
|
message.cards.isNotEmpty() ||
|
||||||
message.attachments.isNotEmpty() ||
|
message.attachments.isNotEmpty() ||
|
||||||
inlineImages.isNotEmpty()
|
inlineImages.isNotEmpty()
|
||||||
@@ -420,7 +469,10 @@ fun MessageBubble(
|
|||||||
// Plain text for user and system messages
|
// Plain text for user and system messages
|
||||||
Text(
|
Text(
|
||||||
text = message.content,
|
text = message.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 21.sp,
|
||||||
|
),
|
||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -478,35 +530,78 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachments — two or more loaded images collapse into one
|
// Image generation owns the bubble's progress slot. Keep the
|
||||||
// grid + swipe-across gallery. Every other item stays on the
|
// selected progress treatment mounted under the real result,
|
||||||
// unified InboundAttachmentCard path, and layout items retain
|
// then reveal the same collapsible attachment surface without
|
||||||
// their original ChatMessage.attachments indices so retry /
|
// rebuilding the surrounding message bubble.
|
||||||
// manual-fetch callbacks cannot drift after grouping.
|
if (hasImageGenerationCall) {
|
||||||
if (message.attachments.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
val attachmentItems = remember(message.attachments) {
|
ImageGenerationResultTransition(
|
||||||
attachmentLayoutItems(message.attachments)
|
generating = showImageGeneration,
|
||||||
}
|
startedAtMillis = imageGenerationStartMillis,
|
||||||
attachmentItems.forEach { item ->
|
visualStyle = imageGenerationVisualStyle,
|
||||||
when (item) {
|
) {
|
||||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
if (message.attachments.isNotEmpty()) {
|
||||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
CollapsibleAttachmentGroup(
|
||||||
maxWidth = maxBubbleWidth - 24.dp,
|
messageKey = message.uiKey,
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
attachments = message.attachments,
|
||||||
)
|
) {
|
||||||
is AttachmentLayoutItem.Single -> {
|
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||||
val index = item.attachmentIndex
|
attachmentItems.forEach { item ->
|
||||||
InboundAttachmentCard(
|
when (item) {
|
||||||
attachment = message.attachments[index],
|
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||||
onRetry = { onAttachmentRetry(message.id, index) },
|
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
maxWidth = maxBubbleWidth - 24.dp,
|
||||||
maxWidth = maxBubbleWidth - 24.dp,
|
modifier = Modifier.padding(vertical = 2.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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))
|
||||||
|
CollapsibleAttachmentGroup(
|
||||||
|
messageKey = message.uiKey,
|
||||||
|
attachments = message.attachments,
|
||||||
|
) {
|
||||||
|
// Two or more loaded images collapse into one grid +
|
||||||
|
// swipe-across gallery. Every other item stays on the
|
||||||
|
// unified attachment path, retaining original indices.
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming indicator — only while awaiting the first token. Once
|
// Streaming indicator — only while awaiting the first token. Once
|
||||||
@@ -514,7 +609,11 @@ fun MessageBubble(
|
|||||||
// signal, so the pulsing dots stop (Messenger/Telegram drop the
|
// signal, so the pulsing dots stop (Messenger/Telegram drop the
|
||||||
// typing bubble the moment content appears) instead of throbbing
|
// typing bubble the moment content appears) instead of throbbing
|
||||||
// under the text for the whole turn.
|
// 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
|
// After a few seconds with no content yet, escalate the bare
|
||||||
// dots to a labeled "Still working…" so a slow first token
|
// dots to a labeled "Still working…" so a slow first token
|
||||||
// never reads as a hang on the SSE / sessions paths.
|
// never reads as a hang on the SSE / sessions paths.
|
||||||
|
|||||||
@@ -219,6 +219,16 @@ data class RelayPairing(
|
|||||||
val transportHint: String? = null,
|
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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
isLenient = true
|
isLenient = true
|
||||||
@@ -258,8 +268,8 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
|||||||
return try {
|
return try {
|
||||||
// Quick check: must contain a `host` field and be valid JSON. We no
|
// Quick check: must contain a `host` field and be valid JSON. We no
|
||||||
// longer reject based on the `hermes` version int — future v4+ QRs
|
// 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
|
// should still parse so wire-format growth does not require an app
|
||||||
// whole release to keep up with wire-format growth.
|
// release for every compatible payload version.
|
||||||
val obj = json.decodeFromString<JsonObject>(raw)
|
val obj = json.decodeFromString<JsonObject>(raw)
|
||||||
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
|
val version = obj["hermes"]?.jsonPrimitive?.intOrNull ?: 1
|
||||||
if (version < 1) return null
|
if (version < 1) return null
|
||||||
@@ -532,7 +542,8 @@ private fun mapBoxToViewport(
|
|||||||
@Composable
|
@Composable
|
||||||
fun QrPairingScanner(
|
fun QrPairingScanner(
|
||||||
onPairingDetected: (HermesPairingPayload) -> Unit,
|
onPairingDetected: (HermesPairingPayload) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit,
|
||||||
|
relayOnly: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
@@ -628,7 +639,10 @@ fun QrPairingScanner(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.align(Alignment.Center)
|
modifier = Modifier.align(Alignment.Center)
|
||||||
@@ -740,7 +754,11 @@ fun QrPairingScanner(
|
|||||||
) {
|
) {
|
||||||
val rawValue = barcode.rawValue ?: continue
|
val rawValue = barcode.rawValue ?: continue
|
||||||
val payload = parseHermesPairingQr(rawValue)
|
val payload = parseHermesPairingQr(rawValue)
|
||||||
if (payload != null && hasDetected.compareAndSet(false, true)) {
|
if (
|
||||||
|
payload != null &&
|
||||||
|
(!relayOnly || payload.hasUsableRelayPairing()) &&
|
||||||
|
hasDetected.compareAndSet(false, true)
|
||||||
|
) {
|
||||||
lockedPayload = payload
|
lockedPayload = payload
|
||||||
return@addOnSuccessListener
|
return@addOnSuccessListener
|
||||||
}
|
}
|
||||||
@@ -798,13 +816,19 @@ fun QrPairingScanner(
|
|||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
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 /
|
* The app's own chats — no badge (they're "your" conversations from this app /
|
||||||
* desktop / API, not a distinct gateway lane).
|
* 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
|
* 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
|
* the phone **Thread** (which renders its own thread-spool chip). External
|
||||||
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
|
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
|
||||||
* small colored chip so the drawer reads like the desktop's per-channel tags.
|
* 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,
|
* Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
|
||||||
* webhook, phone.
|
* cron, webhook, phone.
|
||||||
*/
|
*/
|
||||||
fun sourceBadge(source: String?): SourceBadge? {
|
fun sourceBadge(source: String?): SourceBadge? {
|
||||||
val s = source?.trim()?.lowercase() ?: return null
|
val s = source?.trim()?.lowercase() ?: return null
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ fun ThinkingBlock(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||||
timestamp: Long? = null,
|
timestamp: Long? = null,
|
||||||
|
headerText: String? = null,
|
||||||
|
accessibilityLabel: String = "Thinking",
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(isStreaming) }
|
var expanded by remember { mutableStateOf(isStreaming) }
|
||||||
val locale = LocalLocale.current.platformLocale
|
val locale = LocalLocale.current.platformLocale
|
||||||
@@ -65,13 +67,13 @@ fun ThinkingBlock(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Psychology,
|
imageVector = Icons.Filled.Psychology,
|
||||||
contentDescription = "Thinking",
|
contentDescription = accessibilityLabel,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
tint = MaterialTheme.colorScheme.tertiary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (isStreaming) "Thinking..." else "Thought process",
|
text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.GraphicEq
|
import androidx.compose.material.icons.filled.GraphicEq
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -73,8 +74,10 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
|
import com.hermesandroid.relay.data.HermesCardAction
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
import com.hermesandroid.relay.data.ToolCall
|
import com.hermesandroid.relay.data.ToolCall
|
||||||
|
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||||
@@ -146,7 +149,8 @@ fun VoiceModeOverlay(
|
|||||||
voiceOutputEnabled: Boolean? = null,
|
voiceOutputEnabled: Boolean? = null,
|
||||||
voiceOutputFallbackEnabled: Boolean? = null,
|
voiceOutputFallbackEnabled: Boolean? = null,
|
||||||
onOverlayRequest: () -> Unit = {},
|
onOverlayRequest: () -> Unit = {},
|
||||||
onCompactModeChange: (Boolean) -> Unit = {},
|
presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||||
|
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||||
// === END PHASE3-voice-mode-transcript ===
|
// === END PHASE3-voice-mode-transcript ===
|
||||||
// === v0.4.1 JIT permission-denied chip ===
|
// === v0.4.1 JIT permission-denied chip ===
|
||||||
// Tapped when the user clicks the permission-denied chip. Default no-op
|
// Tapped when the user clicks the permission-denied chip. Default no-op
|
||||||
@@ -159,22 +163,21 @@ fun VoiceModeOverlay(
|
|||||||
onBackgroundRunCancel: () -> Unit = {},
|
onBackgroundRunCancel: () -> Unit = {},
|
||||||
onBackgroundRunTap: () -> Unit = {},
|
onBackgroundRunTap: () -> Unit = {},
|
||||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||||
|
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit =
|
||||||
|
{ _, _, _ -> },
|
||||||
|
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit =
|
||||||
|
{ _, _, _ -> },
|
||||||
// === END v0.4.1 ===
|
// === END v0.4.1 ===
|
||||||
) {
|
) {
|
||||||
val surface = MaterialTheme.colorScheme.surface
|
val surface = MaterialTheme.colorScheme.surface
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
var controlsExpanded by remember { mutableStateOf(false) }
|
var controlsExpanded by remember { mutableStateOf(false) }
|
||||||
var focusMode by remember { mutableStateOf(true) }
|
val focusMode = presentationMode == VoicePresentationMode.Focus
|
||||||
val setFocusMode: (Boolean) -> Unit = { focused ->
|
val setFocusMode: (Boolean) -> Unit = { focused ->
|
||||||
focusMode = focused
|
onPresentationModeChange(
|
||||||
onCompactModeChange(!focused)
|
if (focused) VoicePresentationMode.Focus else VoicePresentationMode.Conversation,
|
||||||
}
|
)
|
||||||
|
|
||||||
LaunchedEffect(uiState.voiceMode) {
|
|
||||||
if (!uiState.voiceMode) {
|
|
||||||
setFocusMode(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice errors surface ONLY on the overlay's own inline top banner
|
// Voice errors surface ONLY on the overlay's own inline top banner
|
||||||
@@ -193,7 +196,7 @@ fun VoiceModeOverlay(
|
|||||||
// chips, pill) didn't handle so stray taps/swipes don't fall
|
// chips, pill) didn't handle so stray taps/swipes don't fall
|
||||||
// through to the chat + session drawer behind it. Children run on
|
// through to the chat + session drawer behind it. Children run on
|
||||||
// the same Main pass leaf-first, so this only catches the gaps.
|
// the same Main pass leaf-first, so this only catches the gaps.
|
||||||
// In compact mode the overlay is intentionally transparent and the
|
// In Conversation the overlay is intentionally transparent and the
|
||||||
// chat stays interactive, so no scrim is installed.
|
// chat stays interactive, so no scrim is installed.
|
||||||
.then(
|
.then(
|
||||||
if (focusMode) {
|
if (focusMode) {
|
||||||
@@ -404,6 +407,11 @@ fun VoiceModeOverlay(
|
|||||||
message = msg,
|
message = msg,
|
||||||
showThinking = showThinking,
|
showThinking = showThinking,
|
||||||
expanded = msg.id == latestId || msg.isStreaming,
|
expanded = msg.id == latestId || msg.isStreaming,
|
||||||
|
onViewConversation = {
|
||||||
|
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||||
|
},
|
||||||
|
onCardAction = onCardAction,
|
||||||
|
onCardInput = onCardInput,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (pendingTranscriptText != null) {
|
if (pendingTranscriptText != null) {
|
||||||
@@ -418,6 +426,11 @@ fun VoiceModeOverlay(
|
|||||||
),
|
),
|
||||||
showThinking = showThinking,
|
showThinking = showThinking,
|
||||||
expanded = true,
|
expanded = true,
|
||||||
|
onViewConversation = {
|
||||||
|
onPresentationModeChange(VoicePresentationMode.Conversation)
|
||||||
|
},
|
||||||
|
onCardAction = onCardAction,
|
||||||
|
onCardInput = onCardInput,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +462,7 @@ fun VoiceModeOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact mode: the background-run chip must survive outside focus
|
// Conversation: the background-run chip must survive outside focus
|
||||||
// mode too — a running task with no visible presence reads as lost
|
// mode too — a running task with no visible presence reads as lost
|
||||||
// (the chip previously existed ONLY in the focus layout).
|
// (the chip previously existed ONLY in the focus layout).
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -821,6 +834,15 @@ private fun VoiceSessionPill(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (!focusMode) {
|
||||||
|
ConversationVoiceMicButton(
|
||||||
|
uiState = uiState,
|
||||||
|
onMicTap = onMicTap,
|
||||||
|
onMicRelease = onMicRelease,
|
||||||
|
onInterrupt = onInterrupt,
|
||||||
|
onPauseAutoMode = onPauseAutoMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
|
contentDescription = if (expanded) stringResource(R.string.voice_overlay_collapse_cd) else stringResource(R.string.voice_overlay_expand_cd),
|
||||||
@@ -855,10 +877,9 @@ private fun VoiceSessionPill(
|
|||||||
.padding(top = 10.dp),
|
.padding(top = 10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Moved out of the collapsed header (4a): the current
|
// The expanded body keeps the current interaction mode
|
||||||
// interaction-mode pill plus the inline mic control. The
|
// visible; Conversation's persistent mic stays in the
|
||||||
// compact mic only appears in compact mode, where the
|
// collapsed header so it never disappears.
|
||||||
// full-size bottom mic button is hidden.
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -866,15 +887,6 @@ private fun VoiceSessionPill(
|
|||||||
) {
|
) {
|
||||||
StatusPill(uiState.interactionMode.label())
|
StatusPill(uiState.interactionMode.label())
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (!focusMode) {
|
|
||||||
CompactVoiceMicButton(
|
|
||||||
uiState = uiState,
|
|
||||||
onMicTap = onMicTap,
|
|
||||||
onMicRelease = onMicRelease,
|
|
||||||
onInterrupt = onInterrupt,
|
|
||||||
onPauseAutoMode = onPauseAutoMode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -915,7 +927,11 @@ private fun VoiceSessionPill(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (focusMode) stringResource(R.string.voice_overlay_compact) else stringResource(R.string.voice_overlay_focus),
|
if (focusMode) {
|
||||||
|
stringResource(R.string.voice_overlay_conversation)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.voice_overlay_focus)
|
||||||
|
},
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -958,7 +974,7 @@ private fun VoiceSessionPill(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CompactVoiceMicButton(
|
private fun ConversationVoiceMicButton(
|
||||||
uiState: VoiceUiState,
|
uiState: VoiceUiState,
|
||||||
onMicTap: () -> Unit,
|
onMicTap: () -> Unit,
|
||||||
onMicRelease: () -> Unit,
|
onMicRelease: () -> Unit,
|
||||||
@@ -1224,6 +1240,9 @@ private fun CompactTranscriptRow(
|
|||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
showThinking: Boolean,
|
showThinking: Boolean,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
|
onViewConversation: () -> Unit,
|
||||||
|
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit,
|
||||||
|
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (message.role == MessageRole.SYSTEM) return
|
if (message.role == MessageRole.SYSTEM) return
|
||||||
|
|
||||||
@@ -1248,6 +1267,13 @@ private fun CompactTranscriptRow(
|
|||||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||||
else -> MaterialTheme.colorScheme.secondary
|
else -> MaterialTheme.colorScheme.secondary
|
||||||
}
|
}
|
||||||
|
val (markdownBody, inlineImages) = remember(message.content, message.role) {
|
||||||
|
if (message.role == MessageRole.ASSISTANT) {
|
||||||
|
extractChatInlineImages(message.content)
|
||||||
|
} else {
|
||||||
|
message.content to emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -1285,7 +1311,7 @@ private fun CompactTranscriptRow(
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
isVoiceActionBubble && hasText -> MarkdownContent(
|
isVoiceActionBubble && hasText -> MarkdownContent(
|
||||||
content = message.content,
|
content = markdownBody,
|
||||||
textColor = MaterialTheme.colorScheme.onSurface,
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
message.role == MessageRole.USER && hasText -> Text(
|
message.role == MessageRole.USER && hasText -> Text(
|
||||||
@@ -1295,12 +1321,107 @@ private fun CompactTranscriptRow(
|
|||||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
hasText -> Text(
|
hasText -> MarkdownContent(
|
||||||
text = message.content,
|
content = markdownBody,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
message.cards.forEachIndexed { index, card ->
|
||||||
|
if (card.actions.isNotEmpty() || card.input != null) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
val cardKey = card.id ?: "idx:$index"
|
||||||
|
HermesCardBubble(
|
||||||
|
card = card,
|
||||||
|
cardKey = cardKey,
|
||||||
|
dispatches = message.cardDispatches,
|
||||||
|
onActionTap = { key, action ->
|
||||||
|
onCardAction(message.id, key, action)
|
||||||
|
},
|
||||||
|
onInputSubmit = { key, value ->
|
||||||
|
onCardInput(message.id, key, value)
|
||||||
|
},
|
||||||
|
maxWidth = 360.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
message.attachments.isNotEmpty() ||
|
||||||
|
message.cards.any { it.actions.isEmpty() && it.input == null } ||
|
||||||
|
inlineImages.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
VoiceRichResultAffordance(
|
||||||
|
message = message,
|
||||||
|
onViewConversation = onViewConversation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceRichResultAffordance(
|
||||||
|
message: ChatMessage,
|
||||||
|
onViewConversation: () -> Unit,
|
||||||
|
) {
|
||||||
|
val inlineImages = remember(message.content) {
|
||||||
|
extractChatInlineImages(message.content).second
|
||||||
|
}
|
||||||
|
val previewModel = remember(message.attachments, inlineImages) {
|
||||||
|
message.attachments.firstOrNull { it.isImage && !it.cachedUri.isNullOrBlank() }?.cachedUri
|
||||||
|
?: inlineImages.firstOrNull {
|
||||||
|
it.src.startsWith("https://") || it.src.startsWith("http://")
|
||||||
|
}?.src
|
||||||
|
}
|
||||||
|
val label = when {
|
||||||
|
message.attachments.size + inlineImages.size > 1 ->
|
||||||
|
stringResource(
|
||||||
|
R.string.voice_overlay_rich_results_count,
|
||||||
|
message.attachments.size + inlineImages.size,
|
||||||
|
)
|
||||||
|
message.attachments.isNotEmpty() || inlineImages.isNotEmpty() ->
|
||||||
|
stringResource(R.string.voice_overlay_image_ready)
|
||||||
|
else -> stringResource(R.string.voice_overlay_rich_result_ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onViewConversation),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.48f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (previewModel != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = previewModel,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Image,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 6,
|
modifier = Modifier.weight(1f),
|
||||||
overflow = TextOverflow.Ellipsis,
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.voice_overlay_view_conversation),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
|||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
@@ -140,6 +141,8 @@ fun VoiceWaveform(
|
|||||||
amplitude: Float,
|
amplitude: Float,
|
||||||
state: VoiceState,
|
state: VoiceState,
|
||||||
outputAudioActive: Boolean = false,
|
outputAudioActive: Boolean = false,
|
||||||
|
height: Dp = 56.dp,
|
||||||
|
compactBars: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
// No downstream smoothing. The VoiceViewModel already runs an
|
// No downstream smoothing. The VoiceViewModel already runs an
|
||||||
@@ -154,6 +157,7 @@ fun VoiceWaveform(
|
|||||||
// doesn't look abrupt.
|
// doesn't look abrupt.
|
||||||
val dim = MaterialTheme.colorScheme.onSurfaceVariant
|
val dim = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
val errorColor = MaterialTheme.colorScheme.error
|
val errorColor = MaterialTheme.colorScheme.error
|
||||||
|
val compactColor = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
val targetPrimary = when (state) {
|
val targetPrimary = when (state) {
|
||||||
VoiceState.Idle -> dim.copy(alpha = 0.3f)
|
VoiceState.Idle -> dim.copy(alpha = 0.3f)
|
||||||
@@ -198,16 +202,38 @@ fun VoiceWaveform(
|
|||||||
)
|
)
|
||||||
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
|
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
|
||||||
|
|
||||||
Canvas(
|
val canvasModifier = if (compactBars) {
|
||||||
modifier = modifier
|
modifier.height(height)
|
||||||
.fillMaxWidth()
|
} else {
|
||||||
.height(56.dp),
|
modifier.fillMaxWidth().height(height)
|
||||||
) {
|
}
|
||||||
|
Canvas(modifier = canvasModifier) {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
val height = size.height
|
val height = size.height
|
||||||
if (width <= 0f || height <= 0f) return@Canvas
|
if (width <= 0f || height <= 0f) return@Canvas
|
||||||
|
|
||||||
val centerY = height / 2f
|
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 peakPixels = height * PEAK_FRACTION
|
||||||
val strokePx = STROKE_WIDTH_DP.dp.toPx()
|
val strokePx = STROKE_WIDTH_DP.dp.toPx()
|
||||||
val centerX = width / 2f
|
val centerX = width / 2f
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package com.hermesandroid.relay.ui.onboarding
|
package com.hermesandroid.relay.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.HermesRelayTheme
|
||||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingPage(
|
fun OnboardingPage(
|
||||||
@@ -44,7 +41,6 @@ fun OnboardingPage(
|
|||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
transparentHero: Boolean = false,
|
|
||||||
heroContent: @Composable BoxScope.() -> Unit = {
|
heroContent: @Composable BoxScope.() -> Unit = {
|
||||||
FeatureHero(
|
FeatureHero(
|
||||||
icon = icon,
|
icon = icon,
|
||||||
@@ -53,101 +49,44 @@ fun OnboardingPage(
|
|||||||
},
|
},
|
||||||
content: @Composable ColumnScope.() -> Unit = {}
|
content: @Composable ColumnScope.() -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = LocalBrand.current.isDark
|
Column(
|
||||||
val heroShape = RoundedCornerShape(30.dp)
|
modifier = modifier
|
||||||
val bodyShape = RoundedCornerShape(26.dp)
|
.fillMaxWidth()
|
||||||
val heroBrush = Brush.radialGradient(
|
.widthIn(max = 560.dp)
|
||||||
colors = if (isDarkTheme) {
|
.verticalScroll(rememberScrollState())
|
||||||
listOf(
|
.padding(horizontal = 42.dp, vertical = 8.dp),
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.96f),
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.92f),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.98f),
|
) {
|
||||||
)
|
Box(
|
||||||
} 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(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.widthIn(max = 560.dp)
|
.height(210.dp),
|
||||||
.verticalScroll(rememberScrollState())
|
contentAlignment = Alignment.Center,
|
||||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
if (heroHeight > 0.dp) {
|
heroContent()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
icon: ImageVector,
|
||||||
title: String,
|
title: String,
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = LocalBrand.current.isDark
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(136.dp)
|
.size(176.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = if (isDarkTheme) 0.14f else 0.10f)
|
Color(0xFF7B55F6).copy(alpha = 0.07f)
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
imageVector = icon,
|
modifier = Modifier
|
||||||
contentDescription = title,
|
.size(128.dp)
|
||||||
modifier = Modifier.size(74.dp),
|
.clip(CircleShape)
|
||||||
tint = MaterialTheme.colorScheme.primary
|
.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
|
package com.hermesandroid.relay.ui.onboarding
|
||||||
|
|
||||||
import android.content.Intent
|
import android.Manifest
|
||||||
import android.net.Uri
|
import android.os.Build
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
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.Settings
|
||||||
import androidx.compose.material.icons.filled.Security
|
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.Forum
|
||||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
|
||||||
import androidx.compose.material.icons.outlined.Terminal
|
import androidx.compose.material.icons.outlined.Terminal
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -45,28 +58,64 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.BiasAlignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.hermesandroid.relay.R
|
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.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.ui.theme.HermesRelayTheme
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** Page identifiers for dynamic onboarding flow. */
|
/** Page identifiers for the standard-first onboarding flow. */
|
||||||
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
|
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:
|
* Standard-first onboarding:
|
||||||
@@ -112,21 +161,23 @@ fun OnboardingScreen(
|
|||||||
*/
|
*/
|
||||||
onTryDemo: () -> Unit = {},
|
onTryDemo: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val pages = remember {
|
val pages = standardOnboardingPages
|
||||||
buildList {
|
|
||||||
add(OnboardingPage.Welcome)
|
|
||||||
add(OnboardingPage.Chat)
|
|
||||||
add(OnboardingPage.Manage)
|
|
||||||
add(OnboardingPage.Power)
|
|
||||||
add(OnboardingPage.Connect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val pageCount = pages.size
|
val pageCount = pages.size
|
||||||
val lastPage = pageCount - 1
|
val lastPage = pageCount - 1
|
||||||
|
|
||||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
|
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(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -156,22 +207,35 @@ fun OnboardingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Top bar with Skip — only on informational pages, not the wizard
|
val currentPage = pagerState.currentPage
|
||||||
// (which has its own "Skip for now" affordance).
|
val currentPageType = pages[currentPage]
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
// The selected welcome frame has no toolbar. Later information
|
||||||
.fillMaxWidth()
|
// pages keep quiet navigation without reserving space above it.
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
if (
|
||||||
horizontalArrangement = Arrangement.End,
|
currentPageType == OnboardingPage.Chat ||
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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 }) {
|
TextButton(onClick = { showSkipConfirm = true }) {
|
||||||
Text(
|
Text(stringResource(R.string.onboarding_skip))
|
||||||
text = stringResource(R.string.onboarding_skip),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +243,13 @@ fun OnboardingScreen(
|
|||||||
// Pager content
|
// Pager content
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
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 ->
|
) { pageIndex ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -194,243 +264,332 @@ fun OnboardingScreen(
|
|||||||
)
|
)
|
||||||
OnboardingPage.Connect -> ConnectPage(
|
OnboardingPage.Connect -> ConnectPage(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
onComplete = onComplete,
|
onComplete = {
|
||||||
|
notificationsPermitted =
|
||||||
|
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||||
|
coroutineScope.launch {
|
||||||
|
pagerState.animateScrollToPage(
|
||||||
|
pages.indexOf(OnboardingPage.Permissions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
onManageSignIn = onManageSignIn,
|
onManageSignIn = onManageSignIn,
|
||||||
onSkip = { showSkipConfirm = true },
|
onSkip = { showSkipConfirm = true },
|
||||||
onTryDemo = onTryDemo,
|
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
|
// Bottom navigation only on informational pages — the wizard
|
||||||
// owns its own back/pair affordances.
|
// owns its own back/pair affordances.
|
||||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
if (
|
||||||
// Short viewports get a tighter footer so more of the pager
|
currentPageType != OnboardingPage.Connect &&
|
||||||
// content stays above the fold; indicator + Back/Next remain
|
currentPageType != OnboardingPage.Permissions
|
||||||
// pinned outside the (scrollable) pager pages either way.
|
) {
|
||||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 32.dp)
|
.padding(horizontal = 42.dp)
|
||||||
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
|
.padding(bottom = if (compactHeight) 10.dp else 20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(if (compactHeight) 8.dp else 12.dp),
|
||||||
) {
|
) {
|
||||||
PageIndicator(
|
GradientOnboardingButton(
|
||||||
pageCount = pageCount,
|
label = when {
|
||||||
currentPage = pagerState.currentPage
|
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))
|
if (currentPage == 0) {
|
||||||
|
TextButton(onClick = onTryDemo) {
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = if (pagerState.currentPage == lastPage - 1) {
|
text = stringResource(R.string.chat_try_demo),
|
||||||
stringResource(R.string.onboarding_connect)
|
color = OnboardingAccent,
|
||||||
} else {
|
style = MaterialTheme.typography.labelLarge,
|
||||||
stringResource(R.string.onboarding_next)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@Composable
|
||||||
private fun WelcomePage() {
|
private fun WelcomePage() {
|
||||||
val context = LocalContext.current
|
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
|
||||||
OnboardingPage(
|
Column(
|
||||||
icon = Icons.Outlined.RocketLaunch,
|
modifier = Modifier
|
||||||
title = stringResource(R.string.onboarding_welcome_title),
|
.fillMaxSize()
|
||||||
description = stringResource(R.string.onboarding_welcome_description),
|
.verticalScroll(rememberScrollState())
|
||||||
transparentHero = true,
|
.padding(horizontal = 28.dp),
|
||||||
heroContent = {
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
.fillMaxWidth()
|
||||||
|
.height(380.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
SetupPathSummary(
|
Image(
|
||||||
label = stringResource(R.string.onboarding_chat_manage_label),
|
painter = painterResource(R.drawable.onboarding_hero_option1),
|
||||||
description = stringResource(R.string.onboarding_chat_manage_description),
|
contentDescription = stringResource(R.string.onboarding_hermes_logo),
|
||||||
)
|
modifier = Modifier.fillMaxSize(),
|
||||||
SetupPathSummary(
|
contentScale = ContentScale.Crop,
|
||||||
label = stringResource(R.string.onboarding_power_tools_label),
|
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
|
||||||
description = stringResource(R.string.onboarding_power_tools_description),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.onboarding_setup_guide_hint),
|
text = buildAnnotatedString {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
append(titleParts.first())
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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,
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
context.startActivity(
|
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.onboarding_api_server_docs),
|
text = stringResource(R.string.onboarding_welcome_description),
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
textDecoration = TextDecoration.Underline
|
lineHeight = 27.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.clickable {
|
textAlign = TextAlign.Center,
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server")))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
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
|
@Composable
|
||||||
private fun SetupPathSummary(
|
private fun SetupPathSummary(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
description: String,
|
description: String,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
shape = RoundedCornerShape(16.dp),
|
.fillMaxWidth()
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.42f),
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Surface(
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
shape = RoundedCornerShape(14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.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(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.width(96.dp),
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,14 +607,17 @@ private fun ChatPage() {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.Bolt,
|
||||||
label = stringResource(R.string.onboarding_streaming_label),
|
label = stringResource(R.string.onboarding_streaming_label),
|
||||||
description = stringResource(R.string.onboarding_streaming_description),
|
description = stringResource(R.string.onboarding_streaming_description),
|
||||||
)
|
)
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.Person,
|
||||||
label = stringResource(R.string.onboarding_profiles_label),
|
label = stringResource(R.string.onboarding_profiles_label),
|
||||||
description = stringResource(R.string.onboarding_profiles_description),
|
description = stringResource(R.string.onboarding_profiles_description),
|
||||||
)
|
)
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.Mic,
|
||||||
label = stringResource(R.string.onboarding_voice_label),
|
label = stringResource(R.string.onboarding_voice_label),
|
||||||
description = stringResource(R.string.onboarding_voice_description),
|
description = stringResource(R.string.onboarding_voice_description),
|
||||||
)
|
)
|
||||||
@@ -475,14 +637,17 @@ private fun ManagePage() {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.Tune,
|
||||||
label = stringResource(R.string.onboarding_control_label),
|
label = stringResource(R.string.onboarding_control_label),
|
||||||
description = stringResource(R.string.onboarding_control_description),
|
description = stringResource(R.string.onboarding_control_description),
|
||||||
)
|
)
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.Extension,
|
||||||
label = stringResource(R.string.onboarding_skills_hub_label),
|
label = stringResource(R.string.onboarding_skills_hub_label),
|
||||||
description = stringResource(R.string.onboarding_skills_hub_description),
|
description = stringResource(R.string.onboarding_skills_hub_description),
|
||||||
)
|
)
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.Lock,
|
||||||
label = stringResource(R.string.onboarding_one_sign_in_label),
|
label = stringResource(R.string.onboarding_one_sign_in_label),
|
||||||
description = stringResource(R.string.onboarding_one_sign_in_description),
|
description = stringResource(R.string.onboarding_one_sign_in_description),
|
||||||
)
|
)
|
||||||
@@ -504,14 +669,17 @@ private fun PowerToolsPage(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Outlined.Terminal,
|
||||||
label = stringResource(R.string.onboarding_terminal_label),
|
label = stringResource(R.string.onboarding_terminal_label),
|
||||||
description = stringResource(R.string.onboarding_terminal_description),
|
description = stringResource(R.string.onboarding_terminal_description),
|
||||||
)
|
)
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.PhoneAndroid,
|
||||||
label = stringResource(R.string.onboarding_bridge_label),
|
label = stringResource(R.string.onboarding_bridge_label),
|
||||||
description = stringResource(R.string.onboarding_bridge_description),
|
description = stringResource(R.string.onboarding_bridge_description),
|
||||||
)
|
)
|
||||||
SetupPathSummary(
|
SetupPathSummary(
|
||||||
|
icon = Icons.Filled.GraphicEq,
|
||||||
label = stringResource(R.string.onboarding_realtime_label),
|
label = stringResource(R.string.onboarding_realtime_label),
|
||||||
description = stringResource(R.string.onboarding_realtime_description),
|
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
|
@Composable
|
||||||
private fun ConnectPage(
|
private fun ConnectPage(
|
||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
|
|||||||
@@ -206,10 +206,16 @@ fun AboutScreen(
|
|||||||
val remaining = 7 - versionTapCount
|
val remaining = 7 - versionTapCount
|
||||||
when {
|
when {
|
||||||
remaining <= 0 -> {
|
remaining <= 0 -> {
|
||||||
scope.launch { FeatureFlags.unlockDevOptions(context) }
|
|
||||||
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
|
|
||||||
versionTapCount = 0
|
versionTapCount = 0
|
||||||
onUnlockDeveloperOptions()
|
scope.launch {
|
||||||
|
FeatureFlags.unlockDevOptions(context)
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
devUnlockedMsg,
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
onUnlockDeveloperOptions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
remaining <= 3 -> {
|
remaining <= 3 -> {
|
||||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||||
@@ -432,7 +438,7 @@ fun AboutScreen(
|
|||||||
// Privacy policy link
|
// Privacy policy link
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
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)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ fun AppearanceSettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||||
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||||
|
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
@@ -526,6 +527,48 @@ fun AppearanceSettingsScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.appearance_image_generation_style),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.appearance_image_generation_style_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val imageStyleOptions = listOf(
|
||||||
|
"rotate" to stringResource(R.string.appearance_image_generation_rotate),
|
||||||
|
"grid" to stringResource(R.string.appearance_image_generation_grid),
|
||||||
|
"sphere" to stringResource(R.string.appearance_image_generation_sphere),
|
||||||
|
"nodes" to stringResource(R.string.appearance_image_generation_nodes),
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
imageStyleOptions.forEach { (id, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = imageGenerationStyle == id,
|
||||||
|
onClick = { connectionViewModel.setImageGenerationStyle(id) },
|
||||||
|
label = { Text(label) },
|
||||||
|
leadingIcon = if (imageGenerationStyle == id) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||||
|
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||||
@@ -138,6 +139,7 @@ import androidx.compose.material3.SmallFloatingActionButton
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -155,7 +157,9 @@ import com.hermesandroid.relay.data.AgentDisplay
|
|||||||
import com.hermesandroid.relay.data.Attachment
|
import com.hermesandroid.relay.data.Attachment
|
||||||
import com.hermesandroid.relay.data.ChatMessage
|
import com.hermesandroid.relay.data.ChatMessage
|
||||||
import com.hermesandroid.relay.data.Connection
|
import com.hermesandroid.relay.data.Connection
|
||||||
|
import com.hermesandroid.relay.data.HermesCardAction
|
||||||
import com.hermesandroid.relay.data.MessageRole
|
import com.hermesandroid.relay.data.MessageRole
|
||||||
|
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||||
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
||||||
@@ -195,11 +199,15 @@ import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
|
|||||||
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
|
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
|
||||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||||
import com.hermesandroid.relay.ui.components.SlashCommand
|
import com.hermesandroid.relay.ui.components.SlashCommand
|
||||||
|
import com.hermesandroid.relay.ui.components.StreamingDots
|
||||||
import com.hermesandroid.relay.ui.components.SubagentLane
|
import com.hermesandroid.relay.ui.components.SubagentLane
|
||||||
import com.hermesandroid.relay.ui.components.ToolProgressCard
|
import com.hermesandroid.relay.ui.components.ToolProgressCard
|
||||||
|
import com.hermesandroid.relay.ui.components.isVisibleForToolDisplay
|
||||||
|
import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
|
||||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||||
import com.hermesandroid.relay.ui.showHumanError
|
import com.hermesandroid.relay.ui.showHumanError
|
||||||
|
import com.hermesandroid.relay.util.HumanErrorAction
|
||||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||||
@@ -208,7 +216,10 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||||
import com.hermesandroid.relay.viewmodel.ChatConnectState
|
import com.hermesandroid.relay.viewmodel.ChatConnectState
|
||||||
|
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||||
|
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
|
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||||
import com.hermesandroid.relay.voice.VoiceOverlayHost
|
import com.hermesandroid.relay.voice.VoiceOverlayHost
|
||||||
import com.hermesandroid.relay.voice.VoiceOverlaySession
|
import com.hermesandroid.relay.voice.VoiceOverlaySession
|
||||||
@@ -220,6 +231,23 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||||
|
|
||||||
|
internal fun resolveChatHeaderSubtitle(
|
||||||
|
isStreaming: Boolean,
|
||||||
|
statusText: String,
|
||||||
|
projectName: String?,
|
||||||
|
personalityName: String?,
|
||||||
|
modelName: String?,
|
||||||
|
): String = if (isStreaming) {
|
||||||
|
statusText
|
||||||
|
} else {
|
||||||
|
listOfNotNull(
|
||||||
|
projectName?.takeIf { it.isNotBlank() },
|
||||||
|
personalityName?.takeIf { it.isNotBlank() },
|
||||||
|
modelName?.takeIf { it.isNotBlank() },
|
||||||
|
).joinToString(" \u00B7 ").ifBlank { statusText }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A same-author run breaks into a new visual group once the gap to the
|
* A same-author run breaks into a new visual group once the gap to the
|
||||||
* neighboring message exceeds this — so a conversation resumed after a pause
|
* neighboring message exceeds this — so a conversation resumed after a pause
|
||||||
@@ -256,6 +284,17 @@ internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?)
|
|||||||
previous.messageCount == messageCount &&
|
previous.messageCount == messageCount &&
|
||||||
previous.lastMessageUiKey == lastMessageUiKey
|
previous.lastMessageUiKey == lastMessageUiKey
|
||||||
|
|
||||||
|
internal fun releaseRetainedLiveTail(
|
||||||
|
retainedUiKey: String?,
|
||||||
|
completedUiKey: String?,
|
||||||
|
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
|
||||||
|
|
||||||
|
internal fun tailEndScrollOffset(
|
||||||
|
tailSizePx: Int,
|
||||||
|
footerSizePx: Int,
|
||||||
|
viewportSizePx: Int,
|
||||||
|
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
|
||||||
|
|
||||||
private class ChatTailTransitionRef(
|
private class ChatTailTransitionRef(
|
||||||
var snapshot: ChatScrollSnapshot? = null,
|
var snapshot: ChatScrollSnapshot? = null,
|
||||||
)
|
)
|
||||||
@@ -415,6 +454,8 @@ fun ChatScreen(
|
|||||||
voiceViewModel: VoiceViewModel,
|
voiceViewModel: VoiceViewModel,
|
||||||
voiceClient: RelayVoiceClient? = null,
|
voiceClient: RelayVoiceClient? = null,
|
||||||
maxBubbleWidth: Dp = 300.dp,
|
maxBubbleWidth: Dp = 300.dp,
|
||||||
|
voicePresentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||||
|
onVoicePresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||||
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
// Deep-link nudge from Settings → Active Agent card: when `true`, the
|
||||||
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
// AgentInfoSheet auto-opens on first composition and [onAgentSheetArgConsumed]
|
||||||
// fires so the host can clear the nav arg (prevents re-open on tab
|
// fires so the host can clear the nav arg (prevents re-open on tab
|
||||||
@@ -427,6 +468,7 @@ fun ChatScreen(
|
|||||||
// don't wire navigation.
|
// don't wire navigation.
|
||||||
onNavigateToConnections: () -> Unit = {},
|
onNavigateToConnections: () -> Unit = {},
|
||||||
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
onNavigateToConnect: () -> Unit = onNavigateToConnections,
|
||||||
|
onRepairConnection: () -> Unit = onNavigateToConnect,
|
||||||
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
|
// Offline demo entry, surfaced on the empty-chat "needs connection" card so a
|
||||||
// skipped / never-connected first run can explore without a server. null hides it.
|
// skipped / never-connected first run can explore without a server. null hides it.
|
||||||
onTryDemo: (() -> Unit)? = null,
|
onTryDemo: (() -> Unit)? = null,
|
||||||
@@ -441,9 +483,10 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
|
||||||
val chatAlpha by animateFloatAsState(
|
val chatAlpha by animateFloatAsState(
|
||||||
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f,
|
targetValue = if (
|
||||||
|
voiceUiState.voiceMode && voicePresentationMode == VoicePresentationMode.Focus
|
||||||
|
) 0.4f else 1f,
|
||||||
animationSpec = tween(300),
|
animationSpec = tween(300),
|
||||||
label = "chatAlpha",
|
label = "chatAlpha",
|
||||||
)
|
)
|
||||||
@@ -453,7 +496,13 @@ fun ChatScreen(
|
|||||||
val snackbarHost = LocalSnackbarHost.current
|
val snackbarHost = LocalSnackbarHost.current
|
||||||
LaunchedEffect(chatViewModel) {
|
LaunchedEffect(chatViewModel) {
|
||||||
chatViewModel.errorEvents.collect { err ->
|
chatViewModel.errorEvents.collect { err ->
|
||||||
snackbarHost.showHumanError(err)
|
val result = snackbarHost.showHumanError(err)
|
||||||
|
if (
|
||||||
|
result == SnackbarResult.ActionPerformed &&
|
||||||
|
err.action == HumanErrorAction.Repair
|
||||||
|
) {
|
||||||
|
onRepairConnection()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +570,7 @@ fun ChatScreen(
|
|||||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||||
val standardVoiceSignInRouteHint by
|
val standardVoiceSignInRouteHint by
|
||||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||||
|
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||||
val error by chatViewModel.error.collectAsState()
|
val error by chatViewModel.error.collectAsState()
|
||||||
@@ -539,6 +589,7 @@ fun ChatScreen(
|
|||||||
// and is consumed by the AgentInfoSheet for the Profile section. The list
|
// and is consumed by the AgentInfoSheet for the Profile section. The list
|
||||||
// of available profiles itself now lives entirely inside the sheet.
|
// of available profiles itself now lives entirely inside the sheet.
|
||||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||||
|
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||||
// Server-advertised profile catalog — used to locate the "default" profile
|
// Server-advertised profile catalog — used to locate the "default" profile
|
||||||
// so the header can render its description/model when no explicit pick
|
// so the header can render its description/model when no explicit pick
|
||||||
// has been made (the /api/config fallback is more useful than the bare
|
// has been made (the /api/config fallback is more useful than the bare
|
||||||
@@ -547,11 +598,12 @@ fun ChatScreen(
|
|||||||
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
|
||||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||||
|
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
|
||||||
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
|
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
|
||||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||||
@@ -583,7 +635,7 @@ fun ChatScreen(
|
|||||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||||
// Whether the NEXT turn would ride the gateway transport — gates the
|
// Whether the NEXT turn would ride the gateway transport — gates the
|
||||||
// "Edit & resend" menu entry (conversation rewind needs the gateway).
|
// "Edit & resend" menu entry (conversation rewind needs the gateway).
|
||||||
// Per-turn steerability comes from [steerableTurn], which also covers
|
// Per-turn correction availability comes from [steerableTurn], which also covers
|
||||||
// the preflight-SSE-fallback window.
|
// the preflight-SSE-fallback window.
|
||||||
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
|
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
|
||||||
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||||
@@ -636,9 +688,24 @@ fun ChatScreen(
|
|||||||
// Animation settings
|
// Animation settings
|
||||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||||
|
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||||
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
||||||
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
||||||
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
|
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
|
||||||
|
val imageGenerationOrdinals = remember(messages) {
|
||||||
|
var nextOrdinal = 0
|
||||||
|
buildMap {
|
||||||
|
messages.forEach { message ->
|
||||||
|
val generationCount = message.toolCalls.count {
|
||||||
|
it.name.trim().equals("image_generate", ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (generationCount > 0) {
|
||||||
|
put(message.uiKey, nextOrdinal + generationCount - 1)
|
||||||
|
nextOrdinal += generationCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
||||||
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
||||||
// empty / new-chat view (no messages) — it teaches the long-press entry
|
// empty / new-chat view (no messages) — it teaches the long-press entry
|
||||||
@@ -746,6 +813,25 @@ fun ChatScreen(
|
|||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||||
|
remember(chatViewModel, context) {
|
||||||
|
{ messageId, cardKey, action ->
|
||||||
|
if (action.mode == HermesCardAction.Modes.OPEN_URL) {
|
||||||
|
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||||
|
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||||
|
context,
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chatViewModel.dispatchCardAction(messageId, cardKey, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val handleCardInput: (String, String, String) -> Unit = remember(chatViewModel) {
|
||||||
|
{ messageId, cardKey, value ->
|
||||||
|
chatViewModel.answerAsk(messageId, cardKey, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
|
// Ephemeral notices from the VM (model-switch warnings/errors, etc.) →
|
||||||
// transient snackbar, never a chat bubble.
|
// transient snackbar, never a chat bubble.
|
||||||
@@ -809,8 +895,7 @@ fun ChatScreen(
|
|||||||
provider = activeVoiceProvider,
|
provider = activeVoiceProvider,
|
||||||
model = activeVoiceModel,
|
model = activeVoiceModel,
|
||||||
voice = activeVoiceName,
|
voice = activeVoiceName,
|
||||||
profileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
|
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||||
?: selectedProfile?.name,
|
|
||||||
configScope = activeVoiceScope,
|
configScope = activeVoiceScope,
|
||||||
outputEnabled = activeVoiceEnabled,
|
outputEnabled = activeVoiceEnabled,
|
||||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||||
@@ -844,7 +929,6 @@ fun ChatScreen(
|
|||||||
if (!voiceUiState.voiceMode) {
|
if (!voiceUiState.voiceMode) {
|
||||||
voiceOverlayHost.hide()
|
voiceOverlayHost.hide()
|
||||||
pendingVoiceOverlayPermission = false
|
pendingVoiceOverlayPermission = false
|
||||||
voiceCompactMode = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,10 +1173,12 @@ fun ChatScreen(
|
|||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||||
|
val settlingVisibleTail = completionSettlingUiKey != null &&
|
||||||
|
messages.lastOrNull()?.uiKey == completionSettlingUiKey
|
||||||
messages.isNotEmpty() &&
|
messages.isNotEmpty() &&
|
||||||
!isAtBottom &&
|
!isAtBottom &&
|
||||||
!programmaticBottomScroll &&
|
!programmaticBottomScroll &&
|
||||||
!((isStreaming || retainingVisibleTail) &&
|
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
|
||||||
smoothAutoScroll &&
|
smoothAutoScroll &&
|
||||||
!userScrolledAway)
|
!userScrolledAway)
|
||||||
}
|
}
|
||||||
@@ -1290,34 +1376,99 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
||||||
|
// Retention is only a completion-transition aid. Never leave the
|
||||||
|
// finalized tail on the plain streaming renderer just because the
|
||||||
|
// user disabled follow-scroll or is reading above the bottom.
|
||||||
|
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||||
|
retainedUiKey = retainedLiveTailUiKey,
|
||||||
|
completedUiKey = settlingKey,
|
||||||
|
)
|
||||||
completionSettlingUiKey = null
|
completionSettlingUiKey = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
var settledFrames = 0
|
var settledFrames = 0
|
||||||
repeat(6) {
|
var previousMarkdownTailSize: Int? = null
|
||||||
|
var previousMarkdownFooterSize: Int? = null
|
||||||
|
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
|
||||||
|
repeat(60) completionFrame@{
|
||||||
withFrameNanos { }
|
withFrameNanos { }
|
||||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||||
completionSettlingUiKey = null
|
completionSettlingUiKey = null
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listState.canScrollForward) {
|
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
|
||||||
settledFrames = 0
|
if (listState.canScrollForward) {
|
||||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
settledFrames = 0
|
||||||
if (viewportHeight > 0) {
|
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||||
listState.scroll(MutatePriority.Default) {
|
if (viewportHeight > 0) {
|
||||||
scrollBy(viewportHeight.toFloat())
|
listState.scroll(MutatePriority.Default) {
|
||||||
|
scrollBy(viewportHeight.toFloat())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return@completionFrame
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
settledFrames += 1
|
settledFrames += 1
|
||||||
if (settledFrames >= 2) {
|
if (settledFrames < 2) return@completionFrame
|
||||||
completionSettlingUiKey = null
|
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||||
return@LaunchedEffect
|
retainedUiKey = retainedLiveTailUiKey,
|
||||||
}
|
completedUiKey = settlingKey,
|
||||||
|
)
|
||||||
|
settledFrames = 0
|
||||||
|
return@completionFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once Markdown owns the row, position its measured trailing edge
|
||||||
|
// explicitly. `canScrollForward` is insufficient here: LazyColumn
|
||||||
|
// may preserve the leading edge of a tall item while reporting an
|
||||||
|
// otherwise valid item anchor. Repeating catches deferred parsing,
|
||||||
|
// highlighted code, and attachment measurement without competing
|
||||||
|
// with the ordinary streaming-growth coroutine.
|
||||||
|
val layout = listState.layoutInfo
|
||||||
|
val tailIndex = messages.size // header item + zero-based messages
|
||||||
|
val footerIndex = tailIndex + 1
|
||||||
|
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
|
||||||
|
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
|
||||||
|
if (tailInfo == null) {
|
||||||
|
listState.scrollToItem(tailIndex)
|
||||||
|
settledFrames = 0
|
||||||
|
return@completionFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
val viewportHeight = layout.viewportSize.height
|
||||||
|
if (viewportHeight <= 0) return@completionFrame
|
||||||
|
val desiredOffset = tailEndScrollOffset(
|
||||||
|
tailSizePx = tailInfo.size,
|
||||||
|
footerSizePx = footerInfo?.size ?: 0,
|
||||||
|
viewportSizePx = viewportHeight,
|
||||||
|
)
|
||||||
|
if (desiredOffset == 0) {
|
||||||
|
listState.scrollToItem(footerIndex)
|
||||||
|
} else {
|
||||||
|
listState.scrollToItem(tailIndex, desiredOffset)
|
||||||
|
}
|
||||||
|
val footerSize = footerInfo?.size ?: 0
|
||||||
|
settledFrames = if (
|
||||||
|
previousMarkdownTailSize == tailInfo.size &&
|
||||||
|
previousMarkdownFooterSize == footerSize
|
||||||
|
) {
|
||||||
|
settledFrames + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
previousMarkdownTailSize = tailInfo.size
|
||||||
|
previousMarkdownFooterSize = footerSize
|
||||||
|
if (settledFrames >= 12) {
|
||||||
|
completionSettlingUiKey = null
|
||||||
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||||
|
retainedUiKey = retainedLiveTailUiKey,
|
||||||
|
completedUiKey = settlingKey,
|
||||||
|
)
|
||||||
completionSettlingUiKey = null
|
completionSettlingUiKey = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,23 +1530,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display profile - the one the header should reflect. Priority:
|
|
||||||
// 1. explicit user pick (selectedProfile)
|
|
||||||
// 2. server-advertised profile named "default"
|
|
||||||
// 3. null (fall back to personality-derived name)
|
|
||||||
//
|
|
||||||
// Computed as a derived state so the header cross-fades when the user
|
|
||||||
// picks a new profile OR when the server's profile catalog finishes
|
|
||||||
// loading and a "default" entry shows up.
|
|
||||||
val effectiveProfile by remember(selectedProfile, agentProfiles) {
|
|
||||||
derivedStateOf {
|
|
||||||
AgentDisplay.effectiveDisplayProfile(
|
|
||||||
selectedProfile = selectedProfile,
|
|
||||||
profiles = agentProfiles,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent display name — used in header and info dialog.
|
// Agent display name — used in header and info dialog.
|
||||||
//
|
//
|
||||||
// Precedence mirrors the messaging-app pattern: show the profile's
|
// Precedence mirrors the messaging-app pattern: show the profile's
|
||||||
@@ -1439,7 +1573,7 @@ fun ChatScreen(
|
|||||||
// drawer itself, so the overlay's pointer scrim alone can't block it.
|
// drawer itself, so the overlay's pointer scrim alone can't block it.
|
||||||
gesturesEnabled = !voiceUiState.voiceMode,
|
gesturesEnabled = !voiceUiState.voiceMode,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
val drawerTitle = if (selectedProfile != null) {
|
val drawerTitle = if (effectiveProfile != null) {
|
||||||
stringResource(R.string.chat_profile_sessions, agentDisplayName)
|
stringResource(R.string.chat_profile_sessions, agentDisplayName)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.chat_server_default_sessions)
|
stringResource(R.string.chat_server_default_sessions)
|
||||||
@@ -1517,17 +1651,36 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
val headerApiReachable = apiReachable || isStreaming
|
// Resolve the same Gateway-first, API-fallback runtime
|
||||||
val isConnecting = isChatConnecting ||
|
// model used elsewhere; Relay is intentionally unrelated
|
||||||
(!headerApiReachable && chatMode != ChatMode.DISCONNECTED)
|
// to Chat health.
|
||||||
|
val chatRuntimeStatus = resolveChatRuntimeStatus(
|
||||||
|
gateway = when (chatGatewayAvailability) {
|
||||||
|
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 {
|
||||||
|
apiReachable -> ChatTransportReadiness.Ready
|
||||||
|
activeConnection?.apiServerUrl.isNullOrBlank() -> ChatTransportReadiness.NotConfigured
|
||||||
|
chatMode != ChatMode.DISCONNECTED -> ChatTransportReadiness.Connecting
|
||||||
|
else -> ChatTransportReadiness.Unavailable
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val headerChatReady = chatRuntimeStatus is ChatRuntimeStatus.Connected || isStreaming
|
||||||
|
val isConnecting = isChatConnecting || chatRuntimeStatus is ChatRuntimeStatus.Connecting
|
||||||
// Once we've been connected this session, a later drop reads as
|
// Once we've been connected this session, a later drop reads as
|
||||||
// "Reconnecting…" (we had it, we're getting it back) rather than
|
// "Reconnecting…" (we had it, we're getting it back) rather than
|
||||||
// a first-time "Connecting…". Honest wording for the WhatsApp-
|
// a first-time "Connecting…". Honest wording for the WhatsApp-
|
||||||
// style subtitle status.
|
// style subtitle status.
|
||||||
var everConnected by remember { mutableStateOf(false) }
|
var everConnected by remember { mutableStateOf(false) }
|
||||||
if (headerApiReachable) everConnected = true
|
if (headerChatReady) everConnected = true
|
||||||
val statusText = when {
|
val statusText = when {
|
||||||
headerApiReachable -> if (isStreaming) {
|
headerChatReady -> if (isStreaming) {
|
||||||
stringResource(R.string.chat_streaming)
|
stringResource(R.string.chat_streaming)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.chat_connected_label)
|
stringResource(R.string.chat_connected_label)
|
||||||
@@ -1540,7 +1693,7 @@ fun ChatScreen(
|
|||||||
else -> stringResource(R.string.chat_disconnected_label)
|
else -> stringResource(R.string.chat_disconnected_label)
|
||||||
}
|
}
|
||||||
val statusColor = when {
|
val statusColor = when {
|
||||||
headerApiReachable -> Color(0xFF4CAF50)
|
headerChatReady -> Color(0xFF4CAF50)
|
||||||
isConnecting -> Color(0xFFFFA726)
|
isConnecting -> Color(0xFFFFA726)
|
||||||
else -> MaterialTheme.colorScheme.error
|
else -> MaterialTheme.colorScheme.error
|
||||||
}
|
}
|
||||||
@@ -1578,14 +1731,18 @@ fun ChatScreen(
|
|||||||
// yet (server config still loading), fall back to the plain
|
// yet (server config still loading), fall back to the plain
|
||||||
// connection status \u2014 never the literal "None"/"Default"
|
// connection status \u2014 never the literal "None"/"Default"
|
||||||
// personality label.
|
// personality label.
|
||||||
val subtitleText = when {
|
val subtitleText = if (!headerChatReady) {
|
||||||
!headerApiReachable -> statusText
|
statusText
|
||||||
else -> listOfNotNull(
|
} else {
|
||||||
nonDefaultPersonality,
|
resolveChatHeaderSubtitle(
|
||||||
modelName?.takeIf { it.isNotBlank() },
|
isStreaming = isStreaming,
|
||||||
).joinToString(" \u00B7 ").ifBlank { statusText }
|
statusText = statusText,
|
||||||
|
projectName = gatewayProjectName,
|
||||||
|
personalityName = nonDefaultPersonality,
|
||||||
|
modelName = modelName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val subtitleColor = if (headerApiReachable) {
|
val subtitleColor = if (headerChatReady) {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
} else {
|
} else {
|
||||||
statusColor
|
statusColor
|
||||||
@@ -1643,7 +1800,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ConnectionStatusBadge(
|
ConnectionStatusBadge(
|
||||||
isConnected = headerApiReachable,
|
isConnected = headerChatReady,
|
||||||
isConnecting = isConnecting,
|
isConnecting = isConnecting,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(10.dp)
|
.size(10.dp)
|
||||||
@@ -1716,6 +1873,10 @@ fun ChatScreen(
|
|||||||
transitionSpec = { loadedContentTransform() },
|
transitionSpec = { loadedContentTransform() },
|
||||||
label = "chatHeaderSubtitle",
|
label = "chatHeaderSubtitle",
|
||||||
) { line ->
|
) { line ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = line,
|
text = line,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -1723,6 +1884,13 @@ fun ChatScreen(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
if (isStreaming && animationEnabled) {
|
||||||
|
StreamingDots(
|
||||||
|
color = subtitleColor,
|
||||||
|
modifier = Modifier.clearAndSetSemantics { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1924,6 +2092,9 @@ fun ChatScreen(
|
|||||||
chatReady = chatReady,
|
chatReady = chatReady,
|
||||||
isLoadingHistory = isLoadingHistory,
|
isLoadingHistory = isLoadingHistory,
|
||||||
isLoadingSessions = isLoadingSessions,
|
isLoadingSessions = isLoadingSessions,
|
||||||
|
gatewayAvailability = chatGatewayAvailability,
|
||||||
|
dashboardRouteMovedHint = dashboardRouteMovedHint,
|
||||||
|
onNavigateToManage = onNavigateToManage,
|
||||||
onNavigateToConnections = onNavigateToConnections,
|
onNavigateToConnections = onNavigateToConnections,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -1972,7 +2143,7 @@ fun ChatScreen(
|
|||||||
// thread itself (not just the header) -
|
// thread itself (not just the header) -
|
||||||
// the desktop's intro.
|
// the desktop's intro.
|
||||||
ChatConnectState.Ready ->
|
ChatConnectState.Ready ->
|
||||||
if (selectedProfile != null) {
|
if (effectiveProfile != null) {
|
||||||
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.chat_start_conversation)
|
stringResource(R.string.chat_start_conversation)
|
||||||
@@ -2238,30 +2409,17 @@ fun ChatScreen(
|
|||||||
isLastInGroup = isLastInGroup,
|
isLastInGroup = isLastInGroup,
|
||||||
retainStreamingLayout = retainLiveLayout,
|
retainStreamingLayout = retainLiveLayout,
|
||||||
recoveringAnswer = recoveringAnswer,
|
recoveringAnswer = recoveringAnswer,
|
||||||
|
imageGenerationStylePreference = imageGenerationStyle,
|
||||||
|
imageGenerationRotationIndex =
|
||||||
|
imageGenerationOrdinals[message.uiKey] ?: 0,
|
||||||
onAttachmentRetry = { msgId, idx ->
|
onAttachmentRetry = { msgId, idx ->
|
||||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||||
},
|
},
|
||||||
onAttachmentManualFetch = { msgId, idx ->
|
onAttachmentManualFetch = { msgId, idx ->
|
||||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||||
},
|
},
|
||||||
onCardAction = { msgId, cardKey, action ->
|
onCardAction = handleCardAction,
|
||||||
// OPEN_URL is resolved at the UI layer
|
onCardInput = handleCardInput,
|
||||||
// because launching ACTION_VIEW needs a
|
|
||||||
// Context. Record the dispatch first so
|
|
||||||
// the card collapses even if launch fails.
|
|
||||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
|
||||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
|
||||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
|
||||||
context,
|
|
||||||
action,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCardInput = { msgId, cardKey, value ->
|
|
||||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
|
||||||
},
|
|
||||||
onEditMessage = if (
|
onEditMessage = if (
|
||||||
isGatewayTransport &&
|
isGatewayTransport &&
|
||||||
!isStreaming &&
|
!isStreaming &&
|
||||||
@@ -2310,7 +2468,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steered sends live inside a server-side tool
|
// Legacy steered sends live inside a server-side tool
|
||||||
// result, not a user message — flag the local
|
// result, not a user message — flag the local
|
||||||
// bubble so the scrollback explains itself.
|
// bubble so the scrollback explains itself.
|
||||||
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
|
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
|
||||||
@@ -2327,12 +2485,21 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolDisplay != "off" && !hasBackgroundTask) {
|
if (!hasBackgroundTask) {
|
||||||
// Subagent children (taskIndex != null) group
|
// Subagent children (taskIndex != null) group
|
||||||
// into lanes after the top-level tool cards;
|
// into lanes after the top-level tool cards;
|
||||||
// the null group renders exactly as before.
|
// the null group renders exactly as before.
|
||||||
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
|
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
|
||||||
laneGroups[null]?.forEach { toolCall ->
|
laneGroups[null]?.forEach { toolCall ->
|
||||||
|
// Image generation renders inside MessageBubble
|
||||||
|
// so the progress canvas and final media share
|
||||||
|
// one stable Surface and transition in place.
|
||||||
|
if (toolCall.showsImageGenerationPlaceholder()) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (!toolCall.isVisibleForToolDisplay(toolDisplay)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
when (toolDisplay) {
|
when (toolDisplay) {
|
||||||
"compact" -> CompactToolCall(toolCall = toolCall)
|
"compact" -> CompactToolCall(toolCall = toolCall)
|
||||||
@@ -2342,12 +2509,14 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
if (toolDisplay != "off") {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||||
SubagentLane(
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
taskIndex = taskIndex,
|
SubagentLane(
|
||||||
calls = laneGroups.getValue(taskIndex),
|
taskIndex = taskIndex,
|
||||||
)
|
calls = laneGroups.getValue(taskIndex),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2675,7 +2844,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Input bar — pill field with ONE trailing slot morphing
|
// Input bar — pill field with ONE trailing slot morphing
|
||||||
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker,
|
// Send / Voice / Stop / Correct / Queue. "+" taps the file picker,
|
||||||
// long-press opens the CommandPalette (the dedicated "/" button
|
// long-press opens the CommandPalette (the dedicated "/" button
|
||||||
// is gone — typing "/" still surfaces InlineAutocomplete).
|
// is gone — typing "/" still surfaces InlineAutocomplete).
|
||||||
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||||
@@ -2702,13 +2871,16 @@ fun ChatScreen(
|
|||||||
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
|
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
|
||||||
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
|
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
|
||||||
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
|
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
|
||||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
|
||||||
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
|
(apiModelOptions +
|
||||||
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
|
agentProfiles.mapNotNull { profile ->
|
||||||
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
|
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
|
||||||
.distinct()
|
} +
|
||||||
|
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
|
||||||
|
.distinctBy { it.id }
|
||||||
}
|
}
|
||||||
val currentModelForInput = AgentDisplay.displayModelName(selectedModelOverride)
|
val currentModelForInput = apiModelOptions.firstOrNull { it.id == selectedModelOverride }?.id
|
||||||
|
?: AgentDisplay.displayModelName(selectedModelOverride)
|
||||||
?: AgentDisplay.displayModelName(gatewayCurrentModel)
|
?: AgentDisplay.displayModelName(gatewayCurrentModel)
|
||||||
?: AgentDisplay.displayModelName(effectiveProfile?.model)
|
?: AgentDisplay.displayModelName(effectiveProfile?.model)
|
||||||
?: AgentDisplay.displayModelName(serverModelName)
|
?: AgentDisplay.displayModelName(serverModelName)
|
||||||
@@ -2776,13 +2948,26 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
|
||||||
|
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
|
||||||
|
add(
|
||||||
|
ChatInputPickerOption(
|
||||||
|
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||||
|
value = model.id,
|
||||||
|
group = "Routes",
|
||||||
|
secondary = model.routeDetail,
|
||||||
|
selected = selectedModelOverride == model.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sseModelOptions.forEach { model ->
|
sseModelOptions.forEach { model ->
|
||||||
add(
|
add(
|
||||||
ChatInputPickerOption(
|
ChatInputPickerOption(
|
||||||
label = model,
|
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||||
value = model,
|
value = model.id,
|
||||||
selected = selectedModelOverride == model,
|
secondary = model.routeDetail,
|
||||||
|
selected = selectedModelOverride == model.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2927,7 +3112,11 @@ fun ChatScreen(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
modelControl = modelControl,
|
modelControl = modelControl,
|
||||||
onModelOptionSelected = { option ->
|
onModelOptionSelected = { option ->
|
||||||
chatViewModel.selectModel(option.value, option.provider)
|
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
|
||||||
|
option.value?.let(chatViewModel::selectApiModel)
|
||||||
|
} else {
|
||||||
|
chatViewModel.selectModel(option.value, option.provider)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
effortControl = effortControl,
|
effortControl = effortControl,
|
||||||
onEffortOptionSelected = { option ->
|
onEffortOptionSelected = { option ->
|
||||||
@@ -2944,7 +3133,11 @@ fun ChatScreen(
|
|||||||
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
|
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
|
||||||
onSelect = { option ->
|
onSelect = { option ->
|
||||||
showModelSheet = false
|
showModelSheet = false
|
||||||
chatViewModel.selectModel(option.value, option.provider)
|
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
|
||||||
|
option.value?.let(chatViewModel::selectApiModel)
|
||||||
|
} else {
|
||||||
|
chatViewModel.selectModel(option.value, option.provider)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDismiss = { showModelSheet = false },
|
onDismiss = { showModelSheet = false },
|
||||||
)
|
)
|
||||||
@@ -3081,19 +3274,17 @@ fun ChatScreen(
|
|||||||
voiceOutputProvider = activeVoiceProvider,
|
voiceOutputProvider = activeVoiceProvider,
|
||||||
voiceOutputModel = activeVoiceModel,
|
voiceOutputModel = activeVoiceModel,
|
||||||
voiceOutputVoice = activeVoiceName,
|
voiceOutputVoice = activeVoiceName,
|
||||||
voiceProfileName = selectedProfile?.description?.takeIf { it.isNotBlank() }
|
voiceProfileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||||
?: selectedProfile?.name,
|
|
||||||
voiceConfigScope = activeVoiceScope,
|
voiceConfigScope = activeVoiceScope,
|
||||||
voiceOutputEnabled = activeVoiceEnabled,
|
voiceOutputEnabled = activeVoiceEnabled,
|
||||||
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
voiceOutputFallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||||
|
presentationMode = voicePresentationMode,
|
||||||
|
onPresentationModeChange = onVoicePresentationModeChange,
|
||||||
onOverlayRequest = showVoiceSystemOverlay,
|
onOverlayRequest = showVoiceSystemOverlay,
|
||||||
// Gear button in the overlay's expanded controls. The overlay
|
// Gear button in the overlay's expanded controls. The overlay
|
||||||
// exits voice mode before invoking this, so navigation lands
|
// exits voice mode before invoking this, so navigation lands
|
||||||
// on Voice Settings with no overlay left on top.
|
// on Voice Settings with no overlay left on top.
|
||||||
onOpenSettings = onNavigateToVoiceSettings,
|
onOpenSettings = onNavigateToVoiceSettings,
|
||||||
onCompactModeChange = { compact ->
|
|
||||||
voiceCompactMode = compact
|
|
||||||
},
|
|
||||||
// === v0.4.1 JIT permission-denied chip ===
|
// === v0.4.1 JIT permission-denied chip ===
|
||||||
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
||||||
// Permissions for the running package. Use BuildConfig
|
// Permissions for the running package. Use BuildConfig
|
||||||
@@ -3117,6 +3308,8 @@ fun ChatScreen(
|
|||||||
onHermesConfirmationAnswer = { answer ->
|
onHermesConfirmationAnswer = { answer ->
|
||||||
voiceViewModel.answerHermesConfirmation(answer)
|
voiceViewModel.answerHermesConfirmation(answer)
|
||||||
},
|
},
|
||||||
|
onCardAction = handleCardAction,
|
||||||
|
onCardInput = handleCardInput,
|
||||||
// === END v0.4.1 ===
|
// === END v0.4.1 ===
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3222,6 +3415,9 @@ private fun ChatColdStartLoadingState(
|
|||||||
chatReady: Boolean,
|
chatReady: Boolean,
|
||||||
isLoadingHistory: Boolean,
|
isLoadingHistory: Boolean,
|
||||||
isLoadingSessions: Boolean,
|
isLoadingSessions: Boolean,
|
||||||
|
gatewayAvailability: GatewayAvailability,
|
||||||
|
dashboardRouteMovedHint: String?,
|
||||||
|
onNavigateToManage: () -> Unit,
|
||||||
onNavigateToConnections: () -> Unit,
|
onNavigateToConnections: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -3284,14 +3480,51 @@ private fun ChatColdStartLoadingState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatLoadingCommandPanel(
|
val dashboardSignInRequired =
|
||||||
commands = commands,
|
gatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
|
||||||
onNavigateToConnections = onNavigateToConnections,
|
if (dashboardSignInRequired) {
|
||||||
modifier = Modifier
|
ElevatedCard(
|
||||||
.align(Alignment.BottomCenter)
|
colors = CardDefaults.elevatedCardColors(
|
||||||
.fillMaxWidth()
|
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
),
|
||||||
)
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.dashboard_signin_required_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = dashboardRouteMovedHint?.let { route ->
|
||||||
|
stringResource(R.string.dashboard_signin_route_hint, route)
|
||||||
|
} ?: stringResource(R.string.chat_settings_gateway_needs_signin_desc),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onNavigateToManage,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.voice_settings_sign_in_via_manage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ChatLoadingCommandPanel(
|
||||||
|
commands = commands,
|
||||||
|
onNavigateToConnections = onNavigateToConnections,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
// Turn-complete notification toggle. First enable on
|
// Background chat-alert toggle. First enable on
|
||||||
// API 33+ runs the POST_NOTIFICATIONS request (the
|
// API 33+ runs the POST_NOTIFICATIONS request (the
|
||||||
// BridgeScreen master-toggle precedent); if the user
|
// BridgeScreen master-toggle precedent); if the user
|
||||||
// denies, the notifier silently no-ops at post time.
|
// 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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -42,13 +46,14 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.data.Connection
|
import com.hermesandroid.relay.data.Connection
|
||||||
import com.hermesandroid.relay.data.FeatureFlags
|
import com.hermesandroid.relay.data.capabilities
|
||||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||||
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
||||||
@@ -60,7 +65,6 @@ import com.hermesandroid.relay.ui.components.SessionInfoSheet
|
|||||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||||
import com.hermesandroid.relay.viewmodel.statusText
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tabbed detail for a single Hermes connection — the level-2 screen the
|
* Tabbed detail for a single Hermes connection — the level-2 screen the
|
||||||
@@ -105,11 +109,6 @@ fun ConnectionDetailScreen(
|
|||||||
val connections by connectionViewModel.connections.collectAsState()
|
val connections by connectionViewModel.connections.collectAsState()
|
||||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||||
val relayUiState by connectionViewModel.relayUiState.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)
|
|
||||||
|
|
||||||
val connection = connections.firstOrNull { it.id == connectionId }
|
val connection = connections.firstOrNull { it.id == connectionId }
|
||||||
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
||||||
LaunchedEffect(connection == null) {
|
LaunchedEffect(connection == null) {
|
||||||
@@ -200,6 +199,12 @@ fun ConnectionDetailScreen(
|
|||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onRepair(connectionId)
|
onRepair(connectionId)
|
||||||
},
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.QrCodeScanner,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (connection.pairedAt != null) {
|
if (connection.pairedAt != null) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
@@ -265,10 +270,7 @@ fun ConnectionDetailScreen(
|
|||||||
ActiveOverview(
|
ActiveOverview(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
connection = connection,
|
connection = connection,
|
||||||
relayConfigured = relayConfigured,
|
|
||||||
relayStatusText = relayRow.statusText(connectedLabel = stringResource(R.string.detail_connected)),
|
|
||||||
relayUiState = relayUiState,
|
relayUiState = relayUiState,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
onReconnect = onReconnect,
|
onReconnect = onReconnect,
|
||||||
onRepair = { onRepair(connectionId) },
|
onRepair = { onRepair(connectionId) },
|
||||||
onOpenApiInfo = { showApiInfoSheet = true },
|
onOpenApiInfo = { showApiInfoSheet = true },
|
||||||
@@ -289,18 +291,22 @@ fun ConnectionDetailScreen(
|
|||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
connection = connection,
|
connection = connection,
|
||||||
liveState = relayUiState,
|
liveState = relayUiState,
|
||||||
|
onEditDashboard = {
|
||||||
|
selectedTab = tabs.indexOf(DetailTab.Advanced)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
onPairRelay = { onRepair(connectionId) },
|
||||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
DetailTab.Security -> ActiveCardSecurityPosture(
|
DetailTab.Security -> ActiveCardSecurityPosture(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
onNavigateToPairedDevices = onNavigateToPairedDevices,
|
onNavigateToPairedDevices = onNavigateToPairedDevices,
|
||||||
|
onRevokeRelay = { showRevokeConfirm = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,10 +416,7 @@ private enum class DetailTab {
|
|||||||
private fun ActiveOverview(
|
private fun ActiveOverview(
|
||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
relayConfigured: Boolean,
|
|
||||||
relayStatusText: String,
|
|
||||||
relayUiState: RelayUiState,
|
relayUiState: RelayUiState,
|
||||||
relayEnabled: Boolean,
|
|
||||||
onReconnect: () -> Unit,
|
onReconnect: () -> Unit,
|
||||||
onRepair: () -> Unit,
|
onRepair: () -> Unit,
|
||||||
onOpenApiInfo: () -> Unit,
|
onOpenApiInfo: () -> Unit,
|
||||||
@@ -421,48 +424,78 @@ private fun ActiveOverview(
|
|||||||
onOpenRelayInfo: () -> Unit,
|
onOpenRelayInfo: () -> Unit,
|
||||||
onOpenSessionInfo: () -> Unit,
|
onOpenSessionInfo: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||||
val headerLine = if (relayConfigured) relayStatusText else stringResource(R.string.detail_standard_prefix) + hostname
|
val dashboardReady = connection.dashboardLastStatus?.reachable == true
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = headerLine,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
) {
|
||||||
maxLines = 1,
|
Icon(
|
||||||
overflow = TextOverflow.Ellipsis,
|
imageVector = Icons.Filled.Dashboard,
|
||||||
)
|
contentDescription = null,
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
text = hostname,
|
)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(
|
||||||
maxLines = 1,
|
text = stringResource(R.string.detail_dashboard_primary),
|
||||||
overflow = TextOverflow.Ellipsis,
|
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(
|
||||||
text = stringResource(R.string.detail_routes_help),
|
text = stringResource(R.string.detail_overview_summary),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
ActiveCardFeaturesSection(
|
ActiveCardFeaturesSection(
|
||||||
connectionViewModel = connectionViewModel,
|
connectionViewModel = connectionViewModel,
|
||||||
relayEnabled = relayEnabled,
|
|
||||||
onOpenApiInfo = onOpenApiInfo,
|
onOpenApiInfo = onOpenApiInfo,
|
||||||
onOpenDashboard = onOpenDashboard,
|
onOpenDashboard = onOpenDashboard,
|
||||||
onOpenRelayInfo = onOpenRelayInfo,
|
onOpenRelayInfo = onOpenRelayInfo,
|
||||||
onOpenSessionInfo = onOpenSessionInfo,
|
onOpenSessionInfo = onOpenSessionInfo,
|
||||||
|
onPairRelay = onRepair,
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -472,9 +505,6 @@ private fun ActiveOverview(
|
|||||||
if (relayUiState == RelayUiState.Stale) {
|
if (relayUiState == RelayUiState.Stale) {
|
||||||
Button(onClick = onReconnect) { Text(stringResource(R.string.detail_reconnect)) }
|
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 +519,10 @@ private fun InactiveOverview(
|
|||||||
onSwitch: () -> Unit,
|
onSwitch: () -> Unit,
|
||||||
onRepair: () -> Unit,
|
onRepair: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||||
val statusLine = when {
|
val statusLine = when {
|
||||||
connection.pairedAt != null -> stringResource(R.string.detail_paired_relay_configured)
|
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)
|
else -> stringResource(R.string.detail_not_configured)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+385
-137
@@ -1,6 +1,9 @@
|
|||||||
package com.hermesandroid.relay.ui.screens
|
package com.hermesandroid.relay.ui.screens
|
||||||
|
|
||||||
import android.content.Context
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Badge
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -39,21 +54,33 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hermesandroid.relay.R
|
import com.hermesandroid.relay.R
|
||||||
import com.hermesandroid.relay.data.Connection
|
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.network.relay.RelayUrlDeriver
|
||||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
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.RelayUiState
|
||||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||||
|
import com.hermesandroid.relay.viewmodel.resolveChatRuntimeStatus
|
||||||
import com.hermesandroid.relay.viewmodel.statusText
|
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.
|
* Level-1 list of Hermes connections. Reachable via Settings → Connections.
|
||||||
@@ -91,6 +118,15 @@ fun ConnectionsSettingsScreen(
|
|||||||
} else {
|
} else {
|
||||||
false
|
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
|
// Kick a WSS reconnect on entry in case the user landed here from a Stale
|
||||||
// chip (same intent as the old inline screen).
|
// chip (same intent as the old inline screen).
|
||||||
@@ -101,7 +137,22 @@ fun ConnectionsSettingsScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(
|
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 ->
|
) { innerPadding ->
|
||||||
if (connections.isEmpty()) {
|
if (connections.isEmpty()) {
|
||||||
// Shown in practice only during tests / after a wipe; cold start
|
// 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),
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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 ->
|
items(connections, key = { it.id }) { connection ->
|
||||||
val isActive = connection.id == activeConnectionId
|
val isActive = connection.id == activeConnectionId
|
||||||
ConnectionListCard(
|
ConnectionListCard(
|
||||||
@@ -162,10 +215,118 @@ fun ConnectionsSettingsScreen(
|
|||||||
connection.hasConfiguredRelay()
|
connection.hasConfiguredRelay()
|
||||||
},
|
},
|
||||||
onClick = { onOpenConnection(connection.id) },
|
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 {
|
||||||
item { Spacer(modifier = Modifier.height(72.dp)) }
|
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?,
|
activeConnectionViewModel: ConnectionViewModel?,
|
||||||
relayConfigured: Boolean,
|
relayConfigured: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onSwitch: (() -> Unit)?,
|
||||||
|
isSwitching: Boolean,
|
||||||
|
justSwitched: Boolean,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
// Active card: muted indigo wash instead of full-strength primaryContainer —
|
// Active card: muted indigo wash instead of full-strength primaryContainer —
|
||||||
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
|
// a card-sized fill of the brand blue overwhelmed body text (2026-06-10
|
||||||
// feedback); small accents keep the vivid blue.
|
// feedback); small accents keep the vivid blue.
|
||||||
val containerColor = if (isActive) {
|
val targetContainerColor = if (isActive || isSwitching || justSwitched) {
|
||||||
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
|
com.hermesandroid.relay.ui.theme.RelayRefresh.ElectricMuted.copy(alpha = 0.25f)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -202,6 +375,7 @@ private fun ConnectionListCard(
|
|||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||||
|
border = BorderStroke(if (isSwitching || justSwitched) 1.5.dp else 0.dp, borderColor),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
@@ -209,52 +383,87 @@ private fun ConnectionListCard(
|
|||||||
) {
|
) {
|
||||||
// ── Title row ────────────────────────────────────────────────
|
// ── Title row ────────────────────────────────────────────────
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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(
|
||||||
text = connection.label,
|
text = connection.label,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(start = 10.dp),
|
||||||
)
|
)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
Badge(
|
Badge(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
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 ──────────────────────────────
|
// ── Subtitle: hostname + status ──────────────────────────────
|
||||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
val hostname = connection.primaryHost.ifBlank { connection.label }
|
||||||
val hasStandardApi = connection.apiServerUrl.isNotBlank()
|
val dashboardStatus = connection.dashboardLastStatus
|
||||||
val pairedStatus = when {
|
val dashboardSignInRequired =
|
||||||
liveState != null &&
|
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||||
(connection.pairedAt != null || liveState != RelayUiState.NotConfigured) ->
|
val connectionStatus = when {
|
||||||
liveState.statusText(connectedLabel = stringResource(R.string.conn_connected))
|
dashboardSignInRequired -> stringResource(R.string.conn_dashboard_sign_in)
|
||||||
connection.pairedAt != null -> formatPairedRelative(context, connection.pairedAt)
|
dashboardStatus?.reachable == true -> stringResource(R.string.conn_dashboard_available)
|
||||||
hasStandardApi -> stringResource(R.string.conn_standard_not_paired)
|
dashboardStatus != null && !dashboardStatus.reachable -> stringResource(R.string.conn_dashboard_offline)
|
||||||
else -> stringResource(R.string.conn_not_paired)
|
connection.resolvedDashboardUrl.isNotBlank() -> stringResource(R.string.conn_dashboard_unchecked)
|
||||||
}
|
else -> stringResource(R.string.conn_dashboard_missing)
|
||||||
val statusColor = if (liveState == RelayUiState.Stale) {
|
|
||||||
MaterialTheme.colorScheme.tertiary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "$hostname • $pairedStatus",
|
text = if (isSwitching) {
|
||||||
|
stringResource(R.string.conn_connecting_to, connection.label)
|
||||||
|
} else {
|
||||||
|
"Dashboard · $hostname"
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = statusColor,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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) ──────────────────────
|
// ── Steps/timeline summary (every card) ──────────────────────
|
||||||
ConnectionSurfaceSummary(
|
ConnectionSurfaceSummary(
|
||||||
@@ -267,6 +476,42 @@ private fun ConnectionListCard(
|
|||||||
// detail — the same destination as the card tap.
|
// detail — the same destination as the card tap.
|
||||||
onOpenDashboard = onClick,
|
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 {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
val gatewayAvailability: GatewayAvailability? = if (activeConnectionViewModel != null) {
|
||||||
|
val availability by activeConnectionViewModel.gatewayAvailability.collectAsState()
|
||||||
|
availability
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
val standardVoiceAvailability: StandardVoiceAvailability? =
|
val standardVoiceAvailability: StandardVoiceAvailability? =
|
||||||
if (activeConnectionViewModel != null) {
|
if (activeConnectionViewModel != null) {
|
||||||
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
|
val availability by activeConnectionViewModel.standardVoiceAvailability.collectAsState()
|
||||||
@@ -315,17 +566,41 @@ private fun ConnectionSurfaceSummary(
|
|||||||
val dashboardSignInRequired =
|
val dashboardSignInRequired =
|
||||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||||
|
|
||||||
val apiText = when {
|
val chatRuntimeStatus: ChatRuntimeStatus? = if (isActive) {
|
||||||
connection.apiServerUrl.isBlank() -> stringResource(R.string.conn_api_missing)
|
resolveChatRuntimeStatus(
|
||||||
activeApiHealth == ConnectionViewModel.HealthStatus.Probing -> stringResource(R.string.conn_api_checking)
|
gateway = when (gatewayAvailability) {
|
||||||
activeApiReachable == true -> stringResource(R.string.conn_api_ready)
|
GatewayAvailability.Ready -> ChatTransportReadiness.Ready
|
||||||
isActive && activeApiReachable == false -> stringResource(R.string.conn_api_offline)
|
GatewayAvailability.Unknown, null -> ChatTransportReadiness.Connecting
|
||||||
else -> stringResource(R.string.conn_api_configured)
|
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
|
val chatText = when {
|
||||||
connection.apiServerUrl.isBlank() -> SummaryTone.Warning
|
chatRuntimeStatus is ChatRuntimeStatus.Connected -> stringResource(R.string.conn_api_ready)
|
||||||
isActive && activeApiReachable == false -> SummaryTone.Warning
|
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
|
else -> SummaryTone.Neutral
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,99 +649,78 @@ private fun ConnectionSurfaceSummary(
|
|||||||
else -> SummaryTone.Neutral
|
else -> SummaryTone.Neutral
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single grouped surface with dot + label + value rows — the
|
Row(
|
||||||
// steps/timeline vocabulary shared with the detail's Features list.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) {
|
ConnectionStatusChip(Icons.Filled.Chat, stringResource(R.string.conn_chat_label), chatText, chatTone, Modifier.weight(1f))
|
||||||
ConnectionSurfaceRow(label = stringResource(R.string.conn_api_label), value = apiText, tone = apiTone)
|
ConnectionStatusChip(
|
||||||
SurfaceRowDivider()
|
Icons.Filled.Dashboard,
|
||||||
ConnectionSurfaceRow(
|
stringResource(R.string.conn_manage_label),
|
||||||
label = stringResource(R.string.conn_dashboard_label),
|
dashboardText,
|
||||||
value = dashboardText,
|
dashboardTone,
|
||||||
tone = dashboardTone,
|
Modifier.weight(1f),
|
||||||
onClick = if (dashboardSignInRequired) onOpenDashboard else null,
|
if (dashboardSignInRequired) onOpenDashboard else null,
|
||||||
)
|
)
|
||||||
SurfaceRowDivider()
|
ConnectionStatusChip(
|
||||||
ConnectionSurfaceRow(
|
Icons.Filled.GraphicEq,
|
||||||
label = stringResource(R.string.conn_voice_label),
|
stringResource(R.string.conn_voice_label),
|
||||||
value = voiceText,
|
voiceText,
|
||||||
tone = voiceTone,
|
voiceTone,
|
||||||
onClick = if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
|
Modifier.weight(1f),
|
||||||
)
|
if (standardVoiceAvailability == StandardVoiceAvailability.SignInRequired) onOpenDashboard else null,
|
||||||
SurfaceRowDivider()
|
)
|
||||||
ConnectionSurfaceRow(label = stringResource(R.string.conn_relay_label), value = relayText, tone = relayTone)
|
ConnectionStatusChip(Icons.Filled.CellTower, stringResource(R.string.conn_relay_label), relayText, relayTone, Modifier.weight(1f))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class SummaryTone { Neutral, Good, Info, Warning }
|
private enum class SummaryTone { Neutral, Good, Info, Warning }
|
||||||
|
|
||||||
/** Hairline divider between summary rows — inset so it reads as a list. */
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SurfaceRowDivider() {
|
private fun ConnectionStatusChip(
|
||||||
HorizontalDivider(
|
icon: ImageVector,
|
||||||
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(
|
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
tone: SummaryTone,
|
tone: SummaryTone,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val dotColor = 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.copy(alpha = 0.5f)
|
|
||||||
}
|
|
||||||
val valueColor = when (tone) {
|
|
||||||
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
SummaryTone.Good -> com.hermesandroid.relay.ui.theme.RelayRefresh.Green
|
||||||
SummaryTone.Info -> MaterialTheme.colorScheme.primary
|
SummaryTone.Info -> MaterialTheme.colorScheme.primary
|
||||||
SummaryTone.Warning -> MaterialTheme.colorScheme.error
|
SummaryTone.Warning -> MaterialTheme.colorScheme.error
|
||||||
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
|
SummaryTone.Neutral -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
Row(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||||
.fillMaxWidth()
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
|
||||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
shape = RoundedCornerShape(10.dp),
|
||||||
.padding(horizontal = 8.dp, vertical = 9.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
|
||||||
.size(8.dp)
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
.clip(RoundedCornerShape(50))
|
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
.background(dotColor),
|
) {
|
||||||
)
|
Icon(
|
||||||
Text(
|
imageVector = icon,
|
||||||
text = label,
|
contentDescription = null,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
modifier = Modifier.size(18.dp),
|
||||||
modifier = Modifier.weight(1f),
|
)
|
||||||
maxLines = 1,
|
Text(
|
||||||
overflow = TextOverflow.Ellipsis,
|
text = label,
|
||||||
)
|
style = MaterialTheme.typography.labelMedium,
|
||||||
Text(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
text = value,
|
maxLines = 1,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
)
|
||||||
color = valueColor,
|
Text(
|
||||||
maxLines = 1,
|
text = value,
|
||||||
overflow = TextOverflow.Ellipsis,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
color = accent,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,21 +731,15 @@ private fun Connection.hasConfiguredRelay(): Boolean {
|
|||||||
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
|
!RelayUrlDeriver.isAutoManagedRelayUrl(trimmedRelayUrl, apiServerUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun formatUsedRelative(context: Context, usedAtMillis: Long): String {
|
||||||
* Hand-rolled "N minutes ago" formatter for the card subtitle. `DateUtils`
|
val relative = if (System.currentTimeMillis() - usedAtMillis < DateUtils.MINUTE_IN_MILLIS) {
|
||||||
* returns awkward copy ("in 0 minutes") for small deltas.
|
context.getString(R.string.conn_just_now)
|
||||||
*/
|
} else {
|
||||||
private fun formatPairedRelative(context: Context, pairedAtMillis: Long): String {
|
DateUtils.getRelativeTimeSpanString(
|
||||||
val deltaMs = System.currentTimeMillis() - pairedAtMillis
|
usedAtMillis,
|
||||||
if (deltaMs < 0) return context.getString(R.string.conn_just_paired)
|
System.currentTimeMillis(),
|
||||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(deltaMs)
|
DateUtils.MINUTE_IN_MILLIS,
|
||||||
val hours = TimeUnit.MILLISECONDS.toHours(deltaMs)
|
).toString()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return context.getString(R.string.conn_last_used_format, relative)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
|
|||||||
EnableMcp,
|
EnableMcp,
|
||||||
DisableMcp,
|
DisableMcp,
|
||||||
TestMcp,
|
TestMcp,
|
||||||
|
AuthenticateMcp,
|
||||||
RemoveMcp,
|
RemoveMcp,
|
||||||
InstallMcpCatalog,
|
InstallMcpCatalog,
|
||||||
ViewProfileSoul,
|
ViewProfileSoul,
|
||||||
ActivateProfile,
|
ActivateProfile,
|
||||||
DeleteProfile,
|
DeleteProfile,
|
||||||
|
EditCustomEndpoint,
|
||||||
|
ValidateCustomEndpoint,
|
||||||
|
ActivateCustomEndpoint,
|
||||||
|
DeleteCustomEndpoint,
|
||||||
|
|
||||||
// Input-backed kinds — intercepted before runAction and routed to a
|
// Input-backed kinds — intercepted before runAction and routed to a
|
||||||
// text-input or model-picker dialog instead of firing immediately.
|
// text-input or model-picker dialog instead of firing immediately.
|
||||||
|
|||||||
+672
-141
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,708 @@
|
|||||||
|
package com.hermesandroid.relay.ui.screens
|
||||||
|
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
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.LinearProgressIndicator
|
||||||
|
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.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.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.platform.LocalResources
|
||||||
|
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 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.DashboardRedirectAuthMode
|
||||||
|
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||||
|
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||||
|
import com.hermesandroid.relay.network.upstream.androidDashboardRedirectAuthMode
|
||||||
|
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||||
|
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||||
|
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val appContext = context.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 actionIsError by remember { mutableStateOf(false) }
|
||||||
|
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||||
|
var authFlows by remember(dashboardUrl, connectionId) {
|
||||||
|
mutableStateOf<List<String>>(emptyList())
|
||||||
|
}
|
||||||
|
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
|
||||||
|
var authenticationComplete by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val cookieStoreFactory = remember(appContext, connectionId) {
|
||||||
|
{
|
||||||
|
connectionViewModel.activeDashboardCookieStore()
|
||||||
|
?: EncryptedDashboardCookieStore(appContext, connectionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||||
|
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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(appContext.cacheDir)
|
||||||
|
connectionViewModel.refreshStandardVoice()
|
||||||
|
connectionViewModel.refreshDashboardProfiles()
|
||||||
|
authenticationComplete = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(dashboardUrl, connectionId) {
|
||||||
|
if (dashboardUrl.isBlank()) {
|
||||||
|
loading = false
|
||||||
|
actionMessage = resources.getString(R.string.dashboard_no_url_configured)
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val client = clientFactory()
|
||||||
|
try {
|
||||||
|
val status = client.getStatus().getOrElse {
|
||||||
|
actionMessage = it.message ?: resources.getString(R.string.dashboard_request_failed)
|
||||||
|
actionIsError = true
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
providers = client.getAuthProviders().getOrNull()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
?: status.authProviderDetails
|
||||||
|
authFlows = status.authFlows
|
||||||
|
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
|
||||||
|
actionIsError = false
|
||||||
|
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
|
||||||
|
?: resources.getString(R.string.dashboard_signin_no_session)
|
||||||
|
actionIsError = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
actionMessage = e.message ?: resources.getString(R.string.dashboard_signin_failed)
|
||||||
|
actionIsError = true
|
||||||
|
} finally {
|
||||||
|
actionInFlight = false
|
||||||
|
client.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||||
|
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||||
|
if (
|
||||||
|
androidDashboardRedirectAuthMode(provider.name, authFlows) ==
|
||||||
|
DashboardRedirectAuthMode.WebView
|
||||||
|
) {
|
||||||
|
oauthProvider = provider
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||||
|
actionMessage = resources.getString(R.string.dashboard_native_signin_requires_https)
|
||||||
|
actionIsError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||||
|
if (authClient == null) {
|
||||||
|
actionMessage = resources.getString(R.string.dashboard_native_signin_unavailable)
|
||||||
|
actionIsError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionInFlight = true
|
||||||
|
actionIsError = false
|
||||||
|
actionMessage = resources.getString(R.string.dashboard_native_signin_opening)
|
||||||
|
nativeSignInJob = scope.launch {
|
||||||
|
try {
|
||||||
|
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
launchNativeDashboardAuthorization(context, authorizationUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val client = clientFactory()
|
||||||
|
val session = try {
|
||||||
|
verifyAndRecord(client)
|
||||||
|
} finally {
|
||||||
|
client.shutdown()
|
||||||
|
}
|
||||||
|
if (session?.authenticated == true) {
|
||||||
|
actionMessage = session.provider?.let {
|
||||||
|
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||||
|
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||||
|
actionIsError = false
|
||||||
|
finishAuthentication()
|
||||||
|
} else {
|
||||||
|
actionMessage = resources.getString(R.string.dashboard_signin_no_session)
|
||||||
|
actionIsError = true
|
||||||
|
}
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
throw cancelled
|
||||||
|
} catch (error: Exception) {
|
||||||
|
actionMessage = error.message
|
||||||
|
?: resources.getString(R.string.dashboard_signin_failed)
|
||||||
|
actionIsError = true
|
||||||
|
} finally {
|
||||||
|
actionInFlight = false
|
||||||
|
nativeSignInJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(dashboardUrl, connectionId) {
|
||||||
|
onDispose { nativeSignInJob?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthProvider?.let { provider ->
|
||||||
|
DashboardOAuthScreen(
|
||||||
|
dashboardUrl = dashboardUrl,
|
||||||
|
provider = provider,
|
||||||
|
cookieStoreFactory = cookieStoreFactory,
|
||||||
|
clientFactory = clientFactory,
|
||||||
|
onDismiss = { oauthProvider = null },
|
||||||
|
onAuthenticated = { session ->
|
||||||
|
oauthProvider = null
|
||||||
|
scope.launch {
|
||||||
|
val client = clientFactory()
|
||||||
|
try {
|
||||||
|
verifyAndRecord(client)
|
||||||
|
} finally {
|
||||||
|
client.shutdown()
|
||||||
|
}
|
||||||
|
actionMessage = session.provider?.let {
|
||||||
|
resources.getString(R.string.dashboard_signed_in_with, it)
|
||||||
|
} ?: resources.getString(R.string.dashboard_signed_in)
|
||||||
|
finishAuthentication()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
actionMessage = it
|
||||||
|
actionIsError = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
actionIsError = actionIsError,
|
||||||
|
nativeSignInInFlight = nativeSignInJob != null,
|
||||||
|
onSignIn = ::submitPassword,
|
||||||
|
onOAuthSignIn = ::startRedirectSignIn,
|
||||||
|
onCancelNativeSignIn = {
|
||||||
|
actionMessage = resources.getString(R.string.dashboard_native_signin_cancelled)
|
||||||
|
actionIsError = false
|
||||||
|
nativeSignInJob?.cancel()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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?,
|
||||||
|
actionIsError: Boolean,
|
||||||
|
nativeSignInInFlight: Boolean,
|
||||||
|
onSignIn: (String, String, String) -> Unit,
|
||||||
|
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||||
|
onCancelNativeSignIn: () -> 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 (nativeSignInInFlight) {
|
||||||
|
Button(
|
||||||
|
onClick = onCancelNativeSignIn,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.dashboard_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = if (actionIsError) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun DashboardOAuthScreen(
|
||||||
|
dashboardUrl: String,
|
||||||
|
provider: DashboardAuthProvider,
|
||||||
|
cookieStoreFactory: () -> DashboardCookieStore,
|
||||||
|
clientFactory: () -> DashboardApiClient,
|
||||||
|
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) }
|
||||||
|
var pageProgress by remember { mutableStateOf(0) }
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
val loginUrl = remember(dashboardUrl, provider.name) {
|
||||||
|
DashboardApiClient.authLoginUrl(
|
||||||
|
baseUrl = dashboardUrl,
|
||||||
|
provider = provider.name,
|
||||||
|
next = DashboardApiClient.authLandingPath(dashboardUrl),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleNavigation(url: String?) {
|
||||||
|
val loadedUrl = url?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
when (dashboardWebViewAuthNavigation(dashboardUrl, loadedUrl)) {
|
||||||
|
DashboardWebViewAuthNavigation.Continue -> return
|
||||||
|
DashboardWebViewAuthNavigation.RejectLoopbackCallback -> {
|
||||||
|
statusText = notAcceptedStatus
|
||||||
|
onError(notAcceptedStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DashboardWebViewAuthNavigation.ImportAndVerify -> Unit
|
||||||
|
}
|
||||||
|
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 = clientFactory()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = onDismiss)
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
webView?.stopLoading()
|
||||||
|
webView?.destroy()
|
||||||
|
webView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.dashboard_signin_with_provider,
|
||||||
|
provider.displayName ?: provider.name,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.dashboard_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
if (pageProgress in 0..99) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { pageProgress / 100f },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
factory = { viewContext ->
|
||||||
|
CookieManager.getInstance().setAcceptCookie(true)
|
||||||
|
WebView(viewContext).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||||
|
pageProgress = newProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
): Boolean {
|
||||||
|
val target = request.url.toString()
|
||||||
|
if (
|
||||||
|
dashboardWebViewAuthNavigation(dashboardUrl, target) ==
|
||||||
|
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||||
|
) {
|
||||||
|
handleNavigation(target)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
error: WebResourceError,
|
||||||
|
) {
|
||||||
|
super.onReceivedError(view, request, error)
|
||||||
|
if (request.isForMainFrame) {
|
||||||
|
val message = error.description?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: verifyFailedStatus
|
||||||
|
statusText = message
|
||||||
|
onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
handleNavigation(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView = this
|
||||||
|
loadUrl(loginUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum class DashboardWebViewAuthNavigation {
|
||||||
|
Continue,
|
||||||
|
ImportAndVerify,
|
||||||
|
RejectLoopbackCallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android redirect providers use the dashboard's cookie/OIDC flow. A foreign
|
||||||
|
* loopback callback belongs to the desktop native-PKCE contract and must never
|
||||||
|
* be followed, imported, or treated as an authenticated Android return.
|
||||||
|
*/
|
||||||
|
internal fun dashboardWebViewAuthNavigation(
|
||||||
|
dashboardUrl: String,
|
||||||
|
loadedUrl: String,
|
||||||
|
): DashboardWebViewAuthNavigation {
|
||||||
|
val dashboard = dashboardUrl.trim().trimEnd('/').toHttpUrlOrNull()
|
||||||
|
?: return DashboardWebViewAuthNavigation.Continue
|
||||||
|
val loaded = loadedUrl.trim().toHttpUrlOrNull()
|
||||||
|
?: return DashboardWebViewAuthNavigation.Continue
|
||||||
|
val sameOrigin = dashboard.scheme == loaded.scheme &&
|
||||||
|
dashboard.host.equals(loaded.host, ignoreCase = true) &&
|
||||||
|
dashboard.port == loaded.port
|
||||||
|
if (!sameOrigin) {
|
||||||
|
val foreignLoopback = loaded.scheme == "http" &&
|
||||||
|
loaded.host in setOf("127.0.0.1", "localhost", "::1") &&
|
||||||
|
loaded.encodedPath == "/callback"
|
||||||
|
return if (foreignLoopback) {
|
||||||
|
DashboardWebViewAuthNavigation.RejectLoopbackCallback
|
||||||
|
} else {
|
||||||
|
DashboardWebViewAuthNavigation.Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val basePath = dashboard.encodedPath.trimEnd('/')
|
||||||
|
val relativePath = loaded.encodedPath
|
||||||
|
.removePrefix(basePath)
|
||||||
|
.ifBlank { "/" }
|
||||||
|
return if (
|
||||||
|
relativePath.equals("/login", ignoreCase = true) ||
|
||||||
|
relativePath.equals("/auth/login", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
DashboardWebViewAuthNavigation.Continue
|
||||||
|
} else {
|
||||||
|
// Includes the public /auth/callback response: import its cookies at
|
||||||
|
// root scope, then verify the resulting session through /api/auth/me.
|
||||||
|
DashboardWebViewAuthNavigation.ImportAndVerify
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
||||||
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
|
* unlock. Hosts experimental labs, test tools, and data-management utilities.
|
||||||
* (clear session / wipe caches), and any experimental toggles.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
|
|||||||
connectionViewModel: ConnectionViewModel,
|
connectionViewModel: ConnectionViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToRealtimeVoice: () -> Unit = {},
|
onNavigateToRealtimeVoice: () -> Unit = {},
|
||||||
|
onNavigateToImageGenerationLab: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isDarkTheme = LocalBrand.current.isDark
|
val isDarkTheme = LocalBrand.current.isDark
|
||||||
|
|
||||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
|
||||||
|
|
||||||
// Data management local state — unfolded from the private
|
// Data management local state — unfolded from the private
|
||||||
// DataManagementSection helper in the old SettingsScreen.
|
// DataManagementSection helper in the old SettingsScreen.
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
connectionViewModel.resetOnboarding()
|
connectionViewModel.resetOnboarding { success ->
|
||||||
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
if (success) {
|
||||||
|
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.dev_settings_reset_failed)
|
||||||
|
},
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.RestartAlt,
|
imageVector = Icons.Filled.RestartAlt,
|
||||||
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
|
|||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// Relay features toggle
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Science,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.dev_settings_relay_features),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.dev_settings_relay_features_desc),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = relayEnabled,
|
|
||||||
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (FeatureFlags.isDevBuild) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Science,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.dev_settings_image_generation_lab),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onNavigateToImageGenerationLab) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Science,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.dev_settings_open_image_generation_lab_cd
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
// Lock developer options
|
// Lock developer options
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch { FeatureFlags.lockDevOptions(context) }
|
scope.launch {
|
||||||
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
|
FeatureFlags.lockDevOptions(context)
|
||||||
onBack()
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.dev_settings_locked_toast),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Lock,
|
imageVector = Icons.Filled.Lock,
|
||||||
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
showExportDialog = false
|
showExportDialog = false
|
||||||
connectionViewModel.exportSettings { json ->
|
connectionViewModel.exportSettings { json ->
|
||||||
backupJson = json
|
if (json == null) {
|
||||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.dev_settings_export_failed),
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
backupJson = json
|
||||||
|
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -577,8 +601,17 @@ fun DeveloperSettingsScreen(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showResetDialog = false
|
showResetDialog = false
|
||||||
connectionViewModel.resetAppData()
|
connectionViewModel.resetAppData { success ->
|
||||||
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
if (success) {
|
||||||
|
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.dev_settings_reset_failed)
|
||||||
|
},
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
|
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user