feat: rename repo to hermes-relay, add GitHub Pages docs deployment
Rename from hermes-android to hermes-relay for platform-agnostic branding. Add VitePress GitHub Pages workflow (builds on user-docs/** changes). Update all repo URL references across docs, code, Docker, systemd, plugin. Remove ARC/ClawPort from Related Projects, clean up README. Fix pre-existing bug in plugin/install.sh (wrong source directory name). Includes accumulated changes: ASCII morphing sphere, ambient mode, animation settings, configurable limits, token tracking fixes, file attachments, feature gating, developer options, MCP tooling, audit fixes, and VitePress docs site. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@@ -1,16 +1,16 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of the changes -->
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Build passes (`scripts/dev.bat build` or `./gradlew assembleDebug`)
|
||||
- [ ] Tests pass (`scripts/dev.bat test` or `./gradlew test`)
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] CHANGELOG.md updated (if user-facing change)
|
||||
- [ ] No credentials, API keys, or secrets in committed files
|
||||
- [ ] `./gradlew assembleDebug` succeeds
|
||||
- [ ] `./gradlew test` passes
|
||||
- [ ] Tested on emulator or device (if UI change)
|
||||
|
||||
## Test Plan
|
||||
|
||||
<!-- How was this tested? -->
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] CHANGELOG.md updated (if user-facing)
|
||||
- [ ] No credentials or secrets in committed files
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
auth:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
authorized: ${{ steps.check.outputs.authorized }}
|
||||
steps:
|
||||
- name: Check collaborator status
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'issues' && ['opened', 'labeled'].includes(context.payload.action)) {
|
||||
core.setOutput('authorized', 'true');
|
||||
return;
|
||||
}
|
||||
const sender = context.payload.sender?.login;
|
||||
if (!sender) { core.setOutput('authorized', 'false'); return; }
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, username: sender,
|
||||
});
|
||||
const allowed = ['admin', 'write', 'maintain'].includes(data.permission);
|
||||
core.setOutput('authorized', allowed ? 'true' : 'false');
|
||||
} catch {
|
||||
core.setOutput('authorized', 'false');
|
||||
}
|
||||
|
||||
triage:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude') ||
|
||||
(github.event_name == 'issues' && github.event.action == 'opened')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Triage this GitHub issue. Analysis-only — do NOT write code or create PRs.
|
||||
|
||||
1. **Classify** — bug, feature request, question, or docs issue?
|
||||
2. **Priority** — critical, high, medium, low based on impact.
|
||||
3. **Affected area** — which module(s)? Check CLAUDE.md for architecture.
|
||||
(ui/, network/, viewmodel/, auth/, data/, relay_server/, plugin/)
|
||||
4. **Reproduction** — for bugs, is there enough info? Ask for device, Android version, steps.
|
||||
5. **Suggested approach** — brief outline (files, strategy).
|
||||
6. **Labels** — suggest appropriate labels.
|
||||
|
||||
Keep it concise and actionable.
|
||||
claude_args: "--max-turns 5"
|
||||
|
||||
fix:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' &&
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'claude-fix'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Implement a fix for this GitHub issue. Read CLAUDE.md for project conventions.
|
||||
|
||||
1. Understand the issue — read relevant source files.
|
||||
2. Implement the minimal fix.
|
||||
3. Follow conventions: Kotlin + Jetpack Compose, kotlinx.serialization, Conventional Commits.
|
||||
4. Run `./gradlew assembleDebug` and fix any errors.
|
||||
5. Create a PR with Conventional Commits format title.
|
||||
|
||||
Do NOT over-engineer. Only change what is needed.
|
||||
claude_args: "--max-turns 25"
|
||||
|
||||
chat:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Responding to a collaborator comment. Read CLAUDE.md for project context.
|
||||
|
||||
Default mode is analysis — investigate, explain, suggest. Do NOT write code
|
||||
unless explicitly asked ("fix this", "implement", "create a PR").
|
||||
|
||||
If asked to fix: follow conventions (Kotlin, Compose, Conventional Commits),
|
||||
run `./gradlew assembleDebug`, and create a PR.
|
||||
claude_args: "--max-turns 15"
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Dependabot Auto-Merge
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable auto-merge (patch + minor only)
|
||||
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Hermes Relay — Docs Deployment
|
||||
#
|
||||
# Builds VitePress docs and deploys to GitHub Pages.
|
||||
# Triggers on pushes to main that change user-docs/ content,
|
||||
# or manually via workflow_dispatch.
|
||||
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'user-docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
run: npm run build
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,8 +1,8 @@
|
||||
# Hermes Relay — Release Pipeline
|
||||
#
|
||||
# Triggered when a version tag (v*) is pushed.
|
||||
# Validates the tag matches the app version, builds a release APK,
|
||||
# and creates a GitHub Release with the artifact attached.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs CI checks, builds a release APK, and creates a GitHub Release.
|
||||
|
||||
name: Release
|
||||
|
||||
@@ -11,15 +11,44 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
validate:
|
||||
name: Validate Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Required for creating GitHub Releases
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.version.outputs.version }}"
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
echo "libs.versions.toml version: $TOML_VERSION"
|
||||
|
||||
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
@@ -30,38 +59,42 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
# Extract versionName from build.gradle.kts and compare with the git tag
|
||||
- name: Validate version matches tag
|
||||
run: |
|
||||
# Extract versionName from app/build.gradle.kts
|
||||
VERSION_NAME=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
echo "App versionName: $VERSION_NAME"
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
|
||||
if [ "$VERSION_NAME" != "$TAG_VERSION" ]; then
|
||||
echo "::error::Version mismatch! Tag $TAG_VERSION does not match app versionName $VERSION_NAME"
|
||||
exit 1
|
||||
fi
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
echo "Version validated: $VERSION_NAME"
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
|
||||
# Generate SHA256 checksum for the APK
|
||||
- name: Generate checksum
|
||||
run: |
|
||||
cd app/build/outputs/apk/release
|
||||
sha256sum *.apk > SHA256SUMS.txt
|
||||
|
||||
# Create a GitHub Release using RELEASE_NOTES.md as the body
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
files: |
|
||||
app/build/outputs/apk/release/*.apk
|
||||
app/build/outputs/apk/release/SHA256SUMS.txt
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
|
||||
@@ -48,3 +48,11 @@ certs/
|
||||
user-docs/.vitepress/cache/
|
||||
user-docs/.vitepress/dist/
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# Local upstream reference
|
||||
hermes-agent-upstream/
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
|
||||
@@ -5,10 +5,21 @@ This extension adds Android device control to hermes-agent via the `android` too
|
||||
It communicates with the Hermes Relay app running on an Android device over WSS.
|
||||
|
||||
## Setup
|
||||
|
||||
### Quick start (relay + plugin)
|
||||
|
||||
```bash
|
||||
pip install aiohttp pyyaml && python -m relay_server --no-ssl # start relay
|
||||
cp -r plugin ~/.hermes/plugins/hermes-android # install plugin
|
||||
```
|
||||
|
||||
Then restart hermes-agent. See [docs/relay-server.md](docs/relay-server.md) for Docker, systemd, TLS, and configuration options.
|
||||
|
||||
### Full setup
|
||||
1. Install the Hermes Relay APK on the Android device (build via `scripts/dev.bat build`)
|
||||
2. Grant the app Accessibility Service permission in Settings > Accessibility
|
||||
3. Grant SYSTEM_ALERT_WINDOW permission
|
||||
4. Start the relay server: `python -m relay_server --no-ssl`
|
||||
4. Start the relay server: `pip install aiohttp pyyaml && python -m relay_server --no-ssl`
|
||||
5. Install the plugin: `cp -r plugin ~/.hermes/plugins/hermes-android`
|
||||
6. Restart hermes-agent
|
||||
|
||||
|
||||
@@ -2,69 +2,103 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-04-05
|
||||
## [0.1.0] - 2026-04-07
|
||||
|
||||
### Added
|
||||
- **Direct API chat** — chat connects directly to Hermes API Server via `/api/sessions` SSE
|
||||
- **Session management** — create, switch, rename, delete chat sessions
|
||||
- **Session drawer** — slide-out panel listing all sessions with title, timestamp, message count
|
||||
- **Message history** — loads when switching sessions from the server
|
||||
- **Auto-session titles** — first user message auto-titles the session (truncated to 50 chars)
|
||||
- **Session persistence** — last session ID saved to DataStore, resumes on app restart
|
||||
- **What's New auto-show** — dialog shown automatically when app version changes
|
||||
- **HermesApiClient** — full session CRUD + SSE streaming via `/api/sessions/{id}/chat/stream`
|
||||
- **API key storage** — securely stored in EncryptedSharedPreferences
|
||||
- **Dual connection model** — API Server for chat, Relay Server for bridge/terminal
|
||||
- **Test Connection** — verify API server reachability from onboarding and settings
|
||||
- **Cancel streaming** — stop button to cancel in-flight chat responses
|
||||
- **Network security config** — cleartext restricted to localhost only
|
||||
- **Markdown rendering** — assistant messages render code blocks, bold, italic, links, lists
|
||||
- **Message copy** — long-press any message to copy text to clipboard
|
||||
- **Reasoning display** — collapsible thinking block above assistant responses (toggle in Settings)
|
||||
- **Token tracking** — per-message input/output token count and estimated cost
|
||||
- **Personality picker** — 8 built-in personalities (concise, creative, technical, pirate, etc.)
|
||||
- **Error retry** — retry button in error banner re-sends last failed message
|
||||
- **Offline detection** — banner shown when network connectivity is lost
|
||||
- **Haptic feedback** — on send, stream complete, error, and message copy
|
||||
- **Input character limit** — 4096 character limit with counter
|
||||
- **Responsive layout** — bubble widths adapt to phone, tablet, and landscape
|
||||
- **Enriched tool cards** — tool-type icons, completion duration tracking
|
||||
|
||||
### Changed
|
||||
- Chat no longer routes through relay server — direct to API server
|
||||
- Onboarding collects API Server URL + API Key (required) + Relay URL (optional)
|
||||
- Settings split into separate API Server and Relay Server cards
|
||||
- ChatHandler refactored from envelope-based to typed SSE entry points
|
||||
- Backup format bumped to v2 with separate apiServerUrl/relayUrl fields
|
||||
- App version bumped to 0.2.0
|
||||
- **ASCII morphing sphere** — animated 3D character sphere on empty chat screen (pure Compose Canvas, `. : - = + * # % @` characters, green-purple color pulse, 3D lighting)
|
||||
- **Ambient mode** — toggle in chat header hides messages and shows sphere fullscreen; tap to return to chat
|
||||
- **Animation behind messages** — sphere renders at 15% opacity behind chat message list as subtle background (toggleable)
|
||||
- **Animation settings** — Settings > Appearance section with "ASCII sphere" and "Behind messages" toggles
|
||||
- **File attachments** — attach files via `+` button; images, documents, PDFs sent as base64 in the Hermes API `attachments` format
|
||||
- **Attachment preview** — horizontal strip above input shows thumbnails (images) or file badges (other types) with remove button
|
||||
- **Message queuing** — send messages while the agent is streaming; queued messages auto-send when the current response completes
|
||||
- **Queue indicator** — animated bar above input shows queued count with clear button
|
||||
- **Configurable limits** — expandable Limits section in Chat settings for max attachment size (1–50 MB) and message length (1K–16K chars)
|
||||
- **Stats for Nerds enhancements** — reset button, tokens per message average, peak TTFT, slowest completion, seconds subtext on all ms values
|
||||
- **Feature gating** — `FeatureFlags` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides
|
||||
- **Developer Options** — hidden settings section, tap version 7 times to unlock (same pattern as Android system Developer Options)
|
||||
- **Relay feature toggle** — relay server settings and pairing sections gated behind developer options in release builds
|
||||
- **Dynamic onboarding** — terminal, bridge, and relay pages excluded from onboarding when relay feature disabled
|
||||
- **Parse tool annotations** — experimental annotation parsing for Sessions mode (marked with badge, disabled for Runs mode)
|
||||
- **Privacy policy link** — accessible from Settings → About
|
||||
- **MCP tooling docs** — `docs/mcp-tooling.md` reference for android-tools-mcp + mobile-mcp development setup
|
||||
- **Dev scripts** — added `release`, `bundle`, `version` commands to `scripts/dev.bat`
|
||||
- **MIT LICENSE** — added project license file
|
||||
|
||||
### Fixed
|
||||
- SSE callbacks now dispatched to main thread for safe StateFlow updates
|
||||
- Overlapping streams prevented — previous stream cancelled before new send
|
||||
- Tool call completion now matches by tool call ID (not first incomplete)
|
||||
- Onboarding test connection properly cleans up client on failure
|
||||
- Health check loop only runs when API client is configured
|
||||
- **Empty bubbles** — messages with blank content and no tool calls are now hidden from chat
|
||||
- **App icon** — adaptive icon foreground scaled to 75% via `<group>` transform for proper safe zone padding
|
||||
- **Token tracking** — usage data now extracted before SSE event type resolution, fixing 0 token counts when server sends OpenAI-format events
|
||||
- **Token field compatibility** — `UsageInfo` accepts both `input_tokens`/`output_tokens` (Hermes) and `prompt_tokens`/`completion_tokens` (OpenAI)
|
||||
- **Keyboard gap** — removed Scaffold content window insets that stacked with ChatScreen's IME padding
|
||||
- **Session drawer highlight** — active session now properly highlighted (background color was computed but not applied)
|
||||
- **Privacy doc** — added CAMERA permission, corrected network security description
|
||||
- **CHANGELOG URLs** — fixed comparison links to use correct GitHub repository
|
||||
- **FOREGROUND_SERVICE** — removed unused permission from AndroidManifest
|
||||
- **Plugin refs** — updated from raulvidis to Codename-11
|
||||
|
||||
## [0.1.0] - 2026-04-05
|
||||
### Changed
|
||||
- Version bumped from `0.1.0-beta` to `0.1.0` for Google Play release
|
||||
- Input bar shows both Stop and Send buttons during streaming (previously only Stop)
|
||||
- Onboarding page flow now uses enum-based dynamic list instead of hardcoded indices
|
||||
|
||||
## [0.1.0-beta] - 2026-04-06
|
||||
|
||||
MVP release — native Android companion app for Hermes agent with direct API chat, session management, and full Compose UI.
|
||||
|
||||
### Added
|
||||
- **Android app** — Jetpack Compose scaffold with chat, terminal (stub), bridge (stub), settings
|
||||
|
||||
#### Core Chat
|
||||
- **Direct API chat** — connects to Hermes API Server via `/api/sessions/{id}/chat/stream` with SSE streaming
|
||||
- **HermesApiClient** — full session CRUD + SSE streaming, health checks, cancel support
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Relay Server (WSS) for bridge/terminal
|
||||
- **API key auth** — optional Bearer token stored in EncryptedSharedPreferences
|
||||
- **Cancel streaming** — stop button to cancel in-flight chat responses
|
||||
- **Error retry** — retry button in error banner re-sends last failed message
|
||||
|
||||
#### Session Management
|
||||
- **Session CRUD** — create, switch, rename, delete chat sessions via Sessions API
|
||||
- **Session drawer** — slide-out panel listing all sessions with title, timestamp, message count
|
||||
- **Message history** — loads from server when switching sessions
|
||||
- **Auto-session titles** — first user message auto-titles the session (truncated to 50 chars)
|
||||
- **Session persistence** — last session ID saved to DataStore, resumes on app restart
|
||||
|
||||
#### Chat UI
|
||||
- **Markdown rendering** — assistant messages render code blocks, bold, italic, links, lists (mikepenz multiplatform-markdown-renderer)
|
||||
- **Reasoning display** — collapsible thinking block above assistant responses (toggle in Settings)
|
||||
- **Token tracking** — per-message input/output token count and estimated cost
|
||||
- **Personality picker** — dynamic personalities from server config (`config.agent.personalities`), agent name on chat bubbles
|
||||
- **Message copy** — long-press any message to copy text to clipboard
|
||||
- **Enriched tool cards** — tool-type icons, completion duration tracking
|
||||
- **Responsive layout** — bubble widths adapt to phone, tablet, and landscape
|
||||
- **Input character limit** — 4096 character limit with counter
|
||||
- **Haptic feedback** — on send, stream complete, error, and message copy
|
||||
|
||||
#### App Foundation
|
||||
- **Jetpack Compose scaffold** — bottom nav with Chat, Terminal (stub), Bridge (stub), Settings
|
||||
- **WSS connection manager** — OkHttp WebSocket with auto-reconnect and exponential backoff
|
||||
- **Channel multiplexer** — typed envelope protocol for chat/terminal/bridge/system
|
||||
- **Auth flow** — 6-character pairing code with session token persistence
|
||||
- **Chat UI** — message bubbles, streaming text, tool progress cards, profile selector
|
||||
- **Relay server** — Python aiohttp WSS server proxying to Hermes WebAPI
|
||||
- **Material 3 + Material You** — dynamic theming with light/dark/auto
|
||||
- **Onboarding** — 5-page pager with feature overview and connection setup
|
||||
- **Settings** — connection management, theme, data export/import/reset
|
||||
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **Onboarding** — multi-page pager with feature overview and connection setup
|
||||
- **Settings** — API Server + Relay Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Offline detection** — banner shown when network connectivity is lost
|
||||
- **What's New dialog** — shown automatically when app version changes
|
||||
- **Splash screen** — branded splash via core-splashscreen API
|
||||
- **Network security** — cleartext restricted to localhost only
|
||||
|
||||
[Unreleased]: https://github.com/user/hermes-android/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/user/hermes-android/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/user/hermes-android/releases/tag/v0.1.0
|
||||
#### Infrastructure
|
||||
- **Relay server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
|
||||
- **Claude Code automation** — issue triage, PR fix, chat, and code review workflows
|
||||
- **Dependabot** — weekly Gradle + GitHub Actions dependency updates with auto-merge
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
|
||||
[0.1.0-beta]: https://github.com/Codename-11/hermes-relay/releases/tag/v0.1.0-beta
|
||||
|
||||
@@ -6,16 +6,53 @@
|
||||
|
||||
A native Android app for Hermes agent. Chat connects directly to the Hermes API Server. Bridge and terminal channels use a relay server over WSS. The app is Kotlin + Jetpack Compose. The server relay is Python + aiohttp.
|
||||
|
||||
**Current state:** MVP Phase 0 + Phase 1 complete with direct API chat. The Android app connects to the Hermes API Server (`/api/sessions/{id}/chat/stream`) for chat via HTTP/SSE. The relay server handles bridge (Phase 3) and terminal (Phase 2) via WSS. Auth uses optional Bearer token for API, pairing code for relay.
|
||||
**Current state:** v0.1.0 (Google Play). Phase 0 + Phase 1 complete with direct API chat, session management, markdown rendering, messaging-style chat header (avatar + agent name + model subtitle), personality picker with agent name on bubbles, searchable command palette (29 gateway commands + dynamic personalities + server skills), QR code pairing, ConnectionStatusBadge (animated pulse ring), in-app analytics (Stats for Nerds with reset, peak times, tokens/msg), animated splash screen, tool display configuration, client-side message queuing (send while streaming), file attachments (images, documents, any file type via base64), configurable limits (attachment size, message length), feature gating with Developer Options, ASCII morphing sphere animation (empty chat state + ambient mode + behind-messages background), and animation settings in Settings. The relay server handles bridge (Phase 3) and terminal (Phase 2) via WSS. Auth uses optional Bearer token for API, pairing code for relay. Relay/pairing settings are hidden in production behind Developer Options (tap version 7x to unlock).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct, OpenAI-compatible]
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
```
|
||||
|
||||
Chat goes directly to the API server using the Hermes Sessions API (`/api/sessions/{id}/chat/stream`) with SSE streaming. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
|
||||
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
|
||||
|
||||
### Upstream Hermes API Reference
|
||||
|
||||
**IMPORTANT:** Always verify endpoints against the actual hermes-agent source (`gateway/platforms/api_server.py`). The upstream repo is the source of truth — not our docs, not our memory, not assumptions from other frontends.
|
||||
|
||||
**Standard endpoints (confirmed in hermes-agent source):**
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
|----------|---------|-----------------|
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (`` `💻 terminal` ``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management | — |
|
||||
|
||||
**Non-standard endpoints (may be version-specific):**
|
||||
|
||||
These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream source**. They may be fork-specific, version-specific, or added by plugins. Always use `detectChatMode()` to probe availability.
|
||||
|
||||
| Endpoint | Purpose | Fallback |
|
||||
|----------|---------|----------|
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Use `/v1/runs` or `/v1/chat/completions` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions` | Session CRUD | Use `X-Hermes-Session-Id` header with `/v1/chat/completions` |
|
||||
| `GET /api/skills` | Skill discovery | Hardcoded command list |
|
||||
| `GET /api/config` | Server config (personalities, model) | No fallback — personality picker empty |
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** (`/v1/runs`) — Best for tool display. Emits `tool.started`/`tool.completed` as real SSE events → rendered as ToolProgressCards.
|
||||
2. **Sessions/Chat Completions** — Tool progress injected as inline markdown (`` `💻 terminal` ``). The `ChatHandler` annotation parser detects these patterns and converts them to ToolCall objects client-side.
|
||||
3. **Annotation parser** (`ChatHandler.parseAnnotationLine`) — Fallback for any endpoint. Matches backtick-wrapped emoji+tool_name patterns. If your Hermes version uses a different format, check `adb logcat -s HermesApiClient` for raw SSE events and update the regex.
|
||||
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document it as non-standard and implement a fallback.
|
||||
- When building features that interface with hermes-agent, reference the upstream source — not just our spec docs. Our spec may be aspirational or based on a specific server version.
|
||||
- If we use a non-standard endpoint, mark it clearly in code comments and ensure `detectChatMode()` handles its absence gracefully.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -44,6 +81,8 @@ hermes-android/ ← Android Studio opens this root
|
||||
│ ├── tools/ # Standalone toolset
|
||||
│ ├── skills/ # Agent skills
|
||||
│ └── tests/
|
||||
├── skills/ ← Installable Hermes skills
|
||||
│ └── hermes-pairing-qr/ # QR code pairing (hermes-pair script + SKILL.md)
|
||||
├── docs/ ← spec, decisions, security
|
||||
└── .github/workflows/ ← CI + release
|
||||
```
|
||||
@@ -86,13 +125,38 @@ hermes-android/ ← Android Studio opens this root
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `app/src/main/kotlin/.../ui/RelayApp.kt` | Main scaffold — bottom nav, navigation |
|
||||
| `app/src/main/kotlin/.../network/HermesApiClient.kt` | Direct HTTP/SSE client for Hermes API Server |
|
||||
| `app/src/main/kotlin/.../network/HermesApiClient.kt` | Direct HTTP/SSE client — `sendChatStream()` for sessions endpoint, `sendRunStream()` for runs endpoint, `detectChatMode()` for capability probing |
|
||||
| `app/src/main/kotlin/.../network/ConnectionManager.kt` | WSS connection with auto-reconnect (relay) |
|
||||
| `app/src/main/kotlin/.../network/ChannelMultiplexer.kt` | Envelope routing by channel (relay) |
|
||||
| `app/src/main/kotlin/.../network/handlers/ChatHandler.kt` | Chat message state + streaming event processing |
|
||||
| `app/src/main/kotlin/.../ui/screens/ChatScreen.kt` | Chat UI — streaming messages, tool cards |
|
||||
| `relay_server/relay.py` | Relay server (bridge/terminal only) |
|
||||
| `app/src/main/kotlin/.../network/ConnectivityObserver.kt` | Reactive network connectivity listener |
|
||||
| `app/src/main/kotlin/.../network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser (inline markdown → ToolCall) |
|
||||
| `app/src/main/kotlin/.../network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `app/src/main/kotlin/.../data/FeatureFlags.kt` | Feature gating — compile-time defaults (DEV_MODE) + runtime DataStore overrides |
|
||||
| `app/src/main/kotlin/.../data/AppAnalytics.kt` | In-app analytics singleton (TTFT, tokens, health, stream rates) |
|
||||
| `app/src/main/kotlin/.../ui/screens/ChatScreen.kt` | Chat UI — streaming messages, slash commands, tool cards |
|
||||
| `app/src/main/kotlin/.../ui/screens/SettingsScreen.kt` | Settings — connection, chat, appearance, analytics, about |
|
||||
| `app/src/main/kotlin/.../ui/components/StatsForNerds.kt` | Canvas bar charts for analytics display |
|
||||
| `app/src/main/kotlin/.../ui/components/CompactToolCall.kt` | Inline compact tool call display |
|
||||
| `app/src/main/kotlin/.../ui/components/PersonalityPicker.kt` | Personality picker dropdown (from config.agent.personalities) |
|
||||
| `app/src/main/kotlin/.../ui/components/CommandPalette.kt` | Searchable command palette (bottom sheet) + inline autocomplete |
|
||||
| `app/src/main/kotlin/.../ui/components/ConnectionStatusBadge.kt` | Animated pulse ring status indicator (connected/connecting/disconnected) |
|
||||
| `app/src/main/kotlin/.../ui/components/MorphingSphere.kt` | ASCII morphing sphere — 3D lit character sphere with color pulse, used in empty chat state, ambient mode, and behind-messages background |
|
||||
| `app/src/main/kotlin/.../ui/components/MessageBubble.kt` | Message bubbles with markdown, tokens, tool cards |
|
||||
| `app/src/main/kotlin/.../ui/components/ToolProgressCard.kt` | Expandable tool execution card (auto-expand/collapse) |
|
||||
| `app/src/main/kotlin/.../viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `app/src/main/kotlin/.../viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay) |
|
||||
| `app/src/main/res/drawable/splash_icon.xml` | Splash screen icon (0.9x scale) |
|
||||
| `app/src/main/res/drawable/splash_icon_animated.xml` | Animated splash (scale + overshoot + fade) |
|
||||
| `relay_server/relay.py` | Relay server — main WSS server (bridge/terminal only) |
|
||||
| `relay_server/SKILL.md` | Hermes skill reference for relay self-setup |
|
||||
| `relay_server/Dockerfile` | Container image for relay server |
|
||||
| `relay_server/hermes-relay.service` | Systemd unit file for persistent deployment |
|
||||
| `docs/relay-server.md` | Relay server setup, config, Docker, systemd, TLS reference |
|
||||
| `app/src/main/kotlin/.../ui/components/QrPairingScanner.kt` | QR code scanner + Hermes pairing payload parser |
|
||||
| `skills/hermes-pairing-qr/SKILL.md` | QR pairing skill for hermes-agent (install to ~/.hermes/skills/) |
|
||||
| `skills/hermes-pairing-qr/hermes-pair` | QR code generator script (install to ~/.local/bin/) |
|
||||
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -104,12 +168,26 @@ hermes-android/ ← Android Studio opens this root
|
||||
- **Don't put documentation in root** — long-form docs go in `docs/`
|
||||
- **Don't forget DEVLOG.md** — update it
|
||||
|
||||
## MCP Tooling
|
||||
|
||||
Two MCP servers are configured for AI-assisted development. See `docs/mcp-tooling.md` for full reference.
|
||||
|
||||
| Server | Layer | Requires |
|
||||
|--------|-------|----------|
|
||||
| `android-tools-mcp` | IDE/Build — Compose previews, Gradle, code search, Android docs | Android Studio running with project open |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
|
||||
Together they cover the full loop: code → preview → build → deploy → interact → screenshot.
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
```bash
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat build # Build debug APK (DEV_MODE=true)
|
||||
scripts/dev.bat release # Build signed release APK (DEV_MODE=false)
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play upload
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version from libs.versions.toml
|
||||
scripts/dev.bat relay # Start relay server (dev mode, no SSL)
|
||||
```
|
||||
|
||||
@@ -117,15 +195,33 @@ Open repo root in Android Studio for Compose previews and device deployment.
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Surface | Endpoint |
|
||||
|---------|----------|
|
||||
| WebAPI chat | `POST localhost:8642/api/sessions/{id}/chat/stream` (SSE) |
|
||||
| WebAPI sessions | `GET/POST localhost:8642/api/sessions` |
|
||||
| Agent profiles | Read from `~/.hermes/config.yaml` |
|
||||
| Plugin tools | `android_*` via `plugin/` |
|
||||
| Surface | Standard Endpoint | Non-Standard Fallback |
|
||||
|---------|-------------------|----------------------|
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` (structured tool events) | `POST /api/sessions/{id}/chat/stream` (inline tool text) |
|
||||
| Chat (OpenAI compat) | `POST /v1/chat/completions` (stream=true) | — |
|
||||
| Session CRUD | `X-Hermes-Session-Id` header on `/v1/chat/completions` | `GET/POST/PATCH/DELETE /api/sessions` (non-standard) |
|
||||
| Personalities | Read from `~/.hermes/config.yaml` | `GET /api/config` (non-standard) |
|
||||
| Server skills | — | `GET /api/skills` (non-standard) |
|
||||
| Health check | `GET /health` or `GET /v1/health` | — |
|
||||
| Models | `GET /v1/models` | — |
|
||||
| Plugin tools | `android_*` via `plugin/` | — |
|
||||
|
||||
## Upstream References
|
||||
|
||||
When working on features that interface with hermes-agent, consult these source files directly:
|
||||
|
||||
| Topic | Upstream File |
|
||||
|-------|--------------|
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)** — the agent platform (gateway, WebAPI, plugin system)
|
||||
- **[ARC](https://github.com/Codename-11/ARC)** — CI/CD patterns, project conventions reference
|
||||
- **[ClawPort](https://github.com/Codename-11/clawport-ui)** — web dashboard (parallel project, uses same WebAPI)
|
||||
- **[android-tools-mcp](https://github.com/Codename-11/android-tools-mcp)** — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- **[mobile-mcp](https://github.com/mobile-next/mobile-mcp)** — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
|
||||
@@ -1,5 +1,289 @@
|
||||
# Hermes Relay — Dev Log
|
||||
|
||||
## 2026-04-07 — ASCII Morphing Sphere, Ambient Mode, Animation Settings, Polish Fixes
|
||||
|
||||
**Done:**
|
||||
- **ASCII morphing sphere** — animated visualization on the empty chat screen, inspired by AMP Code CLI. Pure Compose Canvas rendering (no OpenGL). Characters `. : - = + * # % @` form a sphere shape with 3D lighting. Color pulses green to purple. Contained in square aspect ratio box above "Start a conversation" text.
|
||||
- **Ambient mode** — toggle button (AutoAwesome icon) in chat header bar hides messages and shows the sphere fullscreen. Tap the ChatBubble icon to return to chat.
|
||||
- **Animation behind messages** — sphere renders at 15% opacity behind the chat message list as a subtle ambient background. Toggleable in Settings.
|
||||
- **Animation settings** — new section in Settings under Appearance: "ASCII sphere" toggle (on by default), "Behind messages" toggle (on by default, disabled when animation is off).
|
||||
- **Parse tool annotations** — marked as "Experimental" badge, disabled/dimmed when streaming endpoint is "Runs" mode (only relevant for Sessions mode).
|
||||
- **Empty bubble fix** — messages with blank content and no tool calls are now hidden from chat.
|
||||
- **App icon fix** — adaptive icon foreground scaled to 75% via `<group>` transform for proper safe zone padding.
|
||||
- **Dev scripts** — added `release`, `bundle`, `version` commands to `scripts/dev.bat`.
|
||||
- **MCP tooling** — android-tools-mcp v0.1.1 (IDE/build layer) + mobile-mcp (device/runtime layer) configured as companion MCP servers. Full reference in `docs/mcp-tooling.md`.
|
||||
- **Audit fixes** — MIT LICENSE added, orphaned `companion/` and `companion_relay/` removed, `FOREGROUND_SERVICE` permission removed, CHANGELOG URLs fixed, version refs updated to 0.1.0, .gitignore updated, plugin refs updated from raulvidis to Codename-11.
|
||||
|
||||
**New files:**
|
||||
- `ui/components/MorphingSphere.kt` — ASCII morphing sphere composable (Canvas-based, 3D lighting, color pulse)
|
||||
|
||||
**Files changed:**
|
||||
- `ui/screens/ChatScreen.kt` — Empty state sphere, ambient mode toggle, behind-messages background layer
|
||||
- `ui/screens/SettingsScreen.kt` — Animation settings section (ASCII sphere toggle, behind messages toggle), parse tool annotations experimental badge
|
||||
- `viewmodel/ConnectionViewModel.kt` — Animation preference DataStore keys/flows
|
||||
- `app/build.gradle.kts` — Version and build config updates
|
||||
- `res/mipmap-anydpi-v26/ic_launcher.xml` — 75% scale group transform on foreground
|
||||
- `scripts/dev.bat` — Added release, bundle, version commands
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — Token Tracking Fix, Stats Enhancements, Keyboard Gap, Configurable Limits
|
||||
|
||||
**Done:**
|
||||
- **Token tracking fix** — Root cause: OpenAI-format SSE events (e.g. `chat.completion.chunk`) have no `type`/`event` field, so the `val eventType = type ?: event.resolvedType ?: return` line exited before usage was ever checked. Moved usage extraction **before** the type resolution in both `sendChatStream()` and `sendRunStream()`. Also added `prompt_tokens`/`completion_tokens` (OpenAI naming) support in `UsageInfo` via `resolvedInputTokens`/`resolvedOutputTokens` helper properties.
|
||||
- **Stats for Nerds enhancements** — Reset button with confirmation dialog, tokens per message average in summary line (`~Xk/msg`), peak TTFT and slowest completion times (tertiary color), `formatMsWithSeconds()` helper shows `1234ms (1.2s)` for all time displays >= 1s.
|
||||
- **Configurable limits** — Expandable "Limits" section in Chat settings with segmented button rows for max attachment size (1/5/10/25/50 MB, default 10) and max message length (1K/2K/4K/8K/16K chars, default 4K). Persisted to DataStore, read reactively in ChatScreen.
|
||||
- **Keyboard gap fix** — Set `contentWindowInsets = WindowInsets(0)` on the Scaffold in RelayApp.kt. The Scaffold was adding system bar padding to `innerPadding` that stacked with ChatScreen's `imePadding()`, causing a visible gap between input bar and keyboard.
|
||||
|
||||
**Files changed:**
|
||||
- `network/HermesApiClient.kt` — Usage check moved before eventType resolution in both streaming methods
|
||||
- `network/models/SessionModels.kt` — `UsageInfo` now accepts `prompt_tokens`/`completion_tokens`, added `resolvedInputTokens`/`resolvedOutputTokens`/`resolvedTotalTokens`
|
||||
- `viewmodel/ChatViewModel.kt` — Uses `usage.resolvedInputTokens` etc
|
||||
- `viewmodel/ConnectionViewModel.kt` — `maxAttachmentMb` + `maxMessageLength` DataStore keys/flows/setters
|
||||
- `ui/components/StatsForNerds.kt` — Reset button+dialog, tokens/msg, peak/slowest times, `formatMsWithSeconds()`
|
||||
- `ui/screens/SettingsScreen.kt` — Expandable "Limits" section with segmented buttons
|
||||
- `ui/screens/ChatScreen.kt` — Reads `charLimit`/`maxAttachmentMb` from settings
|
||||
- `ui/RelayApp.kt` — `contentWindowInsets = WindowInsets(0)` on Scaffold
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — File Attachments
|
||||
|
||||
**Done:**
|
||||
- **Generic file attachments** — users can attach any file type via `+` button in the input bar. Uses Android `OpenMultipleDocuments` picker (accepts `*/*`). Files base64-encoded and sent in the Hermes API `attachments` array (`{contentType, content}`).
|
||||
- **Attachment preview strip** — horizontal scrollable row above input bar showing pending attachments. Image attachments show decoded thumbnails, other files show document icon + filename + size. Each attachment has a remove (X) button.
|
||||
- **Attachment rendering in bubbles** — user messages display attached images inline (decoded from base64), non-image attachments show as file badge with name. Forward-compatible with agent-sent images.
|
||||
- **10 MB file size limit** — enforced client-side with toast warning.
|
||||
- **Send with attachments only** — send button enabled when attachments are present even without text. Sends `[attachment]` as placeholder text.
|
||||
- **API integration** — `attachments` parameter added to both `sendChatStream()` and `sendRunStream()` in HermesApiClient. Serialized as JSON array matching Hermes WebAPI spec.
|
||||
- **Message history support** — `MessageItem.imageUrls` extracts `image_url` content blocks from OpenAI-format content arrays for future server-side image rendering.
|
||||
|
||||
**Files changed:**
|
||||
- `data/ChatMessage.kt` — Added `Attachment` data class, `attachments` field on `ChatMessage`
|
||||
- `network/models/SessionModels.kt` — Added `imageUrls` property to `MessageItem`
|
||||
- `network/HermesApiClient.kt` — `attachments` param on `sendChatStream()` + `sendRunStream()`, JSON array serialization
|
||||
- `viewmodel/ChatViewModel.kt` — `_pendingAttachments` StateFlow, add/remove/clear, snapshot-and-clear on send, pass through to API
|
||||
- `ui/screens/ChatScreen.kt` — `+` button, `OpenMultipleDocuments` picker, attachment preview strip, `formatFileSize()` helper
|
||||
- `ui/components/MessageBubble.kt` — Inline image rendering (base64 decode), file badge for non-images
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — Client-Side Message Queuing
|
||||
|
||||
**Done:**
|
||||
- **Message queuing** — Users can now send messages while the agent is streaming. Messages are queued locally and auto-sent when the current stream completes. Queue drains one at a time, maintaining proper ordering.
|
||||
- **Input bar redesign** — During streaming, both Stop and Send buttons are visible side by side. Send button uses `tertiary` color during streaming to indicate "queue" mode. Placeholder changes to "Queue a message..." when streaming.
|
||||
- **Queue indicator** — Animated bar above the input field shows queued message count ("1 message queued" / "3 messages queued") with a Clear button to discard the queue. Uses `AnimatedVisibility` for smooth entrance/exit.
|
||||
- **Queue lifecycle** — Queue is cleared on stream cancellation (Stop button) and on stream error, preventing stale messages from auto-sending after failures.
|
||||
|
||||
**Design decisions:**
|
||||
- Client-side queuing (not server-side `/queue` command) because the Hermes HTTP API doesn't support concurrent SSE streams to the same session. The gateway's `/queue` is a CLI-level feature, not an HTTP endpoint.
|
||||
- Queue drains automatically — no manual "send next" required. Provides a seamless conversation flow.
|
||||
- No purple glow on Send button during streaming — visual distinction between "send now" and "queue for later".
|
||||
|
||||
**Files changed:**
|
||||
- `viewmodel/ChatViewModel.kt` — `_queuedMessages` StateFlow, `sendMessage()` queues during streaming, `sendMessageInternal()` extracted, `drainQueue()` on complete, `clearQueue()`, queue cleared on error/cancel
|
||||
- `ui/screens/ChatScreen.kt` — Queue indicator row, input bar with both Stop+Send buttons, tertiary send tint during streaming, "Queue a message..." placeholder
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — Feature Gating, MCP Tooling, v0.1.0 Release Prep
|
||||
|
||||
**Done:**
|
||||
- **Feature gating system** — `FeatureFlags.kt` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides. Debug builds have all features unlocked; release builds gate experimental features behind Developer Options.
|
||||
- **Developer Options** — Hidden settings section activated by tapping version number 7 times (same UX as Android system Developer Options). Contains relay features toggle and lock button. Uses `tertiary` color scheme for visual distinction.
|
||||
- **Gated relay/pairing settings** — Relay Server and Pairing sections in Settings hidden by default in release builds. Only visible when relay feature flag is enabled via Developer Options.
|
||||
- **Gated onboarding pages** — Terminal, Bridge, and Relay pages dynamically excluded from onboarding flow when relay feature is disabled. Page count and indices adjust automatically.
|
||||
- **Version bump** — `0.1.0-beta` → `0.1.0` for Google Play submission.
|
||||
- **BuildConfig.DEV_MODE** — `true` for debug, `false` for release. Used by FeatureFlags as compile-time default.
|
||||
- **android-tools-mcp v0.1.1** — Fixed MCP server path, built plugin from fork, committed wrapper jar fix, repo cleanup (fork attribution, VM option name fix, cross-platform release script), released to GitHub.
|
||||
- **mobile-mcp added** — Added `mobile-next/mobile-mcp` as companion MCP server for device/runtime testing (tap, swipe, screenshot, app management). Configured with telemetry disabled.
|
||||
- **MCP tooling docs** — Created `docs/mcp-tooling.md` with full reference for both MCP servers (setup, prerequisites, 40 tools listed, when-to-use guide, overlap analysis).
|
||||
|
||||
**New files:**
|
||||
- `data/FeatureFlags.kt` — Feature flag singleton
|
||||
- `docs/mcp-tooling.md` — MCP tooling reference
|
||||
|
||||
**Files changed:**
|
||||
- `app/build.gradle.kts` — Added `DEV_MODE` BuildConfig field, `buildConfig = true`
|
||||
- `gradle/libs.versions.toml` — Version `0.1.0`
|
||||
- `ui/screens/SettingsScreen.kt` — Feature-gated relay/pairing, added Developer Options section with tap-to-unlock
|
||||
- `ui/onboarding/OnboardingScreen.kt` — Dynamic page list based on feature flags
|
||||
- `CLAUDE.md` — Updated current state, added FeatureFlags to key files, MCP tooling section, related projects
|
||||
|
||||
**Next:**
|
||||
- Build release APK and submit to Google Play (closed testing track)
|
||||
- Test feature gating on release build (relay settings hidden, dev options tap unlock)
|
||||
- Phase 2: Terminal channel
|
||||
- Phase 3: Bridge channel
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — Session Management Audit, Play Store Release Prep
|
||||
|
||||
**Done:**
|
||||
- **Session management audit** — Full review of session CRUD, persistence, capability detection, error handling. Implementation is complete and solid against upstream Hermes API (both `/api/sessions` non-standard and `/v1/runs` standard endpoints).
|
||||
- **Fixed SessionDrawer highlight bug** — `backgroundColor` variable was computed but never applied to the Row modifier. Active sessions now properly highlighted with `secondaryContainer`.
|
||||
- **Added Privacy Policy link** — New "Privacy Policy" button in Settings → About, linking to GitHub-hosted `docs/privacy.md`. Required for Google Play Store submission.
|
||||
- **Fixed privacy.md inaccuracies** — Added CAMERA permission to the permissions table (used for QR scanning, declared `required="false"`). Corrected network security description to accurately reflect cleartext policy.
|
||||
- **Fixed RELEASE_NOTES.md URL** — Changed generic `user/hermes-android` to actual `Codename-11/hermes-android`.
|
||||
- **Improved network_security_config.xml docs** — Expanded comment explaining why cleartext is globally permitted (Android doesn't support IP range restrictions, users connect to arbitrary LAN IPs) and how security is enforced at the application layer (insecure mode toggle + warning badge).
|
||||
|
||||
**Session management features confirmed working:**
|
||||
- List/create/switch/rename/delete sessions with optimistic updates + rollback
|
||||
- Message history loading with tool call reconstruction
|
||||
- Auto-session creation on first message send with auto-title
|
||||
- Session ID persistence via DataStore across app restarts
|
||||
- Capability detection (`detectChatMode()`) with graceful degradation
|
||||
- Both Sessions and Runs streaming endpoints
|
||||
|
||||
**Play Store readiness:**
|
||||
- Signing config loads from env vars / local.properties ✅
|
||||
- ProGuard rules comprehensive ✅
|
||||
- Release workflow with version validation ✅
|
||||
- Privacy policy link in app ✅
|
||||
- Network security documented ✅
|
||||
- No hardcoded debug flags in release ✅
|
||||
- Version: `0.1.0-beta` (versionCode 1) — ready for open testing track
|
||||
|
||||
**Files changed:**
|
||||
- `ui/components/SessionDrawer.kt` — Added `background` import + applied `backgroundColor` to Row
|
||||
- `ui/screens/SettingsScreen.kt` — Added Shield icon import + Privacy Policy button in About card
|
||||
- `docs/privacy.md` — Added CAMERA permission, fixed network security description
|
||||
- `RELEASE_NOTES.md` — Fixed issues URL
|
||||
- `res/xml/network_security_config.xml` — Expanded documentation comment
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — Chat UI Polish, Annotation Stripping, Reasoning Extraction
|
||||
|
||||
**Done:**
|
||||
- **Scroll-to-bottom FAB** — SmallFloatingActionButton appears when scrolled up from bottom. Animated fade + slide. Haptic on click. Positioned bottom-end of message area.
|
||||
- **Message entrance animations** — `animateItem()` on all LazyColumn items (messages, spacers, streaming dots). Smooth fade + slide when items appear/reorder.
|
||||
- **Date separators** — "Today", "Yesterday", or "EEE, MMM d" chips between messages from different calendar days. Subtle surfaceVariant pill style.
|
||||
- **Message grouping** — Consecutive same-sender messages have tighter spacing (2dp base + 1dp vs 6dp padding), suppressed agent name on non-first messages, grouped bubble corner shapes (flat edges where messages meet).
|
||||
- **Pre-first-token indicator** — Placeholder assistant message with streaming dots appears immediately after send, before any SSE delta. Fills naturally when first delta arrives.
|
||||
- **Copy feedback toast** — Snackbar "Copied to clipboard" on long-press copy. Previously only haptic with no visual confirmation.
|
||||
- **Annotation stripping** — When the tool annotation parser matches inline text (`` `💻 terminal` ``), it now strips that text from the message content. Previously the raw annotation text remained visible alongside the ToolCall card.
|
||||
- **Inline reasoning extraction** — `<think>`/`<thinking>` tags in assistant text are detected and redirected to `thinkingContent` for the ThinkingBlock. Handles tags split across streaming deltas. Resets on stream complete.
|
||||
|
||||
**Files changed:**
|
||||
- `ui/screens/ChatScreen.kt` — FAB, date separators, grouping, snackbar, animation modifiers, Box wrapper for message area
|
||||
- `ui/components/MessageBubble.kt` — `isFirstInGroup`/`isLastInGroup` params, grouped bubble shapes, conditional agent name
|
||||
- `network/handlers/ChatHandler.kt` — `addPlaceholderMessage()`, `stripLineFromContent()`, `processInlineReasoning()`, thinking tag parser, `parseAnnotationLine` returns Boolean
|
||||
- `viewmodel/ChatViewModel.kt` — placeholder message before stream start
|
||||
|
||||
**Note:** Code block copy button already existed (`MarkdownContent.kt` → `CodeBlockWithCopyButton`).
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-07 — Tool Call Rendering Fix, Runs API, SSE Architecture Correction
|
||||
|
||||
**Done:**
|
||||
- **Fixed premature stream completion** — `assistant.completed` was calling `onComplete()`, terminating the stream before tool events arrived in multi-turn agent loops. Now only `run.completed`/`done` end the stream. `assistant.completed` calls new `onTurnComplete()` which marks one message done without stopping the stream.
|
||||
- **Added `message.started` handling** — Server-assigned message IDs now tracked via `onMessageStarted` callback. Enables proper multi-turn message tracking (each assistant turn gets its own message).
|
||||
- **Dynamic message ID tracking** — `ChatViewModel.startStream()` uses `currentMessageId` variable that updates when the server sends new message IDs, instead of hardcoding one UUID for the whole stream.
|
||||
- **Rewrote tool annotation parser** — Regex patterns now match actual Hermes format: `` `💻 terminal` `` (any emoji + tool name in backticks). Uses state tracking: first occurrence = start, second = complete. Also handles explicit completion/failure emojis (✅/❌) and verbose format (`🔧 Running: tool_name`).
|
||||
- **Fixed message history tool calls** — `loadMessageHistory()` now reconstructs `ToolCall` objects from assistant messages' `tool_calls` field and matches tool results from `role:"tool"` messages. Previously skipped all tool data.
|
||||
- **Runs API event coverage** — Added `message.delta`, `reasoning.available`, `run.failed` event handling. Updated `HermesSseEvent` model with `event` field (alias for `type`), `tool` field (Runs API format), `duration`, `output`, `text`, `timestamp`. Added `resolvedType` and `resolvedToolName` helpers.
|
||||
- **SSE debug logging** — All events logged with `HermesApiClient` tag. Filter with `adb logcat -s HermesApiClient` to see what the server actually sends.
|
||||
- **Updated decisions.md** — Documented the two streaming endpoints (Sessions vs Runs), tool call transparency differences, upstream API notes.
|
||||
- **Updated settings description** — Streaming endpoint toggle now explains the difference.
|
||||
|
||||
**Architecture correction (from upstream research):**
|
||||
- `/api/sessions` CRUD endpoints are NOT in upstream hermes-agent source. They may be version-specific (v0.7.0). Standard endpoints are `/v1/chat/completions`, `/v1/responses`, `/v1/runs`.
|
||||
- `/v1/chat/completions` streaming embeds tool calls as **inline markdown text** (`` `💻 terminal` ``), NOT as separate SSE events. The annotation parser is the primary detection path.
|
||||
- `/v1/runs` + `/v1/runs/{run_id}/events` provides **structured lifecycle events** with real `tool.started`/`tool.completed` — this is the correct endpoint for rich tool display.
|
||||
- Hermes has no "channels" API (Discord/Telegram-style). The `channel_directory.py` is for cross-platform message routing, not a chat API.
|
||||
|
||||
**Files changed:**
|
||||
- `network/HermesApiClient.kt` — new callbacks, fixed completion flow, debug logging
|
||||
- `network/handlers/ChatHandler.kt` — `onTurnComplete()`, annotation rewrite, history tool calls
|
||||
- `network/models/SessionModels.kt` — new fields for Runs API compatibility
|
||||
- `viewmodel/ChatViewModel.kt` — dynamic message ID tracking, new callback wiring
|
||||
- `ui/screens/SettingsScreen.kt` — updated endpoint toggle description
|
||||
- `docs/decisions.md` — corrected API architecture documentation
|
||||
|
||||
**Next:**
|
||||
- Deploy to device and test tool call rendering with `adb logcat -s HermesApiClient`
|
||||
- Test with both "Sessions" and "Runs" endpoint modes
|
||||
- Verify annotation parser matches actual Hermes verbose output
|
||||
- If Runs API works well, consider making it the default endpoint
|
||||
|
||||
**Blockers:**
|
||||
- Need a running hermes-agent server with tools configured to validate tool event flow end-to-end
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-06 — Personality System, Command Palette, QR Pairing, Chat Header
|
||||
|
||||
**Done:**
|
||||
- **Personality system fix** — `getProfiles()` was reading wrong JSON path and returning empty list. Replaced with `getPersonalities()` reading `config.agent.personalities` + `config.display.personality`. Server default personality shown first in picker. Switching sends personality's system prompt via `system_message` (previous `profile` field was ignored by server).
|
||||
- **Agent name on chat bubbles** — Added `agentName` field to `ChatMessage`. Active personality name displayed above assistant messages.
|
||||
- **Chat header redesign** — Messaging-app style: avatar circle with initial letter + `ConnectionStatusBadge` pulse overlay, agent name (`titleMedium`), model name subtitle from `/api/config`.
|
||||
- **Command palette** — Searchable bottom sheet with category filter chips (2-row limit, expandable), 29 gateway built-in commands + dynamic personality commands + 90+ server skills from `GET /api/skills`. `/` button on input bar opens palette.
|
||||
- **Inline autocomplete improved** — Extracted to `InlineAutocomplete` component with `LazyColumn`, 2-line descriptions, up to 8 results.
|
||||
- **QR code pairing** — ML Kit barcode scanner + CameraX. Detects `{"hermes":1,...}` payload, auto-fills server URL + API key, triggers connection test. Available in Settings and Onboarding.
|
||||
- **`hermes-pair` skill** — Added to `skills/hermes-pairing-qr/` for users to install on their server. Generator script + SKILL.md.
|
||||
- **ConnectionStatusBadge** — Reusable animated status indicator with pulse ring (green connected, amber connecting, red disconnected). Wired into Settings, Onboarding, and chat header.
|
||||
- **Relay server docs** — `docs/relay-server.md`, `relay_server/Dockerfile`, `relay_server/hermes-relay.service`, `relay_server/SKILL.md`.
|
||||
- **Upstream contributions doc** — `docs/upstream-contributions.md` — proposed `GET /api/commands`, `personality` parameter, terminal HTTP API.
|
||||
|
||||
**Corrections to previous session:**
|
||||
- "Server profile picker" was actually fetching from wrong path — now correctly reads `config.agent.personalities`
|
||||
- "Sends `profile` field" — server ignores this; now sends `system_message` with personality prompt
|
||||
- "13 personality commands" were hardcoded — now generated dynamically from server config
|
||||
- ProfilePicker renamed to PersonalityPicker
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-06 — v0.1.0-beta Polish, Profiles, Analytics, Splash
|
||||
|
||||
**Done:**
|
||||
- **Package rename** — `com.hermesandroid.companion` → `com.hermesandroid.relay`. All files moved, manifest updated, app name changed to "Hermes Relay".
|
||||
- **Server profile picker** — Replaced hardcoded 8-personality system with dynamic server profiles fetched from `GET /api/config`. ProfilePicker in top bar shows Default + server-configured profiles. Sends `profile` field in chat requests.
|
||||
- **Personality switching** — 13 built-in Hermes personalities available via `/personality <name>` slash commands (server-side, session-level switching).
|
||||
- **Slash command autocomplete** — Type `/` in chat input to see built-in commands (`/help`, `/verbose`, `/clear`, `/status`) + 13 personality commands + dynamically fetched server skills via `GET /api/skills`. Filterable dropdown overlay.
|
||||
- **In-app analytics (Stats for Nerds)** — `AppAnalytics` singleton tracking response times (TTFT, completion), token usage, health check latency, stream success rates. Canvas bar charts in Settings with purple gradient. Accessible via Settings > Chat > Stats for Nerds.
|
||||
- **Tool call display config** — Off/Compact/Detailed modes in Settings. `CompactToolCall` inline component for compact mode. `ToolProgressCard` auto-expands while tool is running, auto-collapses on complete.
|
||||
- **App context prompt** — Toggleable system message telling the agent the user is on mobile. Enabled by default in Settings > Chat.
|
||||
- **Animated splash screen** — `AnimatedVectorDrawable` with scale + overshoot + fade animation. Icon background color matches theme. Hold-while-loading (stays until DataStore ready). Smooth fade-out exit transition. Separate `splash_icon.xml` at 0.9x scale.
|
||||
- **Chat empty state** — Logo + "Start a conversation" + suggestion chips that populate input.
|
||||
- **Animated streaming dots** — Replaces static "streaming..." text with pulsing 3-dot animation.
|
||||
- **Haptic feedback** — On send, copy, stream complete, error.
|
||||
- **About section redesign** — Logo on dark background, dynamic version from `BuildConfig`, Source + Docs link buttons, credits line.
|
||||
- **Hermes docs links** — In onboarding welcome page, API key help dialog, and Settings About section.
|
||||
- **Release signing config** — Environment variables + `local.properties` fallback with graceful debug-signing fallback.
|
||||
- **Centralized versioning** — `libs.versions.toml` as single source of truth (`appVersionName`, `appVersionCode`).
|
||||
- **Logo fix** — Removed vertical H bars from ghost layer, now matches actual SVG (V-crossbar + diagonal feathers only).
|
||||
- **SSE debug logging** — Unhandled event types now logged for diagnostics.
|
||||
- **Release infrastructure (from ARC patterns)** — 3-job release workflow (validate → CI → release) reading from `libs.versions.toml`. Claude automation workflows (issue triage, fix, code review). Dependabot auto-merge. CHANGELOG.md + RELEASE_NOTES.md for v0.1.0-beta. Updated PR template with Android checklist.
|
||||
|
||||
**New files:**
|
||||
- `data/AppAnalytics.kt` — In-app analytics singleton
|
||||
- `ui/components/StatsForNerds.kt` — Canvas bar charts for analytics
|
||||
- `ui/components/CompactToolCall.kt` — Inline compact tool call display
|
||||
- `network/models/SessionModels.kt` — Session, message, SSE event models
|
||||
- `res/drawable/splash_icon.xml` — Static splash icon (0.9x scale)
|
||||
- `res/drawable/splash_icon_animated.xml` — Animated splash vector
|
||||
- `res/animator/` — Splash animation resources
|
||||
- `.github/workflows/claude.yml` — Claude automation
|
||||
- `.github/workflows/claude-code-review.yml` — Claude code review
|
||||
- `.github/workflows/dependabot-auto-merge.yml` — Dependabot auto-merge
|
||||
|
||||
**Next:**
|
||||
- Build and test against running Hermes API server
|
||||
- Test on emulator and physical device (S25 Ultra)
|
||||
- Set up keystore/signing secrets for release CI
|
||||
- Deploy docs site (GitHub Pages or similar)
|
||||
- Phase 2: Terminal channel (xterm.js in WebView, tmux integration)
|
||||
- Phase 3: Bridge channel migration
|
||||
|
||||
**Blockers:**
|
||||
- None — ready for on-device testing
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-05 — Project Scaffolding
|
||||
|
||||
**Done:**
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Axiom Labs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -12,7 +12,7 @@
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-android/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-android/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
|
||||
@@ -37,34 +37,46 @@ A native Android app for [Hermes Agent](https://github.com/NousResearch/hermes-a
|
||||
|
||||
Chat connects directly to the Hermes API Server (`/api/sessions/{id}/chat/stream`). Terminal and bridge use a WebSocket relay with channel multiplexing.
|
||||
|
||||
## Architecture
|
||||
## Server Components
|
||||
|
||||
The app talks to two server-side services. Only the first is required.
|
||||
|
||||
| Component | Required? | What |
|
||||
|-----------|-----------|------|
|
||||
| **Hermes API Server** (`:8642`) | Yes | Chat, sessions, profiles, skills. Part of `hermes gateway`. |
|
||||
| **Relay Server** (`:8767`) | Only for terminal/bridge | WSS server for interactive terminal and device bridge. |
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [terminal]
|
||||
Phone (WSS) → Bridge Relay (:8766) [bridge]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) --> Relay Server (:8767) [terminal, bridge]
|
||||
```
|
||||
|
||||
Chat bypasses the relay entirely — same direct connection used by Open WebUI, ClawPort, and other Hermes frontends. Auth is via optional Bearer token (`API_SERVER_KEY`). The relay handles channels that need persistent bidirectional communication.
|
||||
Chat connects directly to the Hermes API Server — same pattern used by Open WebUI, ClawPort, and other Hermes frontends. The relay server is a separate lightweight Python service for features that need persistent bidirectional communication. See [docs/relay-server.md](docs/relay-server.md) for details.
|
||||
|
||||
## Features
|
||||
|
||||
| Layer | Capabilities |
|
||||
|-------|-------------|
|
||||
| **Chat** | Direct API streaming (SSE), session management, auto-titles, personality picker (8 styles) |
|
||||
| **Rendering** | Full markdown, syntax-highlighted code blocks (Atom theme), reasoning display |
|
||||
| **Tools** | Rich progress cards with type-specific icons, arguments, duration, error display |
|
||||
| **Chat** | Direct API streaming (SSE), session management, auto-titles, message queuing, file attachments, personality picker, agent name on bubbles, slash command autocomplete, QR code pairing |
|
||||
| **Slash Commands** | 29 gateway commands + dynamic personality commands + server skill discovery (`GET /api/skills`). Searchable command palette with category filtering. |
|
||||
| **Personalities** | Dynamic from `GET /api/config` (`config.agent.personalities`). Picker shows server default + all configured. Agent name displayed on chat bubbles. `/personality <name>` slash commands. |
|
||||
| **Rendering** | Full markdown, syntax-highlighted code blocks (Atom theme), reasoning display, animated streaming dots |
|
||||
| **Tools** | Configurable display (Off/Compact/Detailed). Rich progress cards with type-specific icons, auto-expand/collapse, duration |
|
||||
| **Analytics** | Stats for Nerds — TTFT, completion times, token usage, peak/slowest times, health latency, stream success rates. Canvas bar charts. Reset button. |
|
||||
| **Tokens** | Per-message input/output counts and estimated cost |
|
||||
| **Animation** | ASCII morphing sphere on empty chat, ambient fullscreen mode (toggle in header), 15% opacity behind messages (toggleable). Settings: sphere on/off, behind messages on/off. |
|
||||
| **UX** | Animated splash screen, chat empty state with suggestion chips, haptic feedback, app context prompt, configurable limits (attachment size, message length) |
|
||||
| **Security** | EncryptedSharedPreferences (AES-256-GCM), HTTPS enforced, cleartext only for localhost |
|
||||
| **Connectivity** | Network monitoring, auto-reconnect, capability detection (enhanced/portable/disconnected) |
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
hermes-android/
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp)
|
||||
├── plugin/ # Hermes agent plugin (14 android_* tools)
|
||||
├── skills/ # Hermes agent skills (QR pairing)
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
@@ -84,21 +96,69 @@ hermes-android/
|
||||
|
||||
```bash
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
scripts/dev.bat devices # List connected devices
|
||||
scripts/dev.bat wireless # Pair for wireless debugging
|
||||
```
|
||||
|
||||
### Start Relay Server
|
||||
### Android Studio Workflow
|
||||
|
||||
| Action | Shortcut | What it does | Wipes data? |
|
||||
|--------|----------|-------------|-------------|
|
||||
| **Gradle Sync** | Toolbar elephant icon | Reads `build.gradle.kts` + `libs.versions.toml`, resolves dependencies. No compilation. | No |
|
||||
| **Assemble** | Build > Make Project | Compile → DEX → package APK. Does not deploy. | No |
|
||||
| **Run** | Shift+F10 | Assemble + install + launch on device/emulator | No (upgrade install) |
|
||||
| **Apply Changes** | Ctrl+F10 | Hot-patch changed code, restart Activity. ViewModels survive. | No |
|
||||
| **Apply Code Changes** | Ctrl+Shift+F10 | Patch method bodies only, no restart | No |
|
||||
|
||||
To **wipe app data**: emulator app icon long-press > App Info > Storage > Clear Data, or `adb shell pm clear com.hermesandroid.relay`.
|
||||
|
||||
**Versioning** lives in `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`) — the single source of truth read by `build.gradle.kts`.
|
||||
|
||||
### Relay Server (optional — for terminal/bridge)
|
||||
|
||||
```bash
|
||||
pip install -r relay_server/requirements.txt
|
||||
python -m relay_server --no-ssl --log-level DEBUG
|
||||
pip install aiohttp pyyaml && python -m relay_server --no-ssl
|
||||
```
|
||||
|
||||
### Install as Hermes Plugin
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
|
||||
```
|
||||
|
||||
Or as a systemd service:
|
||||
|
||||
```bash
|
||||
sudo cp relay_server/hermes-relay.service /etc/systemd/system/
|
||||
sudo systemctl enable --now hermes-relay
|
||||
```
|
||||
|
||||
See [docs/relay-server.md](docs/relay-server.md) for TLS, configuration, and full setup.
|
||||
|
||||
### QR Code Pairing (optional)
|
||||
|
||||
Generate a QR code on your server that the app can scan to auto-configure:
|
||||
|
||||
```bash
|
||||
# Install the skill + script
|
||||
cp -r skills/hermes-pairing-qr ~/.hermes/skills/hermes-pairing-qr
|
||||
cp skills/hermes-pairing-qr/hermes-pair ~/.local/bin/hermes-pair
|
||||
chmod +x ~/.local/bin/hermes-pair
|
||||
sudo apt install qrencode
|
||||
|
||||
# Generate QR
|
||||
hermes-pair
|
||||
```
|
||||
|
||||
Scan it in the app (Settings > Scan QR, or during onboarding). See [skills/hermes-pairing-qr/SKILL.md](skills/hermes-pairing-qr/SKILL.md) for details.
|
||||
|
||||
### Hermes Plugin (optional — for bridge/device control)
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-android
|
||||
@@ -116,12 +176,12 @@ cp -r plugin ~/.hermes/plugins/hermes-android
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
## Current State
|
||||
## Current State — v0.1.0
|
||||
|
||||
| Phase | Status | Scope |
|
||||
|-------|--------|-------|
|
||||
| **Phase 0** | Complete | Compose scaffold, WSS connection, channel multiplexer, auth |
|
||||
| **Phase 1** | Complete | Direct API chat, sessions, markdown, tools, personalities, tokens |
|
||||
| **Phase 0** | Complete | Compose scaffold, WSS connection, channel multiplexer, auth, splash screen |
|
||||
| **Phase 1** | Complete | Direct API chat, sessions, markdown, tools, personalities, slash commands, command palette, analytics, QR pairing, tool display config |
|
||||
| **Phase 2** | Next | Terminal channel (xterm.js + tmux) |
|
||||
| **Phase 3** | Next | Bridge channel (AccessibilityService) |
|
||||
|
||||
@@ -133,17 +193,16 @@ See [docs/spec.md](docs/spec.md) for the full specification and [docs/decisions.
|
||||
|---|---|
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Relay Server](docs/relay-server.md) | Setup, config, Docker, systemd — everything for the relay |
|
||||
| [Upstream Contributions](docs/upstream-contributions.md) | Potential improvements to propose to hermes-agent |
|
||||
| [Security](docs/security.md) | Auth flow, encryption, network security |
|
||||
| [Privacy](docs/privacy.md) | Data handling, local storage, no telemetry |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Dev Log](DEVLOG.md) | Session-by-session development notes |
|
||||
|
||||
## Related Projects
|
||||
## Hermes Agent
|
||||
|
||||
| Project | What |
|
||||
|---------|------|
|
||||
| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | The agent platform (gateway, WebAPI, plugins) |
|
||||
| [ARC](https://github.com/Codename-11/ARC) | Agent Runtime Control — unified CLI for agent tools |
|
||||
| [ClawPort](https://github.com/Codename-11/clawport-ui) | Web dashboard for Hermes Agent |
|
||||
Hermes Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
# v0.2.0 — Direct API Chat
|
||||
# Hermes Relay v0.1.0
|
||||
|
||||
Chat now connects directly to your Hermes API Server — no relay server needed for conversations.
|
||||
First release — a native Android client for the Hermes agent platform with direct API chat, session management, and a full Material 3 Compose UI.
|
||||
|
||||
## What's New
|
||||
## Highlights
|
||||
|
||||
- **Direct API chat** — uses the Hermes Sessions API (`/api/sessions/{id}/chat/stream`) with SSE streaming
|
||||
- **API key auth (optional)** — `Authorization: Bearer <key>` stored securely on device, only needed if Hermes is configured with `API_SERVER_KEY`
|
||||
- **Session management** — create, list, switch, rename, and delete chat sessions via the Sessions API
|
||||
- **Test Connection** — verify your API server is reachable before chatting
|
||||
- **Cancel streaming** — stop button to cancel in-flight responses
|
||||
|
||||
## What Changed
|
||||
|
||||
- Onboarding now asks for API Server URL + API Key (relay URL is optional)
|
||||
- Settings split into "API Server" and "Relay Server" sections
|
||||
- The relay server is only needed for Bridge and Terminal features
|
||||
- **Direct API chat** — connects to your Hermes API Server via SSE streaming
|
||||
- **Session management** — create, switch, rename, delete sessions with full message history
|
||||
- **Markdown rendering** — code blocks, bold, italic, links, lists in assistant messages
|
||||
- **Reasoning display** — collapsible thinking blocks when the agent uses extended thinking
|
||||
- **Token tracking** — per-message input/output token count and estimated cost
|
||||
- **Personality picker** — dynamic personalities from server config, agent name on chat bubbles
|
||||
- **Command palette** — searchable command browser with 29 gateway commands + server skills
|
||||
- **QR code pairing** — scan `hermes-pair` QR to auto-configure connection
|
||||
- **Material You theming** — dynamic colors with light/dark/auto support
|
||||
- **File attachments** — attach images, documents, and other files to messages
|
||||
- **Message queuing** — send follow-up messages while the agent is still responding
|
||||
- **Offline detection** — graceful degradation when network connectivity is lost
|
||||
- **Feature gating** — Developer Options (tap version 7x) for experimental features
|
||||
- **Configurable limits** — adjustable attachment size and message length in Settings
|
||||
- **In-app analytics** — Stats for Nerds with response times, token usage, peak times, reset
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Build and install debug APK
|
||||
scripts/dev.bat run
|
||||
```
|
||||
Download from Google Play or install the APK from the release assets below.
|
||||
|
||||
Or download the APK from the release assets.
|
||||
## Requirements
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- A running [Hermes agent](https://github.com/NousResearch/hermes-agent) instance
|
||||
|
||||
## What's Next
|
||||
|
||||
- Terminal channel via tmux (Phase 2)
|
||||
- Bridge channel migration (Phase 3)
|
||||
- Push notifications
|
||||
- Agent-initiated image rendering (MEDIA: tags)
|
||||
|
||||
## Feedback
|
||||
|
||||
- Issues: https://github.com/Codename-11/hermes-relay/issues
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
@@ -6,26 +8,60 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.hermesandroid.companion"
|
||||
namespace = "com.hermesandroid.relay"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.hermesandroid.companion"
|
||||
applicationId = "com.hermesandroid.relay"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Feature flags — DEV_MODE enables all experimental features in debug builds
|
||||
buildConfigField("boolean", "DEV_MODE", "false")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val localProps = rootProject.file("local.properties")
|
||||
val props: Properties? = if (localProps.exists()) {
|
||||
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
|
||||
} else null
|
||||
|
||||
storeFile = file(
|
||||
System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
)
|
||||
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
|
||||
?: props?.getProperty("hermes.keystore.password") ?: ""
|
||||
keyAlias = System.getenv("HERMES_KEY_ALIAS")
|
||||
?: props?.getProperty("hermes.key.alias") ?: ""
|
||||
keyPassword = System.getenv("HERMES_KEY_PASSWORD")
|
||||
?: props?.getProperty("hermes.key.password") ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("boolean", "DEV_MODE", "true")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// Use release signing if keystore exists, otherwise fall back to debug signing
|
||||
val releaseSigningConfig = signingConfigs.getByName("release")
|
||||
signingConfig = if (releaseSigningConfig.storeFile?.exists() == true) {
|
||||
releaseSigningConfig
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +76,19 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
kotlin.srcDirs("src/main/kotlin")
|
||||
}
|
||||
getByName("test") {
|
||||
kotlin.srcDirs("src/test/kotlin")
|
||||
}
|
||||
getByName("androidTest") {
|
||||
kotlin.srcDirs("src/androidTest/kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +127,25 @@ dependencies {
|
||||
|
||||
// Networking
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
|
||||
// QR Code scanning (ML Kit + CameraX)
|
||||
implementation(libs.mlkit.barcode)
|
||||
implementation(libs.camera.core)
|
||||
implementation(libs.camera.camera2)
|
||||
implementation(libs.camera.lifecycle)
|
||||
implementation(libs.camera.view)
|
||||
|
||||
// Haze (glassmorphism blur)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
|
||||
// Window size class for responsive layout
|
||||
implementation("androidx.compose.material3:material3-window-size-class")
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
@@ -99,6 +161,9 @@ dependencies {
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
}
|
||||
|
||||
# Keep all @Serializable data classes in our package
|
||||
-keep,includedescriptorclasses class com.hermesandroid.companion.**$$serializer { *; }
|
||||
-keepclassmembers class com.hermesandroid.companion.** {
|
||||
-keep,includedescriptorclasses class com.hermesandroid.relay.**$$serializer { *; }
|
||||
-keepclassmembers class com.hermesandroid.relay.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.hermesandroid.companion.** {
|
||||
-keepclasseswithmembers class com.hermesandroid.relay.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep interface okhttp3.** { *; }
|
||||
|
||||
# ── OkHttp-SSE ──────────────────────────────────────────────────────
|
||||
-keep class okhttp3.sse.** { *; }
|
||||
-keep interface okhttp3.sse.** { *; }
|
||||
-keep class okhttp3.internal.sse.** { *; }
|
||||
|
||||
# ── Mikepenz Markdown Renderer ────────────────────────────────────
|
||||
-keep class com.mikepenz.markdown.** { *; }
|
||||
-keep interface com.mikepenz.markdown.** { *; }
|
||||
-keep class org.intellij.markdown.** { *; }
|
||||
-keep interface org.intellij.markdown.** { *; }
|
||||
|
||||
# ── Compose ──────────────────────────────────────────────────────────
|
||||
# Compose is mostly handled by R8 automatically, but keep stability annotations
|
||||
-dontwarn androidx.compose.**
|
||||
@@ -35,6 +46,15 @@
|
||||
-keep class com.google.crypto.tink.** { *; }
|
||||
-dontwarn com.google.crypto.tink.**
|
||||
|
||||
# ── ML Kit Barcode Scanning ──────────────────────────────────────────
|
||||
-keep class com.google.mlkit.** { *; }
|
||||
-keep class com.google.android.gms.internal.mlkit_vision_barcode.** { *; }
|
||||
-dontwarn com.google.mlkit.**
|
||||
|
||||
# ── CameraX ─────────────────────────────────────────────────────────
|
||||
-keep class androidx.camera.** { *; }
|
||||
-dontwarn androidx.camera.**
|
||||
|
||||
# ── General ──────────────────────────────────────────────────────────
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Relay")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1: Hermes Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 5: Connect to Hermes
|
||||
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 6: Relay Server (last page)
|
||||
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Back button ---
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// On page 1, Back should not exist
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Go to page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiKeyField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog is showing
|
||||
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
|
||||
|
||||
// Dismiss it
|
||||
composeTestRule.onNodeWithText("Got it").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should be gone
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- Page 6: Relay page ---
|
||||
|
||||
@Test
|
||||
fun relayPage_showsOptionalMessaging() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5) // Last page
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("This is optional", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPhase2Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 2")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Secure shell access", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Full ANSI terminal emulator", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("tmux session management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Bridge Screen ---
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Device Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPhase3Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Agent-controlled device interaction", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Permission management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,25 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".CompanionApp"
|
||||
android:name=".HermesRelayApp"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HermesCompanion">
|
||||
android:theme="@style/Theme.HermesRelay">
|
||||
|
||||
<activity
|
||||
android:name=".CompanionActivity"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.HermesCompanion.Splash">
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
v0.1.0 — First Release
|
||||
|
||||
Chat
|
||||
• Direct API chat via SSE streaming
|
||||
• Markdown rendering — code blocks, bold, italic, links
|
||||
• Session management — create, switch, rename, delete
|
||||
• Message history with auto-titles
|
||||
• Reasoning display (collapsible thinking blocks)
|
||||
• Personality picker with dynamic server personalities
|
||||
• Command palette — 29+ commands + server skills
|
||||
• Token & cost tracking per message
|
||||
• File attachments — images, documents, any file type
|
||||
• Message queuing — send while agent is streaming
|
||||
|
||||
Animation
|
||||
• ASCII morphing sphere on empty chat screen
|
||||
• Ambient mode — fullscreen sphere (toggle in header)
|
||||
• Subtle sphere behind messages at 15% opacity
|
||||
• Animation controls in Settings > Appearance
|
||||
|
||||
App
|
||||
• Material You theming (light/dark/auto)
|
||||
• QR code pairing for quick setup
|
||||
• Stats for Nerds — response times, health metrics
|
||||
• Offline detection with reconnect
|
||||
• Developer Options — tap version 7x to unlock experimental features
|
||||
• Configurable limits — attachment size, message length
|
||||
|
||||
Security
|
||||
• API keys in EncryptedSharedPreferences
|
||||
• Network security config for localhost
|
||||
• Feature gating for unfinished features
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.hermesandroid.companion
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.hermesandroid.companion.ui.CompanionApp
|
||||
|
||||
class CompanionActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
CompanionApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.hermesandroid.companion
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class CompanionApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: CompanionApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.hermesandroid.companion.data
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = false,
|
||||
val toolCalls: List<ToolCall> = emptyList()
|
||||
)
|
||||
|
||||
data class ToolCall(
|
||||
val name: String,
|
||||
val args: String?,
|
||||
val result: String?,
|
||||
val success: Boolean?,
|
||||
val isComplete: Boolean = false
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
USER,
|
||||
ASSISTANT,
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
data class ChatSession(
|
||||
val sessionId: String,
|
||||
val title: String?,
|
||||
val model: String?
|
||||
)
|
||||
@@ -1,224 +0,0 @@
|
||||
package com.hermesandroid.companion.network.handlers
|
||||
|
||||
import com.hermesandroid.companion.data.ChatMessage
|
||||
import com.hermesandroid.companion.data.ChatSession
|
||||
import com.hermesandroid.companion.data.MessageRole
|
||||
import com.hermesandroid.companion.data.ToolCall
|
||||
import com.hermesandroid.companion.network.ChannelMultiplexer
|
||||
import com.hermesandroid.companion.network.models.ChatCompletedPayload
|
||||
import com.hermesandroid.companion.network.models.ChatDeltaPayload
|
||||
import com.hermesandroid.companion.network.models.ChatErrorPayload
|
||||
import com.hermesandroid.companion.network.models.ChatSessionPayload
|
||||
import com.hermesandroid.companion.network.models.ChatToolCompletedPayload
|
||||
import com.hermesandroid.companion.network.models.ChatToolStartedPayload
|
||||
import com.hermesandroid.companion.network.models.Envelope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
|
||||
/**
|
||||
* Processes incoming chat channel messages and updates state.
|
||||
*
|
||||
* Handles:
|
||||
* - chat.session — new session created
|
||||
* - chat.delta — streaming text delta
|
||||
* - chat.tool.started — tool execution started
|
||||
* - chat.tool.completed — tool execution completed
|
||||
* - chat.completed — assistant message complete
|
||||
* - chat.error — error in chat
|
||||
*/
|
||||
class ChatHandler : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
override fun onMessage(envelope: Envelope) {
|
||||
when (envelope.type) {
|
||||
"chat.session" -> handleSession(envelope)
|
||||
"chat.delta" -> handleDelta(envelope)
|
||||
"chat.tool.started" -> handleToolStarted(envelope)
|
||||
"chat.tool.completed" -> handleToolCompleted(envelope)
|
||||
"chat.completed" -> handleCompleted(envelope)
|
||||
"chat.error" -> handleError(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
fun addUserMessage(message: ChatMessage) {
|
||||
_messages.update { it + message }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
private fun handleSession(envelope: Envelope) {
|
||||
try {
|
||||
val payload = json.decodeFromJsonElement<ChatSessionPayload>(envelope.payload)
|
||||
val session = ChatSession(
|
||||
sessionId = payload.sessionId,
|
||||
title = payload.title,
|
||||
model = payload.model
|
||||
)
|
||||
_sessions.update { sessions ->
|
||||
if (sessions.any { it.sessionId == session.sessionId }) {
|
||||
sessions.map { if (it.sessionId == session.sessionId) session else it }
|
||||
} else {
|
||||
sessions + session
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDelta(envelope: Envelope) {
|
||||
try {
|
||||
val payload = json.decodeFromJsonElement<ChatDeltaPayload>(envelope.payload)
|
||||
_isStreaming.value = true
|
||||
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast {
|
||||
it.id == payload.messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
// Append delta to existing streaming message
|
||||
messages.map { msg ->
|
||||
if (msg.id == payload.messageId) {
|
||||
msg.copy(content = msg.content + payload.delta)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new assistant message
|
||||
messages + ChatMessage(
|
||||
id = payload.messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = payload.delta,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleToolStarted(envelope: Envelope) {
|
||||
try {
|
||||
val payload = json.decodeFromJsonElement<ChatToolStartedPayload>(envelope.payload)
|
||||
val toolCall = ToolCall(
|
||||
name = payload.toolName,
|
||||
args = payload.args?.toString(),
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
)
|
||||
|
||||
_messages.update { messages ->
|
||||
val lastAssistant = messages.findLast { it.role == MessageRole.ASSISTANT }
|
||||
if (lastAssistant != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == lastAssistant.id) {
|
||||
msg.copy(toolCalls = msg.toolCalls + toolCall)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create a new assistant message to hold the tool call
|
||||
messages + ChatMessage(
|
||||
id = java.util.UUID.randomUUID().toString(),
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(toolCall)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleToolCompleted(envelope: Envelope) {
|
||||
try {
|
||||
val payload = json.decodeFromJsonElement<ChatToolCompletedPayload>(envelope.payload)
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.role == MessageRole.ASSISTANT && msg.toolCalls.any { it.name == payload.toolName && !it.isComplete }) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.name == payload.toolName && !call.isComplete) {
|
||||
call.copy(
|
||||
result = payload.resultPreview,
|
||||
success = payload.success,
|
||||
isComplete = true
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
msg.copy(toolCalls = updatedCalls)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCompleted(envelope: Envelope) {
|
||||
try {
|
||||
val payload = json.decodeFromJsonElement<ChatCompletedPayload>(envelope.payload)
|
||||
_isStreaming.value = false
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == payload.messageId) {
|
||||
msg.copy(
|
||||
content = payload.content,
|
||||
isStreaming = false
|
||||
)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleError(envelope: Envelope) {
|
||||
try {
|
||||
val payload = json.decodeFromJsonElement<ChatErrorPayload>(envelope.payload)
|
||||
_isStreaming.value = false
|
||||
_error.value = payload.message
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package com.hermesandroid.companion.network.models
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class ChatSendPayload(
|
||||
val profile: String,
|
||||
@SerialName("session_id")
|
||||
val sessionId: String? = null,
|
||||
val message: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatDeltaPayload(
|
||||
@SerialName("session_id")
|
||||
val sessionId: String,
|
||||
@SerialName("message_id")
|
||||
val messageId: String,
|
||||
val delta: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatCompletedPayload(
|
||||
@SerialName("session_id")
|
||||
val sessionId: String,
|
||||
@SerialName("message_id")
|
||||
val messageId: String,
|
||||
val content: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatToolStartedPayload(
|
||||
@SerialName("tool_name")
|
||||
val toolName: String,
|
||||
val preview: String? = null,
|
||||
val args: JsonObject? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatToolCompletedPayload(
|
||||
@SerialName("tool_name")
|
||||
val toolName: String,
|
||||
@SerialName("result_preview")
|
||||
val resultPreview: String? = null,
|
||||
val success: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatSessionPayload(
|
||||
@SerialName("session_id")
|
||||
val sessionId: String,
|
||||
val title: String? = null,
|
||||
val model: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatErrorPayload(
|
||||
val message: String
|
||||
)
|
||||
@@ -1,165 +0,0 @@
|
||||
package com.hermesandroid.companion.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.hermesandroid.companion.ui.onboarding.OnboardingScreen
|
||||
import com.hermesandroid.companion.ui.screens.BridgeScreen
|
||||
import com.hermesandroid.companion.ui.screens.ChatScreen
|
||||
import com.hermesandroid.companion.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.companion.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
|
||||
import com.hermesandroid.companion.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.companion.viewmodel.ConnectionViewModel
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
data object Onboarding : Screen("onboarding", "Onboarding", Icons.Filled.Settings)
|
||||
data object Chat : Screen("chat", "Chat", Icons.Filled.Chat)
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
}
|
||||
|
||||
private val bottomNavScreens = listOf(
|
||||
Screen.Chat,
|
||||
Screen.Terminal,
|
||||
Screen.Bridge,
|
||||
Screen.Settings
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CompanionApp() {
|
||||
val connectionViewModel: ConnectionViewModel = viewModel()
|
||||
val chatViewModel: ChatViewModel = viewModel()
|
||||
|
||||
// Initialize ChatViewModel with networking dependencies
|
||||
remember {
|
||||
chatViewModel.initialize(
|
||||
connectionViewModel.multiplexer,
|
||||
connectionViewModel.chatHandler
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
// Observe theme preference
|
||||
val themePreference by connectionViewModel.theme.collectAsState()
|
||||
|
||||
// Check onboarding state
|
||||
val onboardingCompleted by connectionViewModel.onboardingCompleted.collectAsState()
|
||||
|
||||
HermesCompanionTheme(themePreference = themePreference) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Determine start destination based on onboarding state
|
||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||
|
||||
// Track whether we're on the onboarding screen
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
// Hide bottom nav during onboarding
|
||||
if (!isOnboarding) {
|
||||
NavigationBar {
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
bottomNavScreens.forEach { screen ->
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = screen.icon,
|
||||
contentDescription = screen.label
|
||||
)
|
||||
},
|
||||
label = { Text(screen.label) },
|
||||
selected = currentDestination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
OnboardingScreen(
|
||||
onComplete = { serverUrl ->
|
||||
connectionViewModel.updateServerUrl(serverUrl)
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(Screen.Chat.route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onSkipToSettings = { serverUrl ->
|
||||
connectionViewModel.updateServerUrl(serverUrl)
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(Screen.Settings.route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.Chat.route) {
|
||||
ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
connectionViewModel = connectionViewModel
|
||||
)
|
||||
}
|
||||
composable(Screen.Terminal.route) {
|
||||
TerminalScreen()
|
||||
}
|
||||
composable(Screen.Bridge.route) {
|
||||
BridgeScreen()
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(connectionViewModel = connectionViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.companion.data.ChatMessage
|
||||
import com.hermesandroid.companion.data.MessageRole
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: ChatMessage,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
|
||||
val bubbleShape = when {
|
||||
isUser -> RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
||||
else -> RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||
}
|
||||
|
||||
val bubbleColor = when {
|
||||
isUser -> MaterialTheme.colorScheme.primary
|
||||
isSystem -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
val textColor = when {
|
||||
isUser -> MaterialTheme.colorScheme.onPrimary
|
||||
isSystem -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
val alignment = when {
|
||||
isUser -> Alignment.End
|
||||
else -> Alignment.Start
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = bubbleColor,
|
||||
modifier = Modifier.widthIn(max = 300.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
if (isSystem) {
|
||||
Text(
|
||||
text = "System",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTimestamp(message.timestamp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
if (message.isStreaming) {
|
||||
Text(
|
||||
text = "streaming...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val formatter = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
return formatter.format(Date(timestamp))
|
||||
}
|
||||
|
||||
@androidx.compose.ui.tooling.preview.Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun MessageBubblePreview() {
|
||||
com.hermesandroid.companion.ui.theme.HermesCompanionTheme {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp)) {
|
||||
MessageBubble(
|
||||
message = ChatMessage(
|
||||
id = "1", role = MessageRole.USER,
|
||||
content = "Tell me about the weather today",
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
MessageBubble(
|
||||
message = ChatMessage(
|
||||
id = "2", role = MessageRole.ASSISTANT,
|
||||
content = "I'd be happy to help! Let me check the current weather conditions for you.",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.companion.data.ToolCall
|
||||
|
||||
@Composable
|
||||
fun ToolProgressCard(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val statusIcon = when {
|
||||
toolCall.isComplete && toolCall.success == true -> Icons.Filled.Check
|
||||
toolCall.isComplete && toolCall.success == false -> Icons.Filled.Close
|
||||
else -> Icons.Filled.HourglassTop
|
||||
}
|
||||
|
||||
val statusColor = when {
|
||||
toolCall.isComplete && toolCall.success == true -> MaterialTheme.colorScheme.primary
|
||||
toolCall.isComplete && toolCall.success == false -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
// Header row — always visible
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Build,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = toolCall.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "Success"
|
||||
toolCall.isComplete && toolCall.success == false -> "Failed"
|
||||
else -> "Running"
|
||||
},
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Progress indicator for running tools
|
||||
if (!toolCall.isComplete) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Expandable details
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Arguments
|
||||
if (!toolCall.args.isNullOrBlank()) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = "Arguments",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = toolCall.args,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Result
|
||||
if (!toolCall.result.isNullOrBlank()) {
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = "Result",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = toolCall.result,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = if (toolCall.success == true) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.compose.ui.tooling.preview.Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ToolProgressCardPreview() {
|
||||
com.hermesandroid.companion.ui.theme.HermesCompanionTheme {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
ToolProgressCard(
|
||||
toolCall = ToolCall(
|
||||
name = "android_read_screen",
|
||||
args = """{"include_invisible": false}""",
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
)
|
||||
)
|
||||
ToolProgressCard(
|
||||
toolCall = ToolCall(
|
||||
name = "android_tap_text",
|
||||
args = """{"text": "Continue"}""",
|
||||
result = "Tapped element at (540, 1200)",
|
||||
success = true,
|
||||
isComplete = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Stars
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PAGE_COUNT = 5
|
||||
private const val LAST_PAGE = PAGE_COUNT - 1
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onComplete: (serverUrl: String) -> Unit,
|
||||
onSkipToSettings: (serverUrl: String) -> Unit
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { PAGE_COUNT })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var serverUrl by rememberSaveable { mutableStateOf("wss://localhost:8767") }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Top bar with Skip and Skip to Settings
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Skip to Settings - visible on pages 1-4
|
||||
AnimatedVisibility(
|
||||
visible = pagerState.currentPage < LAST_PAGE,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
TextButton(onClick = { onSkipToSettings(serverUrl) }) {
|
||||
Text(
|
||||
text = "Skip to Settings",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Skip button - always visible
|
||||
TextButton(onClick = { onComplete(serverUrl) }) {
|
||||
Text(
|
||||
text = "Skip",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pager content
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { page ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (page) {
|
||||
0 -> WelcomePage()
|
||||
1 -> ChatPage()
|
||||
2 -> TerminalPage()
|
||||
3 -> BridgePage()
|
||||
4 -> ConnectPage(
|
||||
serverUrl = serverUrl,
|
||||
onServerUrlChange = { serverUrl = it },
|
||||
onGetStarted = { onComplete(serverUrl) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom section: page indicator + navigation button
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Page indicator
|
||||
PageIndicator(
|
||||
pageCount = PAGE_COUNT,
|
||||
currentPage = pagerState.currentPage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Next / Get Started button
|
||||
if (pagerState.currentPage < LAST_PAGE) {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "Next")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onComplete(serverUrl) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "Get Started")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Stars,
|
||||
title = "Hermes Companion",
|
||||
description = "Your AI agent, in your pocket. Chat, control, and connect — all from your phone."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Chat,
|
||||
title = "Talk to Your Agent",
|
||||
description = "Stream conversations with any Hermes profile. Ask questions, run tasks, and collaborate in real time."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Terminal,
|
||||
title = "Remote Terminal",
|
||||
description = "Secure shell access to your agent's host machine, right from your phone. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.PhoneAndroid,
|
||||
title = "Device Bridge",
|
||||
description = "Let your agent interact with your phone — read notifications, tap buttons, and automate workflows. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
serverUrl: String,
|
||||
onServerUrlChange: (String) -> Unit,
|
||||
onGetStarted: () -> Unit
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Link,
|
||||
title = "Let's Connect",
|
||||
description = "Enter your companion relay server URL. You'll pair with a 6-character code after connecting."
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = onServerUrlChange,
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "A 6-digit pairing code will appear on screen after your first connection to verify the link.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = onGetStarted,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "Get Started")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
private fun OnboardingScreenPreview() {
|
||||
HermesCompanionTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _ -> },
|
||||
onSkipToSettings = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun OnboardingScreenDarkPreview() {
|
||||
HermesCompanionTheme(themePreference = "dark") {
|
||||
OnboardingScreen(
|
||||
onComplete = { _ -> },
|
||||
onSkipToSettings = { _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun BridgeScreen() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Bridge",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Coming Soon",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Agent-controlled phone interaction will be available in Phase 3. " +
|
||||
"This wraps the existing bridge protocol, allowing Hermes to tap, type, " +
|
||||
"take screenshots, and control your device through the AccessibilityService.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.screens
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.companion.network.ConnectionState
|
||||
import com.hermesandroid.companion.ui.components.MessageBubble
|
||||
import com.hermesandroid.companion.ui.components.ToolProgressCard
|
||||
import com.hermesandroid.companion.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.companion.viewmodel.ConnectionViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
chatViewModel: ChatViewModel,
|
||||
connectionViewModel: ConnectionViewModel
|
||||
) {
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
val currentProfile by chatViewModel.currentProfile.collectAsState()
|
||||
val profiles by chatViewModel.profiles.collectAsState()
|
||||
val connectionState by connectionViewModel.connectionState.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
var profileMenuExpanded by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
) {
|
||||
// Top bar with profile selector
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Hermes Chat")
|
||||
|
||||
// Connection indicator
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Circle,
|
||||
contentDescription = connectionState.name,
|
||||
tint = when (connectionState) {
|
||||
ConnectionState.Connected -> MaterialTheme.colorScheme.primary
|
||||
ConnectionState.Connecting,
|
||||
ConnectionState.Reconnecting -> MaterialTheme.colorScheme.tertiary
|
||||
ConnectionState.Disconnected -> MaterialTheme.colorScheme.error
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(8.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Profile selector
|
||||
Box {
|
||||
TextButton(onClick = { profileMenuExpanded = true }) {
|
||||
Text(currentProfile)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Select profile"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = profileMenuExpanded,
|
||||
onDismissRequest = { profileMenuExpanded = false }
|
||||
) {
|
||||
profiles.forEach { profile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(profile) },
|
||||
onClick = {
|
||||
chatViewModel.selectProfile(profile)
|
||||
profileMenuExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
|
||||
// Error banner
|
||||
AnimatedVisibility(visible = error != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = error ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = { chatViewModel.clearError() }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message list or empty state
|
||||
if (messages.isEmpty() && !isStreaming) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Chat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Send a message to start chatting with your agent",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
if (connectionState != ConnectionState.Connected) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Connect to your server first",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
|
||||
items(messages, key = { it.id }) { message ->
|
||||
MessageBubble(message = message)
|
||||
|
||||
// Show tool progress cards for messages with tool calls
|
||||
message.toolCalls.forEach { toolCall ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ToolProgressCard(toolCall = toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator
|
||||
if (isStreaming) {
|
||||
item {
|
||||
Text(
|
||||
text = "...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Input bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Message...") },
|
||||
maxLines = 4,
|
||||
enabled = connectionState == ConnectionState.Connected
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (inputText.isNotBlank()) {
|
||||
chatViewModel.sendMessage(inputText)
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
enabled = inputText.isNotBlank() &&
|
||||
connectionState == ConnectionState.Connected &&
|
||||
!isStreaming
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send message",
|
||||
tint = if (inputText.isNotBlank() && connectionState == ConnectionState.Connected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Switch
|
||||
import com.hermesandroid.companion.auth.AuthState
|
||||
import com.hermesandroid.companion.network.ConnectionState
|
||||
import com.hermesandroid.companion.viewmodel.ConnectionViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel
|
||||
) {
|
||||
val connectionState by connectionViewModel.connectionState.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val serverUrl by connectionViewModel.serverUrl.collectAsState()
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val theme by connectionViewModel.theme.collectAsState()
|
||||
val insecureMode by connectionViewModel.insecureMode.collectAsState()
|
||||
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
|
||||
|
||||
var urlInput by remember(serverUrl) { mutableStateOf(serverUrl) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Connection section
|
||||
Text(
|
||||
text = "Connection",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Server URL
|
||||
OutlinedTextField(
|
||||
value = urlInput,
|
||||
onValueChange = { urlInput = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Connect/Disconnect buttons
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val canConnect = connectionState == ConnectionState.Disconnected &&
|
||||
(urlInput.startsWith("wss://") ||
|
||||
(insecureMode && urlInput.startsWith("ws://")))
|
||||
Button(
|
||||
onClick = {
|
||||
connectionViewModel.connect(urlInput)
|
||||
},
|
||||
enabled = canConnect
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { connectionViewModel.disconnect() },
|
||||
enabled = connectionState != ConnectionState.Disconnected
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Connection status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
tint = when (connectionState) {
|
||||
ConnectionState.Connected -> MaterialTheme.colorScheme.primary
|
||||
ConnectionState.Connecting,
|
||||
ConnectionState.Reconnecting -> MaterialTheme.colorScheme.tertiary
|
||||
ConnectionState.Disconnected -> MaterialTheme.colorScheme.error
|
||||
},
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
Text(
|
||||
text = when (connectionState) {
|
||||
ConnectionState.Connected -> "Connected"
|
||||
ConnectionState.Connecting -> "Connecting..."
|
||||
ConnectionState.Reconnecting -> "Reconnecting..."
|
||||
ConnectionState.Disconnected -> "Disconnected"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
// Insecure connection warning
|
||||
if (isInsecureConnection && connectionState == ConnectionState.Connected) {
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Insecure connection — traffic is not encrypted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Insecure mode toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Allow insecure connections",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Enable ws:// for local dev/testing only",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = insecureMode,
|
||||
onCheckedChange = { connectionViewModel.setInsecureMode(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pairing section
|
||||
Text(
|
||||
text = "Pairing",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Pairing code display
|
||||
Text(
|
||||
text = "Pairing Code",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = pairingCode,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.15
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(pairingCode))
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy pairing code"
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.regeneratePairingCode()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Generate new code"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Session token status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Session:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when (authState) {
|
||||
is AuthState.Paired -> MaterialTheme.colorScheme.primary
|
||||
is AuthState.Failed -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (authState is AuthState.Paired) {
|
||||
OutlinedButton(onClick = { connectionViewModel.clearSession() }) {
|
||||
Text("Clear Session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Theme section
|
||||
Text(
|
||||
text = "Appearance",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Theme",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
val themeOptions = listOf("auto", "light", "dark")
|
||||
val themeLabels = listOf("Auto", "Light", "Dark")
|
||||
val selectedIndex = themeOptions.indexOf(theme).coerceAtLeast(0)
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
themeOptions.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = themeOptions.size
|
||||
),
|
||||
onClick = { connectionViewModel.setTheme(option) },
|
||||
selected = index == selectedIndex
|
||||
) {
|
||||
Text(themeLabels[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data Management section
|
||||
Text(
|
||||
text = "Data",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
DataManagementSection(connectionViewModel = connectionViewModel)
|
||||
|
||||
// About section
|
||||
Text(
|
||||
text = "About",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "App Version",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "0.1.0",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Build",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "1 (MVP)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DataManagementSection(connectionViewModel: ConnectionViewModel) {
|
||||
val context = LocalContext.current
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
var backupJson by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// SAF file picker for export
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
if (uri != null && backupJson != null) {
|
||||
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) "Settings exported" else "Export failed",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
backupJson = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SAF file picker for import
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
connectionViewModel.importFromUri(uri) { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) "Settings imported" else "Import failed — invalid file",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Reset Onboarding
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Reset Onboarding",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Show the setup guide again on next launch",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding()
|
||||
Toast.makeText(context, "Onboarding will show on next launch", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RestartAlt,
|
||||
contentDescription = "Reset onboarding"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Export Settings
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Export Settings",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Save settings to a file (no tokens)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-companion-backup.json")
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FileDownload,
|
||||
contentDescription = "Export settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Import Settings
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Import Settings",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Restore settings from a backup file",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FileUpload,
|
||||
contentDescription = "Import settings"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Reset All Data
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Reset All Data",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = "Clear all settings, tokens, and cached data",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showResetDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = "Reset all data",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation dialog for data reset
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset All Data?") },
|
||||
text = { Text("This will clear all settings, authentication tokens, and cached data. You'll need to re-pair with your server. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData()
|
||||
Toast.makeText(context, "App data reset", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TerminalScreen() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Terminal",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Coming Soon",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Secure remote shell access via tmux will be available in Phase 2. " +
|
||||
"This will provide a full terminal emulator with xterm.js rendering, " +
|
||||
"session persistence, and biometric authentication.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package com.hermesandroid.companion.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val HermesPurple = Color(0xFF7C4DFF)
|
||||
private val HermesPurpleLight = Color(0xFFB388FF)
|
||||
private val HermesPurpleDark = Color(0xFF651FFF)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = HermesPurpleLight,
|
||||
onPrimary = Color(0xFF1A0049),
|
||||
primaryContainer = HermesPurpleDark,
|
||||
onPrimaryContainer = Color(0xFFEADDFF),
|
||||
secondary = Color(0xFFCCC2DC),
|
||||
onSecondary = Color(0xFF332D41),
|
||||
secondaryContainer = Color(0xFF4A4458),
|
||||
onSecondaryContainer = Color(0xFFE8DEF8),
|
||||
tertiary = Color(0xFFEFB8C8),
|
||||
onTertiary = Color(0xFF492532),
|
||||
tertiaryContainer = Color(0xFF633B48),
|
||||
onTertiaryContainer = Color(0xFFFFD8E4),
|
||||
background = Color(0xFF1C1B1F),
|
||||
onBackground = Color(0xFFE6E1E5),
|
||||
surface = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFFE6E1E5),
|
||||
surfaceVariant = Color(0xFF49454F),
|
||||
onSurfaceVariant = Color(0xFFCAC4D0)
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = HermesPurple,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFEADDFF),
|
||||
onPrimaryContainer = Color(0xFF21005D),
|
||||
secondary = Color(0xFF625B71),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE8DEF8),
|
||||
onSecondaryContainer = Color(0xFF1D192B),
|
||||
tertiary = Color(0xFF7D5260),
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFFFD8E4),
|
||||
onTertiaryContainer = Color(0xFF31111D),
|
||||
background = Color(0xFFFFFBFE),
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
surfaceVariant = Color(0xFFE7E0EC),
|
||||
onSurfaceVariant = Color(0xFF49454F)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HermesCompanionTheme(
|
||||
themePreference: String = "auto",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val useDarkTheme = when (themePreference) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
// Dynamic colors available on Android 12+ (API 31)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (useDarkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
useDarkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package com.hermesandroid.companion.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.hermesandroid.companion.data.ChatMessage
|
||||
import com.hermesandroid.companion.data.ChatSession
|
||||
import com.hermesandroid.companion.data.MessageRole
|
||||
import com.hermesandroid.companion.network.ChannelMultiplexer
|
||||
import com.hermesandroid.companion.network.handlers.ChatHandler
|
||||
import com.hermesandroid.companion.network.models.ChatSendPayload
|
||||
import com.hermesandroid.companion.network.models.Envelope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.util.UUID
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private var multiplexer: ChannelMultiplexer? = null
|
||||
private var chatHandler: ChatHandler? = null
|
||||
|
||||
private val _currentProfile = MutableStateFlow("default")
|
||||
val currentProfile: StateFlow<String> = _currentProfile.asStateFlow()
|
||||
|
||||
private val _profiles = MutableStateFlow<List<String>>(listOf("default"))
|
||||
val profiles: StateFlow<List<String>> = _profiles.asStateFlow()
|
||||
|
||||
private val _currentSessionId = MutableStateFlow<String?>(null)
|
||||
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
|
||||
|
||||
// Delegated to ChatHandler
|
||||
val messages: StateFlow<List<ChatMessage>>
|
||||
get() = chatHandler?.messages ?: MutableStateFlow(emptyList())
|
||||
|
||||
val isStreaming: StateFlow<Boolean>
|
||||
get() = chatHandler?.isStreaming ?: MutableStateFlow(false)
|
||||
|
||||
val sessions: StateFlow<List<ChatSession>>
|
||||
get() = chatHandler?.sessions ?: MutableStateFlow(emptyList())
|
||||
|
||||
val error: StateFlow<String?>
|
||||
get() = chatHandler?.error ?: MutableStateFlow(null)
|
||||
|
||||
/**
|
||||
* Wire up the chat handler and multiplexer.
|
||||
* Called once from the UI layer after dependencies are ready.
|
||||
*/
|
||||
fun initialize(multiplexer: ChannelMultiplexer, chatHandler: ChatHandler) {
|
||||
this.multiplexer = multiplexer
|
||||
this.chatHandler = chatHandler
|
||||
}
|
||||
|
||||
fun updateProfiles(profileList: List<String>) {
|
||||
_profiles.value = profileList
|
||||
if (profileList.isNotEmpty() && _currentProfile.value !in profileList) {
|
||||
_currentProfile.value = profileList.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectProfile(name: String) {
|
||||
_currentProfile.value = name
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
|
||||
// Add user message locally
|
||||
val userMessage = ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.USER,
|
||||
content = text.trim(),
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
chatHandler?.addUserMessage(userMessage)
|
||||
|
||||
// Build and send envelope
|
||||
val payload = ChatSendPayload(
|
||||
profile = _currentProfile.value,
|
||||
sessionId = _currentSessionId.value,
|
||||
message = text.trim()
|
||||
)
|
||||
|
||||
val envelope = Envelope(
|
||||
channel = "chat",
|
||||
type = "chat.send",
|
||||
id = messageId,
|
||||
payload = json.encodeToJsonElement(payload).jsonObject
|
||||
)
|
||||
|
||||
multiplexer?.send(envelope)
|
||||
}
|
||||
|
||||
fun setSessionId(sessionId: String) {
|
||||
_currentSessionId.value = sessionId
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
chatHandler?.clearError()
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package com.hermesandroid.companion.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.companion.auth.AuthManager
|
||||
import com.hermesandroid.companion.auth.AuthState
|
||||
import com.hermesandroid.companion.data.DataManager
|
||||
import com.hermesandroid.companion.data.companionDataStore
|
||||
import com.hermesandroid.companion.network.ChannelMultiplexer
|
||||
import com.hermesandroid.companion.network.ConnectionManager
|
||||
import com.hermesandroid.companion.network.ConnectionState
|
||||
import com.hermesandroid.companion.network.handlers.ChatHandler
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
|
||||
private val KEY_THEME = stringPreferencesKey("theme")
|
||||
private val KEY_INSECURE_MODE = booleanPreferencesKey("insecure_mode")
|
||||
private const val DEFAULT_URL = "wss://localhost:8767"
|
||||
}
|
||||
|
||||
// Core networking components
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val chatHandler = ChatHandler()
|
||||
private val connectionManager = ConnectionManager(multiplexer)
|
||||
val authManager = AuthManager(application, multiplexer, viewModelScope)
|
||||
|
||||
// Data management
|
||||
val dataManager = DataManager(application)
|
||||
|
||||
// Connection state
|
||||
val connectionState: StateFlow<ConnectionState> = connectionManager.connectionState
|
||||
val authState: StateFlow<AuthState> = authManager.authState
|
||||
val insecureMode: StateFlow<Boolean> = connectionManager.insecureMode
|
||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||
|
||||
// Server URL
|
||||
private val _serverUrl = MutableStateFlow(DEFAULT_URL)
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
|
||||
// Theme preference
|
||||
val theme: StateFlow<String> = application.companionDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[KEY_THEME] ?: "auto"
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
|
||||
|
||||
// Onboarding state
|
||||
private val _onboardingCompleted = MutableStateFlow(true) // default true to avoid flash
|
||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted.asStateFlow()
|
||||
|
||||
// Pairing code from AuthManager
|
||||
val pairingCode: StateFlow<String> = authManager.pairingCode
|
||||
|
||||
init {
|
||||
// Register chat handler
|
||||
multiplexer.registerHandler("chat", chatHandler)
|
||||
|
||||
// Wire multiplexer to connection manager
|
||||
multiplexer.setSendCallback { envelope ->
|
||||
connectionManager.send(envelope)
|
||||
}
|
||||
|
||||
// Auto-authenticate on connect
|
||||
multiplexer.setOnConnectedCallback {
|
||||
authManager.authenticate()
|
||||
}
|
||||
|
||||
// Load saved state
|
||||
viewModelScope.launch {
|
||||
// Load onboarding state
|
||||
_onboardingCompleted.value = dataManager.isOnboardingCompleted()
|
||||
|
||||
// Load saved preferences
|
||||
application.companionDataStore.data.collect { preferences ->
|
||||
// Restore insecure mode
|
||||
val insecure = preferences[KEY_INSECURE_MODE] ?: false
|
||||
connectionManager.setInsecureMode(insecure)
|
||||
val savedUrl = preferences[KEY_SERVER_URL]
|
||||
if (savedUrl != null) {
|
||||
_serverUrl.value = savedUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(url: String) {
|
||||
_serverUrl.value = url
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().companionDataStore.edit { preferences ->
|
||||
preferences[KEY_SERVER_URL] = url
|
||||
}
|
||||
}
|
||||
connectionManager.connect(url)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
connectionManager.connect(_serverUrl.value)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
|
||||
fun updateServerUrl(url: String) {
|
||||
_serverUrl.value = url
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().companionDataStore.edit { preferences ->
|
||||
preferences[KEY_SERVER_URL] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().companionDataStore.edit { preferences ->
|
||||
preferences[KEY_THEME] = theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setInsecureMode(enabled: Boolean) {
|
||||
connectionManager.setInsecureMode(enabled)
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().companionDataStore.edit { preferences ->
|
||||
preferences[KEY_INSECURE_MODE] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
_onboardingCompleted.value = true
|
||||
viewModelScope.launch {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
viewModelScope.launch {
|
||||
dataManager.resetOnboarding()
|
||||
_onboardingCompleted.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAppData() {
|
||||
viewModelScope.launch {
|
||||
disconnect()
|
||||
dataManager.resetAppData()
|
||||
_serverUrl.value = DEFAULT_URL
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(onResult: (String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val json = dataManager.exportSettings(
|
||||
serverUrl = _serverUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
profiles = authManager.profiles.value
|
||||
)
|
||||
onResult(json)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeBackupToUri(uri: Uri, backup: String, onResult: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val success = dataManager.writeBackupToUri(uri, backup)
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromUri(uri: Uri, onResult: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val jsonString = dataManager.readBackupFromUri(uri) ?: run {
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
val backup = dataManager.importSettings(jsonString) ?: run {
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
// Apply imported settings
|
||||
backup.serverUrl?.let { updateServerUrl(it) }
|
||||
setTheme(backup.theme)
|
||||
if (backup.onboardingCompleted) {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
_onboardingCompleted.value = true
|
||||
}
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun regeneratePairingCode() {
|
||||
authManager.regeneratePairingCode()
|
||||
}
|
||||
|
||||
fun clearSession() {
|
||||
authManager.clearSession()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.Application
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
|
||||
class HermesRelayApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
AppAnalytics.initialize(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: HermesRelayApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val connectionViewModel: ConnectionViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
|
||||
// Hold splash until DataStore is loaded and onboarding status is known
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
!connectionViewModel.isReady.value
|
||||
}
|
||||
|
||||
// Smooth exit: fade out the splash screen
|
||||
splashScreen.setOnExitAnimationListener { splashScreenView ->
|
||||
val fadeOut = ObjectAnimator.ofFloat(
|
||||
splashScreenView.view,
|
||||
View.ALPHA,
|
||||
1f, 0f
|
||||
).apply {
|
||||
duration = 400
|
||||
interpolator = DecelerateInterpolator()
|
||||
doOnEnd { splashScreenView.remove() }
|
||||
}
|
||||
fadeOut.start()
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
RelayApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
package com.hermesandroid.companion.auth
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.hermesandroid.companion.network.ChannelMultiplexer
|
||||
import com.hermesandroid.companion.network.models.Envelope
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -37,27 +39,33 @@ class AuthManager(
|
||||
private const val PREFS_NAME = "hermes_companion_auth"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Lazy-init crypto on first access (off main thread)
|
||||
// Thread-safe lazy-init crypto on first access (off main thread)
|
||||
private var _prefs: SharedPreferences? = null
|
||||
private val prefsMutex = Mutex()
|
||||
private suspend fun prefs(): SharedPreferences {
|
||||
_prefs?.let { return it }
|
||||
return withContext(Dispatchers.IO) {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
).also { _prefs = it }
|
||||
return prefsMutex.withLock {
|
||||
// Double-check inside lock
|
||||
_prefs?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
).also { _prefs = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +154,25 @@ class AuthManager(
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Key storage (for direct Hermes API Server auth) ---
|
||||
|
||||
suspend fun getApiKey(): String? {
|
||||
return prefs().getString(KEY_API_KEY, null)
|
||||
}
|
||||
|
||||
suspend fun setApiKey(key: String) {
|
||||
val trimmed = key.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
prefs().edit().remove(KEY_API_KEY).apply()
|
||||
} else {
|
||||
prefs().edit().putString(KEY_API_KEY, trimmed).apply()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearApiKey() {
|
||||
prefs().edit().remove(KEY_API_KEY).apply()
|
||||
}
|
||||
|
||||
val isPaired: Boolean
|
||||
get() = _authState.value is AuthState.Paired
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Lightweight in-app analytics tracker.
|
||||
*
|
||||
* Tracks response times, token usage, session stats, connection health,
|
||||
* and stream outcomes. Lifetime stats are persisted to DataStore; session
|
||||
* stats reset when the app process restarts.
|
||||
*
|
||||
* All public methods are thread-safe (StateFlow + atomic updates).
|
||||
* Call [initialize] once from Application.onCreate().
|
||||
*/
|
||||
object AppAnalytics {
|
||||
|
||||
private const val TAG = "AppAnalytics"
|
||||
private const val MAX_RECENT_TIMES = 20
|
||||
|
||||
// DataStore keys for lifetime stats
|
||||
private val KEY_TOTAL_MESSAGES_SENT = intPreferencesKey("analytics_total_messages_sent")
|
||||
private val KEY_TOTAL_TOKENS_IN = longPreferencesKey("analytics_total_tokens_in")
|
||||
private val KEY_TOTAL_TOKENS_OUT = longPreferencesKey("analytics_total_tokens_out")
|
||||
private val KEY_TOTAL_RESPONSE_TIME_MS = longPreferencesKey("analytics_total_response_time_ms")
|
||||
private val KEY_TOTAL_COMPLETION_TIME_MS = longPreferencesKey("analytics_total_completion_time_ms")
|
||||
private val KEY_RESPONSE_TIME_COUNT = intPreferencesKey("analytics_response_time_count")
|
||||
private val KEY_COMPLETION_TIME_COUNT = intPreferencesKey("analytics_completion_time_count")
|
||||
private val KEY_STREAMS_COMPLETED = intPreferencesKey("analytics_streams_completed")
|
||||
private val KEY_STREAMS_ERRORED = intPreferencesKey("analytics_streams_errored")
|
||||
private val KEY_STREAMS_CANCELLED = intPreferencesKey("analytics_streams_cancelled")
|
||||
private val KEY_HEALTH_CHECKS_TOTAL = intPreferencesKey("analytics_health_checks_total")
|
||||
private val KEY_HEALTH_CHECKS_SUCCESS = intPreferencesKey("analytics_health_checks_success")
|
||||
private val KEY_TOTAL_HEALTH_LATENCY_MS = longPreferencesKey("analytics_total_health_latency_ms")
|
||||
private val KEY_SESSION_COUNT = intPreferencesKey("analytics_session_count")
|
||||
private val KEY_RECENT_RESPONSE_TIMES = stringPreferencesKey("analytics_recent_response_times")
|
||||
private val KEY_RECENT_COMPLETION_TIMES = stringPreferencesKey("analytics_recent_completion_times")
|
||||
|
||||
private val _stats = MutableStateFlow(AppStats())
|
||||
val stats: StateFlow<AppStats> = _stats.asStateFlow()
|
||||
|
||||
private var context: Context? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
// Timing state for current in-flight message
|
||||
private var messageSentAtMs: Long = 0L
|
||||
private var firstTokenReceivedAtMs: Long = 0L
|
||||
private var firstTokenReceived: Boolean = false
|
||||
|
||||
/**
|
||||
* Initialize with application context. Loads persisted lifetime stats.
|
||||
* Must be called once from Application.onCreate().
|
||||
*/
|
||||
fun initialize(appContext: Context) {
|
||||
context = appContext.applicationContext
|
||||
scope.launch { loadFromDataStore() }
|
||||
}
|
||||
|
||||
// --- Event hooks (call from ChatViewModel / HermesApiClient) ---
|
||||
|
||||
/** Called when a user sends a message. Starts the response timer. */
|
||||
fun onMessageSent() {
|
||||
messageSentAtMs = System.currentTimeMillis()
|
||||
firstTokenReceived = false
|
||||
firstTokenReceivedAtMs = 0L
|
||||
|
||||
_stats.value = _stats.value.let { s ->
|
||||
s.copy(
|
||||
totalMessagesSent = s.totalMessagesSent + 1,
|
||||
currentSessionMessages = s.currentSessionMessages + 1
|
||||
)
|
||||
}
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/** Called when the first text delta arrives. Calculates TTFT. */
|
||||
fun onFirstTokenReceived() {
|
||||
if (firstTokenReceived || messageSentAtMs == 0L) return
|
||||
firstTokenReceived = true
|
||||
firstTokenReceivedAtMs = System.currentTimeMillis()
|
||||
|
||||
val ttft = firstTokenReceivedAtMs - messageSentAtMs
|
||||
|
||||
_stats.value = _stats.value.let { s ->
|
||||
val newRecent = (s.recentResponseTimesMs + ttft).takeLast(MAX_RECENT_TIMES)
|
||||
val newCount = s.responseTimeCount + 1
|
||||
val newTotal = s.totalResponseTimeMs + ttft
|
||||
s.copy(
|
||||
avgResponseTimeMs = if (newCount > 0) newTotal / newCount else 0L,
|
||||
recentResponseTimesMs = newRecent,
|
||||
responseTimeCount = newCount,
|
||||
totalResponseTimeMs = newTotal
|
||||
)
|
||||
}
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when streaming completes successfully.
|
||||
* [inputTokens] and [outputTokens] may be null if the server doesn't report them.
|
||||
*/
|
||||
fun onStreamComplete(inputTokens: Int?, outputTokens: Int?) {
|
||||
val now = System.currentTimeMillis()
|
||||
val completionTime = if (messageSentAtMs > 0L) now - messageSentAtMs else 0L
|
||||
|
||||
_stats.value = _stats.value.let { s ->
|
||||
val tokIn = inputTokens?.toLong() ?: 0L
|
||||
val tokOut = outputTokens?.toLong() ?: 0L
|
||||
|
||||
val newRecentCompletion = if (completionTime > 0L) {
|
||||
(s.recentCompletionTimesMs + completionTime).takeLast(MAX_RECENT_TIMES)
|
||||
} else {
|
||||
s.recentCompletionTimesMs
|
||||
}
|
||||
|
||||
val newCompletionCount = if (completionTime > 0L) s.completionTimeCount + 1 else s.completionTimeCount
|
||||
val newCompletionTotal = s.totalCompletionTimeMs + completionTime
|
||||
|
||||
val newStreamsCompleted = s.streamsCompleted + 1
|
||||
val totalStreams = newStreamsCompleted + s.streamsErrored + s.streamsCancelled
|
||||
|
||||
s.copy(
|
||||
totalTokensIn = s.totalTokensIn + tokIn,
|
||||
totalTokensOut = s.totalTokensOut + tokOut,
|
||||
currentSessionTokensIn = s.currentSessionTokensIn + tokIn,
|
||||
currentSessionTokensOut = s.currentSessionTokensOut + tokOut,
|
||||
avgCompletionTimeMs = if (newCompletionCount > 0) newCompletionTotal / newCompletionCount else 0L,
|
||||
recentCompletionTimesMs = newRecentCompletion,
|
||||
completionTimeCount = newCompletionCount,
|
||||
totalCompletionTimeMs = newCompletionTotal,
|
||||
streamsCompleted = newStreamsCompleted,
|
||||
streamSuccessRate = if (totalStreams > 0) newStreamsCompleted.toFloat() / totalStreams else 1f
|
||||
)
|
||||
}
|
||||
|
||||
// Reset timing state
|
||||
messageSentAtMs = 0L
|
||||
firstTokenReceived = false
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/** Called when streaming encounters an error. */
|
||||
fun onStreamError() {
|
||||
_stats.value = _stats.value.let { s ->
|
||||
val newErrored = s.streamsErrored + 1
|
||||
val totalStreams = s.streamsCompleted + newErrored + s.streamsCancelled
|
||||
s.copy(
|
||||
streamsErrored = newErrored,
|
||||
streamSuccessRate = if (totalStreams > 0) s.streamsCompleted.toFloat() / totalStreams else 0f
|
||||
)
|
||||
}
|
||||
|
||||
messageSentAtMs = 0L
|
||||
firstTokenReceived = false
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/** Called when a stream is intentionally cancelled by the user. */
|
||||
fun onStreamCancelled() {
|
||||
_stats.value = _stats.value.let { s ->
|
||||
val newCancelled = s.streamsCancelled + 1
|
||||
val totalStreams = s.streamsCompleted + s.streamsErrored + newCancelled
|
||||
s.copy(
|
||||
streamsCancelled = newCancelled,
|
||||
streamSuccessRate = if (totalStreams > 0) s.streamsCompleted.toFloat() / totalStreams else 1f
|
||||
)
|
||||
}
|
||||
|
||||
messageSentAtMs = 0L
|
||||
firstTokenReceived = false
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/** Called after a health check completes. */
|
||||
fun onHealthCheck(success: Boolean, latencyMs: Long) {
|
||||
_stats.value = _stats.value.let { s ->
|
||||
val newTotal = s.healthChecksTotal + 1
|
||||
val newSuccess = s.healthChecksSuccess + if (success) 1 else 0
|
||||
val newLatencyTotal = s.totalHealthLatencyMs + latencyMs
|
||||
s.copy(
|
||||
healthChecksTotal = newTotal,
|
||||
healthChecksSuccess = newSuccess,
|
||||
healthCheckSuccessRate = if (newTotal > 0) newSuccess.toFloat() / newTotal else 0f,
|
||||
avgHealthLatencyMs = if (newTotal > 0) newLatencyTotal / newTotal else 0L,
|
||||
totalHealthLatencyMs = newLatencyTotal
|
||||
)
|
||||
}
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/** Called when a new session is created. */
|
||||
fun onSessionCreated() {
|
||||
_stats.value = _stats.value.let { s ->
|
||||
s.copy(
|
||||
sessionCount = s.sessionCount + 1,
|
||||
currentSessionMessages = 0,
|
||||
currentSessionTokensIn = 0L,
|
||||
currentSessionTokensOut = 0L
|
||||
)
|
||||
}
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
/** Reset current-session counters (e.g., when switching sessions). */
|
||||
fun onSessionSwitched() {
|
||||
_stats.value = _stats.value.copy(
|
||||
currentSessionMessages = 0,
|
||||
currentSessionTokensIn = 0L,
|
||||
currentSessionTokensOut = 0L
|
||||
)
|
||||
}
|
||||
|
||||
/** Reset all analytics data (called from data reset). */
|
||||
fun resetAll() {
|
||||
_stats.value = AppStats()
|
||||
messageSentAtMs = 0L
|
||||
firstTokenReceived = false
|
||||
firstTokenReceivedAtMs = 0L
|
||||
persistAsync()
|
||||
}
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
private fun persistAsync() {
|
||||
val ctx = context ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val s = _stats.value
|
||||
ctx.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TOTAL_MESSAGES_SENT] = s.totalMessagesSent
|
||||
prefs[KEY_TOTAL_TOKENS_IN] = s.totalTokensIn
|
||||
prefs[KEY_TOTAL_TOKENS_OUT] = s.totalTokensOut
|
||||
prefs[KEY_TOTAL_RESPONSE_TIME_MS] = s.totalResponseTimeMs
|
||||
prefs[KEY_TOTAL_COMPLETION_TIME_MS] = s.totalCompletionTimeMs
|
||||
prefs[KEY_RESPONSE_TIME_COUNT] = s.responseTimeCount
|
||||
prefs[KEY_COMPLETION_TIME_COUNT] = s.completionTimeCount
|
||||
prefs[KEY_STREAMS_COMPLETED] = s.streamsCompleted
|
||||
prefs[KEY_STREAMS_ERRORED] = s.streamsErrored
|
||||
prefs[KEY_STREAMS_CANCELLED] = s.streamsCancelled
|
||||
prefs[KEY_HEALTH_CHECKS_TOTAL] = s.healthChecksTotal
|
||||
prefs[KEY_HEALTH_CHECKS_SUCCESS] = s.healthChecksSuccess
|
||||
prefs[KEY_TOTAL_HEALTH_LATENCY_MS] = s.totalHealthLatencyMs
|
||||
prefs[KEY_SESSION_COUNT] = s.sessionCount
|
||||
prefs[KEY_RECENT_RESPONSE_TIMES] = s.recentResponseTimesMs.joinToString(",")
|
||||
prefs[KEY_RECENT_COMPLETION_TIMES] = s.recentCompletionTimesMs.joinToString(",")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to persist analytics: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadFromDataStore() {
|
||||
val ctx = context ?: return
|
||||
try {
|
||||
val prefs = ctx.relayDataStore.data.first()
|
||||
|
||||
val recentResponse = prefs[KEY_RECENT_RESPONSE_TIMES]
|
||||
?.split(",")
|
||||
?.mapNotNull { it.toLongOrNull() }
|
||||
?.takeLast(MAX_RECENT_TIMES)
|
||||
?: emptyList()
|
||||
|
||||
val recentCompletion = prefs[KEY_RECENT_COMPLETION_TIMES]
|
||||
?.split(",")
|
||||
?.mapNotNull { it.toLongOrNull() }
|
||||
?.takeLast(MAX_RECENT_TIMES)
|
||||
?: emptyList()
|
||||
|
||||
val totalMessages = prefs[KEY_TOTAL_MESSAGES_SENT] ?: 0
|
||||
val totalTokensIn = prefs[KEY_TOTAL_TOKENS_IN] ?: 0L
|
||||
val totalTokensOut = prefs[KEY_TOTAL_TOKENS_OUT] ?: 0L
|
||||
val totalResponseTime = prefs[KEY_TOTAL_RESPONSE_TIME_MS] ?: 0L
|
||||
val totalCompletionTime = prefs[KEY_TOTAL_COMPLETION_TIME_MS] ?: 0L
|
||||
val responseTimeCount = prefs[KEY_RESPONSE_TIME_COUNT] ?: 0
|
||||
val completionTimeCount = prefs[KEY_COMPLETION_TIME_COUNT] ?: 0
|
||||
val streamsCompleted = prefs[KEY_STREAMS_COMPLETED] ?: 0
|
||||
val streamsErrored = prefs[KEY_STREAMS_ERRORED] ?: 0
|
||||
val streamsCancelled = prefs[KEY_STREAMS_CANCELLED] ?: 0
|
||||
val healthTotal = prefs[KEY_HEALTH_CHECKS_TOTAL] ?: 0
|
||||
val healthSuccess = prefs[KEY_HEALTH_CHECKS_SUCCESS] ?: 0
|
||||
val totalHealthLatency = prefs[KEY_TOTAL_HEALTH_LATENCY_MS] ?: 0L
|
||||
val sessionCount = prefs[KEY_SESSION_COUNT] ?: 0
|
||||
|
||||
val totalStreams = streamsCompleted + streamsErrored + streamsCancelled
|
||||
|
||||
_stats.value = AppStats(
|
||||
totalMessagesSent = totalMessages,
|
||||
totalTokensIn = totalTokensIn,
|
||||
totalTokensOut = totalTokensOut,
|
||||
avgResponseTimeMs = if (responseTimeCount > 0) totalResponseTime / responseTimeCount else 0L,
|
||||
avgCompletionTimeMs = if (completionTimeCount > 0) totalCompletionTime / completionTimeCount else 0L,
|
||||
streamSuccessRate = if (totalStreams > 0) streamsCompleted.toFloat() / totalStreams else 1f,
|
||||
healthCheckSuccessRate = if (healthTotal > 0) healthSuccess.toFloat() / healthTotal else 0f,
|
||||
avgHealthLatencyMs = if (healthTotal > 0) totalHealthLatency / healthTotal else 0L,
|
||||
sessionCount = sessionCount,
|
||||
currentSessionMessages = 0,
|
||||
currentSessionTokensIn = 0L,
|
||||
currentSessionTokensOut = 0L,
|
||||
recentResponseTimesMs = recentResponse,
|
||||
recentCompletionTimesMs = recentCompletion,
|
||||
// Internal accumulators
|
||||
totalResponseTimeMs = totalResponseTime,
|
||||
totalCompletionTimeMs = totalCompletionTime,
|
||||
responseTimeCount = responseTimeCount,
|
||||
completionTimeCount = completionTimeCount,
|
||||
streamsCompleted = streamsCompleted,
|
||||
streamsErrored = streamsErrored,
|
||||
streamsCancelled = streamsCancelled,
|
||||
healthChecksTotal = healthTotal,
|
||||
healthChecksSuccess = healthSuccess,
|
||||
totalHealthLatencyMs = totalHealthLatency
|
||||
)
|
||||
|
||||
Log.d(TAG, "Loaded analytics: $totalMessages msgs, ${totalTokensIn + totalTokensOut} tokens")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to load analytics: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all tracked analytics.
|
||||
*
|
||||
* UI-facing fields are the primary ones. Internal accumulators (prefixed
|
||||
* descriptions say "internal") are used for running averages and persistence.
|
||||
*/
|
||||
data class AppStats(
|
||||
// Lifetime totals
|
||||
val totalMessagesSent: Int = 0,
|
||||
val totalTokensIn: Long = 0L,
|
||||
val totalTokensOut: Long = 0L,
|
||||
val avgResponseTimeMs: Long = 0L,
|
||||
val avgCompletionTimeMs: Long = 0L,
|
||||
val streamSuccessRate: Float = 1f,
|
||||
val healthCheckSuccessRate: Float = 0f,
|
||||
val avgHealthLatencyMs: Long = 0L,
|
||||
val sessionCount: Int = 0,
|
||||
// Current session
|
||||
val currentSessionMessages: Int = 0,
|
||||
val currentSessionTokensIn: Long = 0L,
|
||||
val currentSessionTokensOut: Long = 0L,
|
||||
// Recent history for charting (last 20)
|
||||
val recentResponseTimesMs: List<Long> = emptyList(),
|
||||
val recentCompletionTimesMs: List<Long> = emptyList(),
|
||||
// Internal accumulators (not displayed directly, but needed for running averages)
|
||||
val totalResponseTimeMs: Long = 0L,
|
||||
val totalCompletionTimeMs: Long = 0L,
|
||||
val responseTimeCount: Int = 0,
|
||||
val completionTimeCount: Int = 0,
|
||||
val streamsCompleted: Int = 0,
|
||||
val streamsErrored: Int = 0,
|
||||
val streamsCancelled: Int = 0,
|
||||
val healthChecksTotal: Int = 0,
|
||||
val healthChecksSuccess: Int = 0,
|
||||
val totalHealthLatencyMs: Long = 0L
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = false,
|
||||
val toolCalls: List<ToolCall> = emptyList(),
|
||||
// Reasoning/thinking content (collapsible in UI)
|
||||
val thinkingContent: String = "",
|
||||
val isThinkingStreaming: Boolean = false,
|
||||
// Token tracking (from content_complete event)
|
||||
val inputTokens: Int? = null,
|
||||
val outputTokens: Int? = null,
|
||||
val totalTokens: Int? = null,
|
||||
val estimatedCost: Double? = null,
|
||||
// Agent/personality name for display on assistant messages
|
||||
val agentName: String? = null,
|
||||
// File attachments (images, documents, etc.)
|
||||
val attachments: List<Attachment> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* A file attachment sent with a message.
|
||||
* Matches the Hermes API format: { contentType, content (base64) }
|
||||
*/
|
||||
data class Attachment(
|
||||
val contentType: String, // MIME type (e.g. "image/png", "application/pdf", "text/plain")
|
||||
val content: String, // Base64-encoded file content
|
||||
val fileName: String? = null,
|
||||
val fileSize: Long? = null
|
||||
) {
|
||||
val isImage: Boolean get() = contentType.startsWith("image/")
|
||||
}
|
||||
|
||||
data class ToolCall(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val args: String?,
|
||||
val result: String?,
|
||||
val success: Boolean?,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
USER,
|
||||
ASSISTANT,
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
data class ChatSession(
|
||||
val sessionId: String,
|
||||
val title: String?,
|
||||
val model: String?,
|
||||
val messageCount: Int = 0,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hermesandroid.companion.data
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
@@ -44,8 +44,10 @@ class DataManager(private val context: Context) {
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 1,
|
||||
val serverUrl: String? = null,
|
||||
val version: Int = 2,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val profiles: List<String> = emptyList(),
|
||||
@@ -60,11 +62,15 @@ class DataManager(private val context: Context) {
|
||||
serverUrl: String?,
|
||||
theme: String,
|
||||
onboardingCompleted: Boolean,
|
||||
profiles: List<String>
|
||||
profiles: List<String>,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val backup = AppBackup(
|
||||
version = 1,
|
||||
serverUrl = serverUrl,
|
||||
version = 2,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
profiles = profiles,
|
||||
@@ -133,11 +139,11 @@ class DataManager(private val context: Context) {
|
||||
val onboarding = isOnboardingCompleted()
|
||||
|
||||
// Clear all DataStore preferences
|
||||
context.companionDataStore.edit { it.clear() }
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
|
||||
// Restore onboarding flag so it isn't wiped
|
||||
if (onboarding) {
|
||||
context.companionDataStore.edit { preferences ->
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = true
|
||||
}
|
||||
}
|
||||
@@ -171,7 +177,7 @@ class DataManager(private val context: Context) {
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
try {
|
||||
context.companionDataStore.edit { preferences ->
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
@@ -185,7 +191,7 @@ class DataManager(private val context: Context) {
|
||||
*/
|
||||
suspend fun isOnboardingCompleted(): Boolean {
|
||||
return try {
|
||||
context.companionDataStore.data
|
||||
context.relayDataStore.data
|
||||
.map { preferences -> preferences[KEY_ONBOARDING_COMPLETED] ?: false }
|
||||
.first()
|
||||
} catch (e: Exception) {
|
||||
@@ -199,7 +205,7 @@ class DataManager(private val context: Context) {
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
try {
|
||||
context.companionDataStore.edit { preferences ->
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hermesandroid.companion.data
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
@@ -6,12 +6,12 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
/**
|
||||
* Single DataStore instance for the companion app settings.
|
||||
* Single DataStore instance for the app settings.
|
||||
*
|
||||
* All code that needs DataStore access should use [Context.companionDataStore]
|
||||
* All code that needs DataStore access should use [Context.relayDataStore]
|
||||
* rather than creating its own [preferencesDataStore] delegate (multiple
|
||||
* delegates targeting the same file cause a runtime crash).
|
||||
*/
|
||||
internal val Context.companionDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "companion_settings"
|
||||
internal val Context.relayDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "relay_settings"
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
* Runtime overrides persist in DataStore so testers can toggle features
|
||||
* without needing a debug APK.
|
||||
*/
|
||||
object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
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. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
suspend fun unlockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.hermesandroid.companion.network
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.companion.network.models.Envelope
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Routes incoming WebSocket envelopes to the appropriate channel handler.
|
||||
@@ -24,7 +25,7 @@ class ChannelMultiplexer {
|
||||
fun onMessage(envelope: Envelope)
|
||||
}
|
||||
|
||||
private val handlers = mutableMapOf<String, ChannelHandler>()
|
||||
private val handlers = ConcurrentHashMap<String, ChannelHandler>()
|
||||
|
||||
private var onConnectedCallback: (() -> Unit)? = null
|
||||
private var sendCallback: ((Envelope) -> Unit)? = null
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.hermesandroid.companion.network
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.companion.network.models.Envelope
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -29,7 +29,8 @@ enum class ConnectionState {
|
||||
class ConnectionManager(
|
||||
private val multiplexer: ChannelMultiplexer
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val supervisorJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -100,6 +101,13 @@ class ConnectionManager(
|
||||
_isInsecureConnection.value = false
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
supervisorJob.cancel()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
fun send(envelope: Envelope) {
|
||||
val text = json.encodeToString(envelope)
|
||||
webSocket?.send(text)
|
||||
@@ -128,7 +136,7 @@ class ConnectionManager(
|
||||
val envelope = json.decodeFromString<Envelope>(text)
|
||||
multiplexer.route(envelope)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w(TAG, "Malformed relay envelope: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
class ConnectivityObserver(private val context: Context) {
|
||||
|
||||
sealed class Status {
|
||||
data object Available : Status()
|
||||
data object Unavailable : Status()
|
||||
data object Lost : Status()
|
||||
}
|
||||
|
||||
fun observe(): Flow<Status> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(Status.Available)
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(Status.Lost)
|
||||
}
|
||||
override fun onUnavailable() {
|
||||
trySend(Status.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
// Emit current state
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isConnected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
trySend(if (isConnected) Status.Available else Status.Unavailable)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.network.models.CreateSessionRequest
|
||||
import com.hermesandroid.relay.network.models.HermesSseEvent
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.MessageListResponse
|
||||
import com.hermesandroid.relay.network.models.RenameSessionRequest
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import com.hermesandroid.relay.network.models.SessionListResponse
|
||||
import com.hermesandroid.relay.network.models.SessionResponse
|
||||
import com.hermesandroid.relay.network.models.SkillInfo
|
||||
import com.hermesandroid.relay.network.models.SkillListResponse
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
sealed interface HealthCheckResult {
|
||||
data object Healthy : HealthCheckResult
|
||||
data class Unhealthy(val message: String) : HealthCheckResult
|
||||
}
|
||||
|
||||
enum class ChatMode {
|
||||
/** Full Hermes Sessions API — /api/sessions/{id}/chat/stream */
|
||||
ENHANCED_HERMES,
|
||||
/** Only OpenAI-compatible /v1/chat/completions */
|
||||
PORTABLE,
|
||||
/** Cannot connect to the server */
|
||||
DISCONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
* Session CRUD via /api/sessions REST endpoints.
|
||||
* Chat streaming via /api/sessions/{id}/chat/stream SSE.
|
||||
* All event callbacks dispatched to the main thread for safe StateFlow updates.
|
||||
*/
|
||||
class HermesApiClient(
|
||||
baseUrl: String,
|
||||
private val apiKey: String,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
) {
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HermesApiClient"
|
||||
private val JSON_MEDIA = "application/json".toMediaType()
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.readTimeout(5, TimeUnit.MINUTES)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val sseFactory = EventSources.createFactory(client)
|
||||
|
||||
// --- Health check ---
|
||||
|
||||
suspend fun checkHealth(): Boolean = withContext(Dispatchers.IO) {
|
||||
val startMs = System.currentTimeMillis()
|
||||
try {
|
||||
val request = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val latencyMs = System.currentTimeMillis() - startMs
|
||||
val success = if (!response.isSuccessful) {
|
||||
false
|
||||
} else {
|
||||
// Validate it's actually a JSON API, not an HTML error page
|
||||
val contentType = response.header("Content-Type") ?: ""
|
||||
contentType.contains("json", ignoreCase = true) ||
|
||||
contentType.contains("text/plain", ignoreCase = true) ||
|
||||
(response.body?.string()?.trimStart()?.startsWith("{") == true)
|
||||
}
|
||||
AppAnalytics.onHealthCheck(success, latencyMs)
|
||||
success
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
val latencyMs = System.currentTimeMillis() - startMs
|
||||
AppAnalytics.onHealthCheck(false, latencyMs)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkHealthDetailed(): HealthCheckResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/health").get().build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
when {
|
||||
response.isSuccessful -> HealthCheckResult.Healthy
|
||||
response.code == 401 || response.code == 403 ->
|
||||
HealthCheckResult.Unhealthy("Unauthorized — check your API key")
|
||||
else ->
|
||||
HealthCheckResult.Unhealthy("Server returned HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: javax.net.ssl.SSLException) {
|
||||
if (baseUrl.startsWith("https://", ignoreCase = true)) {
|
||||
HealthCheckResult.Unhealthy("TLS handshake failed — try http:// if your server doesn't use HTTPS")
|
||||
} else {
|
||||
HealthCheckResult.Unhealthy("SSL error: ${e.message}")
|
||||
}
|
||||
} catch (e: java.net.ConnectException) {
|
||||
HealthCheckResult.Unhealthy("Connection refused — check the URL and port")
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
HealthCheckResult.Unhealthy("Server not found — check the hostname")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
HealthCheckResult.Unhealthy("Connection timed out — is the server running?")
|
||||
} catch (e: IOException) {
|
||||
val msg = e.message ?: ""
|
||||
when {
|
||||
msg.contains("tls", ignoreCase = true) || msg.contains("ssl", ignoreCase = true) ->
|
||||
HealthCheckResult.Unhealthy("TLS error — try http:// if your server doesn't use HTTPS")
|
||||
else -> HealthCheckResult.Unhealthy("Connection failed: $msg")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
HealthCheckResult.Unhealthy("Unexpected error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.items ?: parsed.sessions ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(title: String? = null): SessionItem? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
|
||||
val request = authRequest("$baseUrl/api/sessions")
|
||||
.post(reqBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val body = response.body?.string() ?: return@withContext null
|
||||
val parsed = json.decodeFromString<SessionResponse>(body)
|
||||
parsed.session ?: parsed.id?.let {
|
||||
SessionItem(id = it, title = parsed.title, model = parsed.model)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create session: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId")
|
||||
.delete()
|
||||
.build()
|
||||
client.newCall(request).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to delete session: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun renameSession(sessionId: String, title: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(RenameSessionRequest(title = title))
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId")
|
||||
.patch(reqBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to rename session: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMessages(sessionId: String): List<MessageItem> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/messages")
|
||||
.get()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<MessageListResponse>(body)
|
||||
parsed.items ?: parsed.messages ?: emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get messages: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
|
||||
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/skills").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
// Try structured response: { "skills": [...] } or { "items": [...] }
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items
|
||||
if (skills != null) return@withContext skills
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
// Try direct array: [...]
|
||||
try {
|
||||
return@withContext json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
* Personality config fetched from GET /api/config.
|
||||
* Names are the keys from config.agent.personalities.
|
||||
* Prompts map personality name → system prompt text.
|
||||
* Default is from config.display.personality (the server's active personality).
|
||||
*/
|
||||
data class PersonalityConfig(
|
||||
val names: List<String> = emptyList(),
|
||||
val prompts: Map<String, String> = emptyMap(),
|
||||
val defaultName: String = "",
|
||||
val modelName: String = ""
|
||||
)
|
||||
|
||||
suspend fun getPersonalities(): PersonalityConfig = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/config").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext PersonalityConfig()
|
||||
val body = response.body?.string() ?: return@withContext PersonalityConfig()
|
||||
val root = json.parseToJsonElement(body) as? JsonObject
|
||||
?: return@withContext PersonalityConfig()
|
||||
|
||||
// Model name from top-level: { "model": "claude-opus-4-6", ... }
|
||||
val modelName = (root["model"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||
|
||||
val config = root["config"] as? JsonObject
|
||||
?: return@withContext PersonalityConfig(modelName = modelName)
|
||||
|
||||
// Personalities: config.agent.personalities { name: "system prompt", ... }
|
||||
val agent = config["agent"] as? JsonObject
|
||||
val personalitiesObj = agent?.get("personalities") as? JsonObject
|
||||
val prompts = personalitiesObj?.entries?.associate { (key, value) ->
|
||||
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
|
||||
} ?: emptyMap()
|
||||
|
||||
// Default personality: config.display.personality
|
||||
val display = config["display"] as? JsonObject
|
||||
val defaultName = (display?.get("personality") as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content ?: ""
|
||||
|
||||
PersonalityConfig(
|
||||
names = prompts.keys.toList(),
|
||||
prompts = prompts,
|
||||
defaultName = defaultName,
|
||||
modelName = modelName
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch personalities: ${e.message}")
|
||||
PersonalityConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat streaming via /api/sessions/{id}/chat/stream ---
|
||||
|
||||
fun sendChatStream(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
onThinkingDelta: (String) -> Unit,
|
||||
onToolCallStart: (String, String) -> Unit,
|
||||
onToolCallDone: (String, String?) -> Unit,
|
||||
onToolCallFailed: (String, String?) -> Unit,
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
|
||||
// Notify caller of the session ID being used
|
||||
mainHandler.post { onSessionId(sessionId) }
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// Check for usage data on ANY event before type resolution
|
||||
// (OpenAI-format chunks have no type/event field but may carry usage)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
mainHandler.post { onUsage(event.usage) }
|
||||
}
|
||||
|
||||
val eventType = type ?: event.resolvedType ?: return
|
||||
|
||||
// Debug: log every SSE event type (content truncated for deltas)
|
||||
if (eventType.startsWith("assistant.delta") || eventType == "tool.progress") {
|
||||
Log.d(TAG, "SSE ← $eventType (${data.length} chars)")
|
||||
} else {
|
||||
Log.d(TAG, "SSE ← $eventType | ${data.take(300)}")
|
||||
}
|
||||
|
||||
when (eventType) {
|
||||
// --- Hermes-native events ---
|
||||
"assistant.delta" -> {
|
||||
val delta = event.delta
|
||||
if (!delta.isNullOrEmpty()) {
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
"tool.progress" -> {
|
||||
val delta = event.delta
|
||||
if (!delta.isNullOrEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(delta) }
|
||||
}
|
||||
}
|
||||
"tool.pending", "tool.started" -> {
|
||||
val toolName = event.resolvedToolName ?: "unknown"
|
||||
val callId = event.callId ?: event.toolCallId ?: toolName
|
||||
Log.d(TAG, "SSE tool start: name=$toolName callId=$callId")
|
||||
mainHandler.post { onToolCallStart(callId, toolName) }
|
||||
}
|
||||
"tool.completed" -> {
|
||||
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
|
||||
mainHandler.post { onToolCallDone(callId, event.resultPreview) }
|
||||
}
|
||||
"tool.failed" -> {
|
||||
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
|
||||
val errorMsg = event.error ?: event.messageText ?: "Tool failed"
|
||||
mainHandler.post { onToolCallFailed(callId, errorMsg) }
|
||||
}
|
||||
// message.started — server assigns a new message ID for each turn
|
||||
"message.started" -> {
|
||||
val msgObj = event.message as? JsonObject
|
||||
val serverMsgId = (msgObj?.get("id") as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (serverMsgId != null) {
|
||||
mainHandler.post { onMessageStarted(serverMsgId) }
|
||||
}
|
||||
Log.d(TAG, "SSE message.started: id=$serverMsgId")
|
||||
}
|
||||
// Informational events — acknowledged but not surfaced to UI yet
|
||||
"session.created", "run.started",
|
||||
"memory.updated", "skill.loaded", "artifact.created" -> {
|
||||
Log.d(TAG, "SSE info event: $eventType")
|
||||
}
|
||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||
"assistant.completed" -> {
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
} else {
|
||||
onTurnComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = event.messageText ?: event.error ?: "Unknown error"
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Legacy/backward-compat event names ---
|
||||
"thinking_delta", "reasoning_delta", "thinking" -> {
|
||||
val thinkingText = event.thinkingDelta ?: event.thinking ?: event.delta
|
||||
if (!thinkingText.isNullOrEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(thinkingText) }
|
||||
}
|
||||
}
|
||||
"content_delta", "delta" -> {
|
||||
val thinking = event.thinking ?: event.thinkingDelta
|
||||
if (!thinking.isNullOrEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(thinking) }
|
||||
}
|
||||
val delta = event.delta
|
||||
if (!delta.isNullOrEmpty()) {
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
"tool_start", "tool_started" -> {
|
||||
val toolName = event.toolName ?: event.name ?: "unknown"
|
||||
val callId = event.callId ?: event.toolCallId ?: toolName
|
||||
mainHandler.post { onToolCallStart(callId, toolName) }
|
||||
}
|
||||
"tool_result", "tool_completed" -> {
|
||||
val callId = event.callId ?: event.toolCallId ?: event.toolName ?: event.name ?: ""
|
||||
mainHandler.post { onToolCallDone(callId, event.resultPreview) }
|
||||
}
|
||||
"content_complete", "complete", "completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Unhandled SSE event type: $eventType | data: ${data.take(200)}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unparseable SSE event ($type): ${e.message}\nRaw: $data")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
// --- Run streaming via /v1/runs ---
|
||||
|
||||
fun sendRunStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
onThinkingDelta: (String) -> Unit,
|
||||
onToolCallStart: (String, String) -> Unit,
|
||||
onToolCallDone: (String, String?) -> Unit,
|
||||
onToolCallFailed: (String, String?) -> Unit,
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("model", model ?: "default")
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/runs")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// Check for usage data before type resolution (catches OpenAI-format chunks)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
mainHandler.post { onUsage(event.usage) }
|
||||
}
|
||||
|
||||
val eventType = type ?: event.resolvedType ?: return
|
||||
|
||||
when (eventType) {
|
||||
"response.created" -> {
|
||||
// Extract session/run ID if available
|
||||
val sid = event.sessionId ?: event.runId
|
||||
if (sid != null) {
|
||||
mainHandler.post { onSessionId(sid) }
|
||||
}
|
||||
Log.d(TAG, "Run response created")
|
||||
}
|
||||
"response.in_progress" -> {
|
||||
Log.d(TAG, "Run response in progress")
|
||||
}
|
||||
"response.output_item.added",
|
||||
"response.content_part.added" -> {
|
||||
Log.d(TAG, "Run SSE info: $eventType")
|
||||
}
|
||||
"response.output_text.delta" -> {
|
||||
val delta = event.delta
|
||||
if (!delta.isNullOrEmpty()) {
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
"response.output_text.done" -> {
|
||||
Log.d(TAG, "Run output text done")
|
||||
}
|
||||
// Tool events — Hermes /v1/runs uses "tool" field, sessions uses "tool_name"
|
||||
"tool.started", "tool.pending" -> {
|
||||
val toolName = event.resolvedToolName ?: "unknown"
|
||||
val callId = event.callId ?: event.toolCallId ?: toolName
|
||||
Log.d(TAG, "Run SSE tool start: name=$toolName callId=$callId")
|
||||
mainHandler.post { onToolCallStart(callId, toolName) }
|
||||
}
|
||||
"tool.completed" -> {
|
||||
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
|
||||
val durationStr = event.duration?.let { String.format("%.1fs", it) }
|
||||
val preview = event.resultPreview ?: durationStr
|
||||
mainHandler.post { onToolCallDone(callId, preview) }
|
||||
}
|
||||
"tool.failed" -> {
|
||||
val callId = event.callId ?: event.toolCallId ?: event.resolvedToolName ?: ""
|
||||
val errorMsg = event.error ?: event.messageText ?: "Tool failed"
|
||||
mainHandler.post { onToolCallFailed(callId, errorMsg) }
|
||||
}
|
||||
"tool.progress" -> {
|
||||
val delta = event.delta
|
||||
if (!delta.isNullOrEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(delta) }
|
||||
}
|
||||
}
|
||||
// Reasoning — /v1/runs uses "reasoning.available" with "text" field
|
||||
"reasoning.available" -> {
|
||||
val reasoningText = event.text
|
||||
if (!reasoningText.isNullOrEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(reasoningText) }
|
||||
}
|
||||
}
|
||||
// Text deltas — /v1/runs uses "message.delta", sessions uses "assistant.delta"
|
||||
"message.delta", "assistant.delta" -> {
|
||||
val delta = event.delta
|
||||
if (!delta.isNullOrEmpty()) {
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
"response.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// assistant.completed — one turn done, run may continue
|
||||
"assistant.completed" -> {
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
} else {
|
||||
onTurnComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
"error", "run.failed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = event.error ?: event.messageText ?: "Unknown error"
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
// message.started — server assigns a new message ID for each turn
|
||||
"message.started" -> {
|
||||
val msgObj = event.message as? JsonObject
|
||||
val serverMsgId = (msgObj?.get("id") as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (serverMsgId != null) {
|
||||
mainHandler.post { onMessageStarted(serverMsgId) }
|
||||
}
|
||||
Log.d(TAG, "Run SSE message.started: id=$serverMsgId")
|
||||
}
|
||||
// Informational events
|
||||
"session.created", "run.started",
|
||||
"memory.updated", "skill.loaded", "artifact.created" -> {
|
||||
Log.d(TAG, "Run SSE info event: $eventType")
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Unhandled run SSE event: $eventType | data: ${data.take(200)}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unparseable run SSE event ($type): ${e.message}\nRaw: $data")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
// --- Capability detection ---
|
||||
|
||||
/**
|
||||
* Probe the server to determine which chat API is available.
|
||||
* Checks /health, then /api/sessions (enhanced), then /v1/models (portable).
|
||||
*/
|
||||
suspend fun detectChatMode(): ChatMode = withContext(Dispatchers.IO) {
|
||||
// 1. Basic connectivity
|
||||
try {
|
||||
val healthReq = authRequest("$baseUrl/health").get().build()
|
||||
client.newCall(healthReq).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext ChatMode.DISCONNECTED
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return@withContext ChatMode.DISCONNECTED
|
||||
}
|
||||
|
||||
// 2. Try enhanced sessions API
|
||||
try {
|
||||
val sessionsReq = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
client.newCall(sessionsReq).execute().use { response ->
|
||||
if (response.isSuccessful) return@withContext ChatMode.ENHANCED_HERMES
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
|
||||
// 3. Try OpenAI-compatible models endpoint
|
||||
try {
|
||||
val modelsReq = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(modelsReq).execute().use { response ->
|
||||
if (response.isSuccessful) return@withContext ChatMode.PORTABLE
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
|
||||
// Server is reachable but neither API is available
|
||||
ChatMode.DISCONNECTED
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
fun shutdown() {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
try {
|
||||
if (!client.dispatcher.executorService.awaitTermination(2, TimeUnit.SECONDS)) {
|
||||
client.dispatcher.executorService.shutdownNow()
|
||||
}
|
||||
} catch (_: InterruptedException) {
|
||||
client.dispatcher.executorService.shutdownNow()
|
||||
}
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
private fun authRequest(url: String): Request.Builder {
|
||||
val builder = Request.Builder().url(url)
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
return builder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
package com.hermesandroid.relay.network.handlers
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* Manages chat message state, session list, and streaming events.
|
||||
*/
|
||||
class ChatHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChatHandler"
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
private const val MAX_MESSAGES = 500
|
||||
|
||||
// Tool annotation patterns embedded as text markers by Hermes.
|
||||
//
|
||||
// Hermes /v1/chat/completions injects tool progress as inline markdown:
|
||||
// `💻 terminal` — tool-type-specific emoji + tool name in backticks
|
||||
// `🔍 Python docs` — some tool names have spaces
|
||||
//
|
||||
// The emoji varies by tool type. We match ANY non-whitespace char(s) as the
|
||||
// emoji token (covers 💻🔍📝🔧⏳🔄✅✓❌✗ and future additions).
|
||||
//
|
||||
// We detect start vs. complete by tracking: first occurrence = start,
|
||||
// second occurrence of the same tool = complete.
|
||||
//
|
||||
// Additionally supports bare emoji formats: 🔧 Running: terminal
|
||||
//
|
||||
// Format 1 (primary): `<emoji> <tool_name>` — backtick-wrapped, any emoji
|
||||
private val toolAnnotationBacktickRegex = Regex(
|
||||
"""`([^\s`]+)\s+([^`]+)`"""
|
||||
)
|
||||
// Format 2 (verbose): 🔧 Running: tool_name / ✅ Completed: tool_name / ❌ Failed: tool_name
|
||||
private val toolAnnotationVerboseStartRegex = Regex(
|
||||
"""([🔧⏳🔄💻🔍📝🛠️])\s+(?:Running(?:\s*:\s*|\s+))(\w[\w\s]*)"""
|
||||
)
|
||||
private val toolAnnotationVerboseCompleteRegex = Regex(
|
||||
"""([✅✓])\s+(?:Completed(?:\s*:\s*|\s+)|Done(?:\s*:\s*|\s+))(\w[\w\s]*)"""
|
||||
)
|
||||
private val toolAnnotationVerboseFailedRegex = Regex(
|
||||
"""([❌✗])\s+(?:Failed(?:\s*:\s*|\s+)|Error(?:\s*:\s*|\s+))(\w[\w\s]*)"""
|
||||
)
|
||||
// Known completion/failure emojis — if these appear in backtick format, it's a completion
|
||||
private val completionEmojis = setOf("✅", "✓", "☑")
|
||||
private val failureEmojis = setOf("❌", "✗", "⚠")
|
||||
}
|
||||
|
||||
/** Whether to parse tool annotations from assistant text (for servers that don't emit tool events). */
|
||||
var parseToolAnnotations: Boolean = true
|
||||
|
||||
/** Active personality/agent name — set by ChatViewModel before each stream. Included on new assistant messages. */
|
||||
var activeAgentName: String? = null
|
||||
|
||||
/**
|
||||
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
||||
* newline and then scan completed lines. This handles the case where a single
|
||||
* annotation is split across multiple SSE deltas.
|
||||
*/
|
||||
private var annotationLineBuffer = StringBuilder()
|
||||
|
||||
/**
|
||||
* Tracks which tool names currently have an active (in-progress) annotation-based
|
||||
* ToolCall, keyed by "messageId:toolName" → toolCallId. This lets us match a
|
||||
* completion/failure annotation back to the correct ToolCall.
|
||||
*/
|
||||
private val activeAnnotationTools = mutableMapOf<String, String>()
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _currentSessionId = MutableStateFlow<String?>(null)
|
||||
val currentSessionId: StateFlow<String?> = _currentSessionId.asStateFlow()
|
||||
|
||||
// --- Message management ---
|
||||
|
||||
fun addUserMessage(message: ChatMessage) {
|
||||
_messages.update { list ->
|
||||
(list + message).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a placeholder assistant message immediately after the user sends,
|
||||
* showing streaming dots before the first SSE delta arrives.
|
||||
* Gets filled in naturally when onTextDelta finds the matching ID.
|
||||
*/
|
||||
fun addPlaceholderMessage(message: ChatMessage) {
|
||||
_isStreaming.value = true
|
||||
_messages.update { list ->
|
||||
(list + message).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessages() {
|
||||
_messages.value = emptyList()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load message history from API response into the messages list.
|
||||
* Replaces current messages with the loaded history.
|
||||
* Reconstructs tool calls from assistant messages' tool_calls field.
|
||||
*/
|
||||
fun loadMessageHistory(items: List<MessageItem>) {
|
||||
// Build a map of tool result messages (role:"tool") keyed by tool_call_id
|
||||
// so we can attach results back to the originating assistant message's ToolCall
|
||||
val toolResults = items.filter { it.role == "tool" }
|
||||
.associateBy { it.toolCallId }
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
"system" -> MessageRole.SYSTEM
|
||||
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.timestamp ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
|
||||
// Reconstruct tool calls from assistant messages
|
||||
val toolCalls = if (role == MessageRole.ASSISTANT) {
|
||||
parseToolCallsFromHistory(item.toolCalls, toolResults)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
id = item.id?.toString() ?: java.util.UUID.randomUUID().toString(),
|
||||
role = role,
|
||||
content = item.contentText ?: "",
|
||||
timestamp = timestampMs,
|
||||
isStreaming = false,
|
||||
toolCalls = toolCalls
|
||||
)
|
||||
}
|
||||
_messages.value = if (loaded.size > MAX_MESSAGES) loaded.takeLast(MAX_MESSAGES) else loaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the tool_calls JSON from an assistant message into ToolCall objects.
|
||||
* Format: array of objects with {id, type:"function", function: {name, arguments}}
|
||||
* or Hermes format: {name, call_id, args, ...}
|
||||
*/
|
||||
private fun parseToolCallsFromHistory(
|
||||
toolCallsJson: kotlinx.serialization.json.JsonElement?,
|
||||
toolResults: Map<String?, MessageItem>
|
||||
): List<ToolCall> {
|
||||
if (toolCallsJson == null || toolCallsJson !is JsonArray) return emptyList()
|
||||
|
||||
return toolCallsJson.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
|
||||
// Try OpenAI format: { id, type:"function", function: { name, arguments } }
|
||||
val funcObj = obj["function"] as? JsonObject
|
||||
val name: String
|
||||
val callId: String?
|
||||
val args: String?
|
||||
|
||||
if (funcObj != null) {
|
||||
name = (funcObj["name"] as? JsonPrimitive)?.content ?: return@mapNotNull null
|
||||
callId = (obj["id"] as? JsonPrimitive)?.content
|
||||
args = (funcObj["arguments"] as? JsonPrimitive)?.content
|
||||
} else {
|
||||
// Hermes format: { name, call_id, args, ... }
|
||||
name = (obj["name"] as? JsonPrimitive)?.content
|
||||
?: (obj["tool_name"] as? JsonPrimitive)?.content
|
||||
?: return@mapNotNull null
|
||||
callId = (obj["call_id"] as? JsonPrimitive)?.content
|
||||
?: (obj["id"] as? JsonPrimitive)?.content
|
||||
args = obj["args"]?.toString()
|
||||
}
|
||||
|
||||
// Check if we have a tool result for this call
|
||||
val resultItem = toolResults[callId]
|
||||
val resultText = resultItem?.contentText
|
||||
|
||||
ToolCall(
|
||||
id = callId,
|
||||
name = name,
|
||||
args = args,
|
||||
result = resultText,
|
||||
success = resultText != null, // Has result → completed
|
||||
isComplete = true // History items are always complete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session management ---
|
||||
|
||||
fun setSessionId(sessionId: String?) {
|
||||
_currentSessionId.value = sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sessions list from API response.
|
||||
*/
|
||||
fun updateSessions(items: List<SessionItem>) {
|
||||
_sessions.value = items.map { item ->
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.startedAt ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
updatedAt = timestampMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session from the local list (optimistic delete).
|
||||
*/
|
||||
fun removeSession(sessionId: String) {
|
||||
_sessions.update { sessions ->
|
||||
sessions.filter { it.sessionId != sessionId }
|
||||
}
|
||||
if (_currentSessionId.value == sessionId) {
|
||||
_currentSessionId.value = null
|
||||
clearMessages()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a session's title in the local list (optimistic rename).
|
||||
*/
|
||||
fun renameSessionLocal(sessionId: String, newTitle: String) {
|
||||
_sessions.update { sessions ->
|
||||
sessions.map { s ->
|
||||
if (s.sessionId == sessionId) s.copy(title = newTitle) else s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a newly created session to the list.
|
||||
*/
|
||||
fun addSession(session: ChatSession) {
|
||||
_sessions.update { listOf(session) + it }
|
||||
}
|
||||
|
||||
// --- SSE streaming event entry points ---
|
||||
|
||||
/**
|
||||
* Tracks whether we are currently inside a `<think>`/`<thinking>` block
|
||||
* in the text stream. Content inside these tags is redirected to
|
||||
* thinkingContent instead of the main message content.
|
||||
*/
|
||||
private var insideThinkingBlock = false
|
||||
|
||||
fun onTextDelta(messageId: String, delta: String) {
|
||||
_isStreaming.value = true
|
||||
|
||||
// Check for inline reasoning tags — some servers embed thinking in the text stream
|
||||
val processedDelta = processInlineReasoning(messageId, delta)
|
||||
|
||||
// If all content was redirected to thinking, nothing left for main content
|
||||
if (processedDelta.isEmpty()) return
|
||||
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(content = msg.content + processedDelta)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = processedDelta,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for tool annotations in the text stream
|
||||
if (parseToolAnnotations) {
|
||||
scanForToolAnnotations(messageId, processedDelta)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and extract inline `<think>`/`<thinking>` blocks from the text stream.
|
||||
* Content inside these tags is redirected to onThinkingDelta.
|
||||
* Returns the remaining non-thinking content.
|
||||
*/
|
||||
private fun processInlineReasoning(messageId: String, delta: String): String {
|
||||
// Fast path: no tags in delta and not inside a block
|
||||
if (!insideThinkingBlock && !delta.contains("<think", ignoreCase = true)) {
|
||||
return delta
|
||||
}
|
||||
|
||||
val result = StringBuilder()
|
||||
var remaining = delta
|
||||
|
||||
while (remaining.isNotEmpty()) {
|
||||
if (insideThinkingBlock) {
|
||||
// Look for closing tag
|
||||
val closeIdx = remaining.indexOfClose()
|
||||
if (closeIdx != -1) {
|
||||
// Extract thinking content before the close tag
|
||||
val thinkingPart = remaining.substring(0, closeIdx)
|
||||
if (thinkingPart.isNotEmpty()) {
|
||||
onThinkingDelta(messageId, thinkingPart)
|
||||
}
|
||||
// Skip past the closing tag
|
||||
val tagEnd = remaining.indexOf('>', closeIdx) + 1
|
||||
remaining = if (tagEnd > 0) remaining.substring(tagEnd) else ""
|
||||
insideThinkingBlock = false
|
||||
} else {
|
||||
// Entire remaining is thinking content
|
||||
onThinkingDelta(messageId, remaining)
|
||||
remaining = ""
|
||||
}
|
||||
} else {
|
||||
// Look for opening tag
|
||||
val openIdx = remaining.indexOfOpen()
|
||||
if (openIdx != -1) {
|
||||
// Content before the tag is regular text
|
||||
val beforeTag = remaining.substring(0, openIdx)
|
||||
result.append(beforeTag)
|
||||
// Skip past the opening tag
|
||||
val tagEnd = remaining.indexOf('>', openIdx) + 1
|
||||
remaining = if (tagEnd > 0) remaining.substring(tagEnd) else ""
|
||||
insideThinkingBlock = true
|
||||
} else {
|
||||
// No tags — all regular content
|
||||
result.append(remaining)
|
||||
remaining = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/** Find index of `</think>` or `</thinking>` closing tag. */
|
||||
private fun String.indexOfClose(): Int {
|
||||
val i1 = indexOf("</think>", ignoreCase = true)
|
||||
val i2 = indexOf("</thinking>", ignoreCase = true)
|
||||
return when {
|
||||
i1 == -1 -> i2
|
||||
i2 == -1 -> i1
|
||||
else -> minOf(i1, i2)
|
||||
}
|
||||
}
|
||||
|
||||
/** Find index of `<think>` or `<thinking>` opening tag. */
|
||||
private fun String.indexOfOpen(): Int {
|
||||
val i1 = indexOf("<think>", ignoreCase = true)
|
||||
val i2 = indexOf("<thinking>", ignoreCase = true)
|
||||
return when {
|
||||
i1 == -1 -> i2
|
||||
i2 == -1 -> i1
|
||||
else -> minOf(i1, i2)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool annotation parsing ---
|
||||
|
||||
/**
|
||||
* Accumulate incoming text in a line buffer and scan completed lines for
|
||||
* tool annotation patterns. Annotations are line-oriented, so we only
|
||||
* attempt matching once we have a full line (terminated by newline).
|
||||
*
|
||||
* If the stream completes with a partial line still in the buffer, it is
|
||||
* flushed in [onStreamComplete].
|
||||
*/
|
||||
private fun scanForToolAnnotations(messageId: String, delta: String) {
|
||||
annotationLineBuffer.append(delta)
|
||||
|
||||
// Process all complete lines (newline-terminated)
|
||||
while (true) {
|
||||
val newlineIndex = annotationLineBuffer.indexOf('\n')
|
||||
if (newlineIndex == -1) break
|
||||
|
||||
val line = annotationLineBuffer.substring(0, newlineIndex)
|
||||
annotationLineBuffer.delete(0, newlineIndex + 1)
|
||||
|
||||
val trimmed = line.trim()
|
||||
if (parseAnnotationLine(messageId, trimmed)) {
|
||||
// Matched — strip this annotation line from the message content
|
||||
stripLineFromContent(messageId, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a matched annotation line from the message's displayed content.
|
||||
* This prevents the raw annotation text (e.g., `💻 terminal`) from showing
|
||||
* in the chat bubble alongside the ToolCall card.
|
||||
*/
|
||||
private fun stripLineFromContent(messageId: String, line: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
// Remove the line (with surrounding newlines) from content
|
||||
val cleaned = msg.content
|
||||
.replace("\n$line\n", "\n")
|
||||
.replace("\n$line", "")
|
||||
.replace("$line\n", "")
|
||||
.replace(line, "")
|
||||
.trim()
|
||||
msg.copy(content = cleaned)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single completed line for a tool annotation pattern and
|
||||
* dispatch the appropriate tool call event.
|
||||
*
|
||||
* Hermes injects tool progress as backtick-wrapped inline markdown:
|
||||
* `💻 terminal` — first occurrence = tool start
|
||||
* `💻 terminal` — second occurrence of same tool = tool complete
|
||||
* `✅ terminal` — explicit completion emoji
|
||||
* `❌ terminal` — explicit failure emoji
|
||||
*
|
||||
* Also supports verbose format:
|
||||
* 🔧 Running: terminal
|
||||
* ✅ Completed: terminal
|
||||
* ❌ Failed: terminal
|
||||
*/
|
||||
/**
|
||||
* Returns true if the line matched an annotation pattern (caller should strip it from content).
|
||||
*/
|
||||
private fun parseAnnotationLine(messageId: String, line: String): Boolean {
|
||||
if (line.isEmpty()) return false
|
||||
|
||||
// --- Format 1: Backtick-wrapped `<emoji> <tool_name>` ---
|
||||
toolAnnotationBacktickRegex.find(line)?.let { match ->
|
||||
val emojiToken = match.groupValues[1]
|
||||
val toolName = match.groupValues[2].trim()
|
||||
if (toolName.isEmpty()) return false
|
||||
|
||||
val key = "$messageId:$toolName"
|
||||
|
||||
when {
|
||||
// Explicit failure emoji
|
||||
failureEmojis.any { emojiToken.contains(it) } -> {
|
||||
val toolCallId = activeAnnotationTools.remove(key)
|
||||
if (toolCallId != null) {
|
||||
onToolCallFailed(messageId, toolCallId, null)
|
||||
Log.d(TAG, "Annotation tool failed (backtick): $toolName")
|
||||
}
|
||||
}
|
||||
// Explicit completion emoji
|
||||
completionEmojis.any { emojiToken.contains(it) } -> {
|
||||
val toolCallId = activeAnnotationTools.remove(key)
|
||||
if (toolCallId != null) {
|
||||
onToolCallComplete(messageId, toolCallId, null)
|
||||
Log.d(TAG, "Annotation tool complete (backtick): $toolName")
|
||||
}
|
||||
}
|
||||
// Tool-type emoji — first occurrence = start, second = complete
|
||||
activeAnnotationTools.containsKey(key) -> {
|
||||
val toolCallId = activeAnnotationTools.remove(key)
|
||||
if (toolCallId != null) {
|
||||
onToolCallComplete(messageId, toolCallId, null)
|
||||
Log.d(TAG, "Annotation tool complete (repeat): $toolName")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val toolCallId = "annotation-${toolName.replace(" ", "_")}-${System.currentTimeMillis()}"
|
||||
activeAnnotationTools[key] = toolCallId
|
||||
onToolCallStart(messageId, toolCallId, toolName)
|
||||
Log.d(TAG, "Annotation tool start (backtick): $toolName [$emojiToken]")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- Format 2: Verbose bare emoji ---
|
||||
toolAnnotationVerboseStartRegex.find(line)?.let { match ->
|
||||
val toolName = match.groupValues[2].trim()
|
||||
val toolCallId = "annotation-${toolName.replace(" ", "_")}-${System.currentTimeMillis()}"
|
||||
activeAnnotationTools["$messageId:$toolName"] = toolCallId
|
||||
onToolCallStart(messageId, toolCallId, toolName)
|
||||
Log.d(TAG, "Annotation tool start (verbose): $toolName")
|
||||
return true
|
||||
}
|
||||
|
||||
toolAnnotationVerboseCompleteRegex.find(line)?.let { match ->
|
||||
val toolName = match.groupValues[2].trim()
|
||||
val key = "$messageId:$toolName"
|
||||
val toolCallId = activeAnnotationTools.remove(key) ?: return false
|
||||
onToolCallComplete(messageId, toolCallId, null)
|
||||
Log.d(TAG, "Annotation tool complete (verbose): $toolName")
|
||||
return true
|
||||
}
|
||||
|
||||
toolAnnotationVerboseFailedRegex.find(line)?.let { match ->
|
||||
val toolName = match.groupValues[2].trim()
|
||||
val key = "$messageId:$toolName"
|
||||
val toolCallId = activeAnnotationTools.remove(key) ?: return false
|
||||
onToolCallFailed(messageId, toolCallId, null)
|
||||
Log.d(TAG, "Annotation tool failed (verbose): $toolName")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any remaining partial line in the annotation buffer.
|
||||
* Called when the stream ends so we don't miss annotations that
|
||||
* arrived without a trailing newline.
|
||||
*/
|
||||
private fun flushAnnotationBuffer(messageId: String) {
|
||||
if (annotationLineBuffer.isNotEmpty()) {
|
||||
val remaining = annotationLineBuffer.toString().trim()
|
||||
annotationLineBuffer.clear()
|
||||
if (parseAnnotationLine(messageId, remaining)) {
|
||||
stripLineFromContent(messageId, remaining)
|
||||
}
|
||||
}
|
||||
// Clean up any active annotation tools for this message that never completed
|
||||
val keysToRemove = activeAnnotationTools.keys.filter { it.startsWith("$messageId:") }
|
||||
keysToRemove.forEach { activeAnnotationTools.remove(it) }
|
||||
}
|
||||
|
||||
fun onToolCallStart(messageId: String, toolCallId: String, toolName: String) {
|
||||
_isStreaming.value = true
|
||||
|
||||
val toolCall = ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
)
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(toolCalls = msg.toolCalls + toolCall)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(toolCall),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallComplete(messageId: String, toolCallId: String, resultPreview: String? = null) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
success = true,
|
||||
isComplete = true,
|
||||
result = resultPreview ?: call.result,
|
||||
completedAt = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
msg.copy(toolCalls = updatedCalls)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallFailed(messageId: String, toolCallId: String, error: String?) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
success = false,
|
||||
isComplete = true,
|
||||
error = error,
|
||||
completedAt = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
msg.copy(toolCalls = updatedCalls)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single assistant turn completed, but the agent run may continue
|
||||
* (e.g., tool calls pending → next assistant turn). Marks the current
|
||||
* message as no longer streaming but keeps the global isStreaming flag
|
||||
* active so the UI continues showing progress.
|
||||
*/
|
||||
fun onTurnComplete(messageId: String) {
|
||||
// Flush annotations for this turn
|
||||
if (parseToolAnnotations) {
|
||||
flushAnnotationBuffer(messageId)
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: do NOT set _isStreaming to false — the run is still active
|
||||
}
|
||||
|
||||
/**
|
||||
* The entire agent run is complete (run.completed / done).
|
||||
* Marks the stream as finished and finalizes all messages.
|
||||
*/
|
||||
fun onStreamComplete(messageId: String) {
|
||||
_isStreaming.value = false
|
||||
insideThinkingBlock = false
|
||||
|
||||
// Flush any remaining annotation text that didn't end with a newline
|
||||
if (parseToolAnnotations) {
|
||||
flushAnnotationBuffer(messageId)
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
_isStreaming.value = false
|
||||
_error.value = message
|
||||
// Clear streaming flag on any actively streaming message
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.isStreaming || msg.isThinkingStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
_isStreaming.value = true
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(
|
||||
thinkingContent = msg.thinkingContent + delta,
|
||||
isThinkingStreaming = true
|
||||
)
|
||||
} else msg
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
thinkingContent = delta,
|
||||
isThinkingStreaming = true,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onUsageReceived(messageId: String, inputTokens: Int?, outputTokens: Int?, totalTokens: Int?, cost: Double?) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
totalTokens = totalTokens,
|
||||
estimatedCost = cost
|
||||
)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Retry support ---
|
||||
|
||||
private val _lastSentMessage = MutableStateFlow<String?>(null)
|
||||
val lastSentMessage: StateFlow<String?> = _lastSentMessage.asStateFlow()
|
||||
|
||||
fun setLastSentMessage(text: String) {
|
||||
_lastSentMessage.value = text
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hermesandroid.companion.network.models
|
||||
package com.hermesandroid.relay.network.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -0,0 +1,303 @@
|
||||
package com.hermesandroid.relay.network.models
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Models for the Hermes /api/sessions REST API.
|
||||
* These are Hermes-native format, not OpenAI-compatible.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serializer that accepts both string and integer IDs from the server,
|
||||
* normalizing them to String. Hermes returns int IDs for messages but
|
||||
* string IDs for sessions.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
object FlexibleIdSerializer : KSerializer<String?> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("FlexibleId", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): String? {
|
||||
return try {
|
||||
val jsonDecoder = decoder as? JsonDecoder
|
||||
?: return decoder.decodeString()
|
||||
val element = jsonDecoder.decodeJsonElement()
|
||||
when {
|
||||
element is JsonNull -> null
|
||||
element is JsonPrimitive -> element.content
|
||||
else -> element.toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: String?) {
|
||||
if (value != null) encoder.encodeString(value) else encoder.encodeNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-null variant — returns empty string instead of null. Safe for primary key fields. */
|
||||
object FlexibleIdNonNullSerializer : KSerializer<String> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("FlexibleIdNonNull", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): String {
|
||||
return try {
|
||||
val jsonDecoder = decoder as? JsonDecoder
|
||||
?: return decoder.decodeString()
|
||||
val element = jsonDecoder.decodeJsonElement()
|
||||
when {
|
||||
element is JsonNull -> ""
|
||||
element is JsonPrimitive -> element.content
|
||||
else -> element.toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: String) {
|
||||
encoder.encodeString(value)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD responses ---
|
||||
|
||||
@Serializable
|
||||
data class SessionListResponse(
|
||||
val items: List<SessionItem>? = null,
|
||||
val sessions: List<SessionItem>? = null, // alternate key
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionResponse(
|
||||
val session: SessionItem? = null,
|
||||
// Flat session fields for when server returns at top level
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val id: String? = null,
|
||||
val title: String? = null,
|
||||
val model: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionItem(
|
||||
@Serializable(with = FlexibleIdNonNullSerializer::class)
|
||||
val id: String = "",
|
||||
val title: String? = null,
|
||||
val model: String? = null,
|
||||
val source: String? = null,
|
||||
@SerialName("started_at") val startedAt: Double? = null,
|
||||
@SerialName("ended_at") val endedAt: Double? = null,
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
val title: String? = null,
|
||||
val model: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RenameSessionRequest(
|
||||
val title: String
|
||||
)
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
@Serializable
|
||||
data class MessageListResponse(
|
||||
val items: List<MessageItem>? = null,
|
||||
val messages: List<MessageItem>? = null, // alternate key
|
||||
val total: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageItem(
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val id: String? = null,
|
||||
@SerialName("session_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val sessionId: String? = null,
|
||||
val role: String,
|
||||
val content: JsonElement? = null,
|
||||
@SerialName("tool_calls") val toolCalls: JsonElement? = null,
|
||||
@SerialName("tool_name") val toolName: String? = null,
|
||||
@SerialName("tool_call_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null
|
||||
) {
|
||||
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
|
||||
val contentText: String?
|
||||
get() = when (content) {
|
||||
is JsonPrimitive -> content.content
|
||||
is JsonArray -> content.jsonArray
|
||||
.filterIsInstance<JsonObject>()
|
||||
.filter { (it["type"] as? JsonPrimitive)?.content == "text" }
|
||||
.mapNotNull { (it["text"] as? JsonPrimitive)?.content }
|
||||
.joinToString("")
|
||||
.ifEmpty { null }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Extract image URLs from OpenAI-format content arrays. */
|
||||
val imageUrls: List<String>
|
||||
get() = when (content) {
|
||||
is JsonArray -> content.jsonArray
|
||||
.filterIsInstance<JsonObject>()
|
||||
.filter { (it["type"] as? JsonPrimitive)?.content == "image_url" }
|
||||
.mapNotNull { block ->
|
||||
val imageUrl = block["image_url"] as? JsonObject
|
||||
(imageUrl?.get("url") as? JsonPrimitive)?.content
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE streaming events from /api/sessions/{id}/chat/stream ---
|
||||
//
|
||||
// Hermes WebAPI event types (from server source):
|
||||
// session.created — { session_id, run_id, title? }
|
||||
// run.started — { session_id, run_id, user_message: { id, role, content } }
|
||||
// message.started — { session_id, run_id, message: { id, role } }
|
||||
// assistant.delta — { session_id, run_id, message_id, delta }
|
||||
// tool.progress — { session_id, run_id, message_id, delta } (thinking/reasoning)
|
||||
// tool.pending — { session_id, run_id, tool_name, call_id }
|
||||
// tool.started — { session_id, run_id, tool_name, call_id, preview?, args }
|
||||
// tool.completed — { session_id, run_id, tool_call_id, tool_name, args, result_preview }
|
||||
// tool.failed — { session_id, run_id, call_id, tool_name, error }
|
||||
// assistant.completed — { session_id, run_id, message_id, content, completed, partial, interrupted }
|
||||
// run.completed — { session_id, run_id, message_id, completed, partial, interrupted, api_calls? }
|
||||
// error — { message (string), error }
|
||||
// done — { session_id, run_id, state: "final" }
|
||||
|
||||
@Serializable
|
||||
data class HermesSseEvent(
|
||||
// Event type — may come as "type" or "event" depending on server version
|
||||
val type: String? = null,
|
||||
val event: String? = null,
|
||||
// Shared envelope fields
|
||||
@SerialName("session_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val sessionId: String? = null,
|
||||
@SerialName("run_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val runId: String? = null,
|
||||
@SerialName("message_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val messageId: String? = null,
|
||||
val seq: Int? = null,
|
||||
val ts: Double? = null,
|
||||
val timestamp: Double? = null,
|
||||
// assistant.delta / tool.progress / message.delta
|
||||
val delta: String? = null,
|
||||
// tool fields — different servers use different names
|
||||
val name: String? = null,
|
||||
val tool: String? = null, // /v1/runs format: "tool":"terminal"
|
||||
@SerialName("tool_name") val toolName: String? = null,
|
||||
val preview: String? = null,
|
||||
val args: JsonObject? = null,
|
||||
@SerialName("result_preview") val resultPreview: String? = null,
|
||||
val duration: Double? = null, // /v1/runs tool.completed duration in seconds
|
||||
val success: Boolean? = null,
|
||||
@SerialName("call_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val callId: String? = null,
|
||||
@SerialName("tool_call_id")
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val toolCallId: String? = null,
|
||||
// assistant.completed / run.completed
|
||||
val content: String? = null,
|
||||
val output: String? = null, // /v1/runs run.completed final text
|
||||
@SerialName("final_response") val finalResponse: String? = null,
|
||||
val completed: Boolean? = null,
|
||||
val partial: Boolean? = null,
|
||||
val interrupted: Boolean? = null,
|
||||
@SerialName("api_calls") val apiCalls: Int? = null,
|
||||
// session.created
|
||||
val title: String? = null,
|
||||
// run.started — user_message is an object
|
||||
@SerialName("user_message") val userMessage: JsonObject? = null,
|
||||
// message.started / error — message can be String or Object
|
||||
val message: JsonElement? = null,
|
||||
val error: String? = null,
|
||||
// done event
|
||||
val state: String? = null,
|
||||
// Reasoning fields — multiple possible names across server versions
|
||||
val thinking: String? = null,
|
||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||
val text: String? = null, // /v1/runs reasoning.available text
|
||||
// Usage/token fields (on assistant.completed / run.completed)
|
||||
val usage: UsageInfo? = null
|
||||
) {
|
||||
/** Resolve the event type from whichever field is populated. */
|
||||
val resolvedType: String?
|
||||
get() = type ?: event
|
||||
|
||||
/** Resolve tool name from whichever field the server uses. */
|
||||
val resolvedToolName: String?
|
||||
get() = toolName ?: tool ?: name
|
||||
|
||||
/** Extract message as string (returns null if message is an object, not a string). */
|
||||
val messageText: String?
|
||||
get() = (message as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UsageInfo(
|
||||
// Hermes naming
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("total_tokens") val totalTokens: Int? = null,
|
||||
// OpenAI naming (fallback)
|
||||
@SerialName("prompt_tokens") val promptTokens: Int? = null,
|
||||
@SerialName("completion_tokens") val completionTokens: Int? = null,
|
||||
// Cache tokens
|
||||
@SerialName("cache_creation_input_tokens") val cacheCreationInputTokens: Int? = null,
|
||||
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null
|
||||
) {
|
||||
/** Resolved input tokens — prefers Hermes naming, falls back to OpenAI. */
|
||||
val resolvedInputTokens: Int? get() = inputTokens ?: promptTokens
|
||||
/** Resolved output tokens — prefers Hermes naming, falls back to OpenAI. */
|
||||
val resolvedOutputTokens: Int? get() = outputTokens ?: completionTokens
|
||||
/** Resolved total tokens. */
|
||||
val resolvedTotalTokens: Int? get() = totalTokens
|
||||
?: if (resolvedInputTokens != null || resolvedOutputTokens != null)
|
||||
(resolvedInputTokens ?: 0) + (resolvedOutputTokens ?: 0)
|
||||
else null
|
||||
}
|
||||
|
||||
// --- Skills API ---
|
||||
|
||||
@Serializable
|
||||
data class SkillInfo(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val category: String? = null,
|
||||
@SerialName("usage") val usage: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SkillListResponse(
|
||||
val skills: List<SkillInfo>? = null,
|
||||
val items: List<SkillInfo>? = null
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.hermesandroid.relay.ui.components.WhatsNewDialog
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
|
||||
import com.hermesandroid.relay.ui.screens.BridgeScreen
|
||||
import com.hermesandroid.relay.ui.screens.ChatScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
data object Onboarding : Screen("onboarding", "Onboarding", Icons.Filled.Settings)
|
||||
data object Chat : Screen("chat", "Chat", Icons.Filled.Chat)
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
}
|
||||
|
||||
private val bottomNavScreens = listOf(
|
||||
Screen.Chat,
|
||||
Screen.Terminal,
|
||||
Screen.Bridge,
|
||||
Screen.Settings
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val connectionViewModel: ConnectionViewModel = viewModel()
|
||||
val chatViewModel: ChatViewModel = viewModel()
|
||||
|
||||
// Initialize ChatViewModel reactively when API client becomes available
|
||||
val apiClient by connectionViewModel.apiClient.collectAsState()
|
||||
val lastSessionId by connectionViewModel.lastSessionId.collectAsState()
|
||||
var sessionResumed by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(apiClient) {
|
||||
apiClient?.let { client ->
|
||||
chatViewModel.initialize(client, connectionViewModel.chatHandler)
|
||||
chatViewModel.updateApiClient(client)
|
||||
|
||||
// Wire session persistence callback
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
connectionViewModel.saveLastSessionId(sessionId)
|
||||
}
|
||||
|
||||
// Resume last session on first connection
|
||||
if (!sessionResumed && lastSessionId != null) {
|
||||
chatViewModel.resumeSession(lastSessionId!!)
|
||||
sessionResumed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync app context toggle from settings to chat
|
||||
val appContextEnabled by connectionViewModel.appContextEnabled.collectAsState()
|
||||
LaunchedEffect(appContextEnabled) {
|
||||
chatViewModel.appContextEnabled = appContextEnabled
|
||||
}
|
||||
|
||||
// Sync tool annotation parsing toggle to ChatHandler
|
||||
val parseAnnotations by connectionViewModel.parseToolAnnotations.collectAsState()
|
||||
LaunchedEffect(parseAnnotations) {
|
||||
connectionViewModel.chatHandler.parseToolAnnotations = parseAnnotations
|
||||
}
|
||||
|
||||
// Sync streaming endpoint preference to chat
|
||||
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
|
||||
LaunchedEffect(streamingEndpoint) {
|
||||
chatViewModel.streamingEndpoint = streamingEndpoint
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
|
||||
if (showWhatsNew) {
|
||||
WhatsNewDialog(onDismiss = { connectionViewModel.dismissWhatsNew() })
|
||||
}
|
||||
|
||||
// Mark version as seen on first launch (when there's no previous version)
|
||||
val onboardingCompleted by connectionViewModel.onboardingCompleted.collectAsState()
|
||||
LaunchedEffect(onboardingCompleted) {
|
||||
if (onboardingCompleted) {
|
||||
connectionViewModel.markVersionSeen()
|
||||
}
|
||||
}
|
||||
|
||||
// Observe theme preference
|
||||
val themePreference by connectionViewModel.theme.collectAsState()
|
||||
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
val startDestination = if (onboardingCompleted) Screen.Chat.route else Screen.Onboarding.route
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val isOnboarding = navBackStackEntry?.destination?.route == Screen.Onboarding.route
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0),
|
||||
bottomBar = {
|
||||
if (!isOnboarding) {
|
||||
NavigationBar(
|
||||
containerColor = if (isDarkTheme) {
|
||||
Color(0xFF1A1A2E).copy(alpha = 0.9f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
) {
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
bottomNavScreens.forEach { screen ->
|
||||
val isSelected = currentDestination?.hierarchy?.any {
|
||||
it.route == screen.route
|
||||
} == true
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Box(
|
||||
modifier = if (isSelected && isDarkTheme) {
|
||||
Modifier.purpleGlow(
|
||||
radius = 18.dp,
|
||||
alpha = 0.4f,
|
||||
isDarkTheme = true
|
||||
)
|
||||
} else Modifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = screen.icon,
|
||||
contentDescription = screen.label
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(screen.label) },
|
||||
selected = isSelected,
|
||||
colors = if (isDarkTheme) {
|
||||
NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
NavigationBarItemDefaults.colors()
|
||||
},
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
OnboardingScreen(
|
||||
onComplete = { apiServerUrl, apiKey, relayUrl ->
|
||||
connectionViewModel.updateApiServerUrl(apiServerUrl)
|
||||
if (apiKey.isNotBlank()) {
|
||||
connectionViewModel.updateApiKey(apiKey)
|
||||
}
|
||||
if (relayUrl.isNotBlank()) {
|
||||
connectionViewModel.updateRelayUrl(relayUrl)
|
||||
}
|
||||
connectionViewModel.completeOnboarding()
|
||||
navController.navigate(Screen.Chat.route) {
|
||||
popUpTo(Screen.Onboarding.route) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.Chat.route) {
|
||||
// Responsive bubble width based on screen width
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthDp = configuration.screenWidthDp.dp
|
||||
val maxBubbleWidth = when {
|
||||
screenWidthDp >= 840.dp -> 600.dp // Expanded (tablet)
|
||||
screenWidthDp >= 600.dp -> 480.dp // Medium (landscape / small tablet)
|
||||
else -> 300.dp // Compact (phone portrait)
|
||||
}
|
||||
|
||||
ChatScreen(
|
||||
chatViewModel = chatViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
maxBubbleWidth = maxBubbleWidth
|
||||
)
|
||||
}
|
||||
composable(Screen.Terminal.route) {
|
||||
TerminalScreen()
|
||||
}
|
||||
composable(Screen.Bridge.route) {
|
||||
BridgeScreen()
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(connectionViewModel = connectionViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowOverflow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A slash command entry — built-in, personality, or server skill.
|
||||
*/
|
||||
data class SlashCommand(
|
||||
val command: String,
|
||||
val description: String,
|
||||
val category: String = "built-in"
|
||||
)
|
||||
|
||||
/**
|
||||
* Full-screen command palette as a bottom sheet.
|
||||
* Shows all available commands grouped by category with search.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun CommandPalette(
|
||||
commands: List<SlashCommand>,
|
||||
onSelect: (SlashCommand) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var selectedCategory by remember { mutableStateOf<String?>(null) }
|
||||
var categoriesExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
// Get unique categories in a logical order
|
||||
val categories = remember(commands) {
|
||||
val priorityOrder = listOf("session", "configuration", "info", "personality")
|
||||
commands.map { it.category }.distinct().sortedWith(
|
||||
compareBy<String> {
|
||||
val idx = priorityOrder.indexOf(it)
|
||||
if (idx >= 0) idx else priorityOrder.size
|
||||
}.thenBy { it }
|
||||
)
|
||||
}
|
||||
|
||||
// Filter commands by search + category
|
||||
val filtered = remember(commands, searchQuery, selectedCategory) {
|
||||
commands.filter { cmd ->
|
||||
val matchesSearch = searchQuery.isBlank() ||
|
||||
cmd.command.contains(searchQuery, ignoreCase = true) ||
|
||||
cmd.description.contains(searchQuery, ignoreCase = true)
|
||||
val matchesCategory = selectedCategory == null || cmd.category == selectedCategory
|
||||
matchesSearch && matchesCategory
|
||||
}
|
||||
}
|
||||
|
||||
// Group filtered commands by category with priority ordering
|
||||
val grouped = remember(filtered, categories) {
|
||||
val order = categories.withIndex().associate { (i, v) -> v to i }
|
||||
filtered.groupBy { it.category }
|
||||
.toSortedMap(compareBy { order[it] ?: Int.MAX_VALUE })
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 400.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Commands",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "${filtered.size} available",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
placeholder = { Text("Search commands...") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Clear search",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Category filter chips with expand/collapse
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
maxLines = if (categoriesExpanded) Int.MAX_VALUE else 2,
|
||||
overflow = FlowRowOverflow.Clip
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedCategory == null,
|
||||
onClick = { selectedCategory = null },
|
||||
label = { Text("All") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
categories.forEach { category ->
|
||||
val count = commands.count { it.category == category }
|
||||
FilterChip(
|
||||
selected = selectedCategory == category,
|
||||
onClick = {
|
||||
selectedCategory = if (selectedCategory == category) null else category
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = "${formatCategoryName(category)} ($count)",
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (categories.size > 6) {
|
||||
TextButton(
|
||||
onClick = { categoriesExpanded = !categoriesExpanded },
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (categoriesExpanded) "Show less"
|
||||
else "Show all (${categories.size})",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
|
||||
// Command list grouped by category
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
grouped.forEach { (category, cmds) ->
|
||||
// Category header
|
||||
item(key = "header_$category") {
|
||||
Text(
|
||||
text = formatCategoryName(category),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp, end = 16.dp,
|
||||
top = 12.dp, bottom = 4.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = cmds,
|
||||
key = { "${it.category}:${it.command}" }
|
||||
) { cmd ->
|
||||
CommandRow(
|
||||
command = cmd,
|
||||
onClick = { onSelect(cmd) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (filtered.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No commands match your search",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single command row used in both the palette and the inline autocomplete.
|
||||
*/
|
||||
@Composable
|
||||
fun CommandRow(
|
||||
command: SlashCommand,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = if (compact) 8.dp else 10.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = command.command,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (!compact && command.category != "built-in" && command.category != "personality") {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Text(
|
||||
text = command.category,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = command.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (compact) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline autocomplete popup for quick command entry.
|
||||
* Shows filtered results as user types "/" in chat input.
|
||||
*/
|
||||
@Composable
|
||||
fun InlineAutocomplete(
|
||||
commands: List<SlashCommand>,
|
||||
onSelect: (SlashCommand) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shadowElevation = 4.dp,
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 280.dp)
|
||||
) {
|
||||
items(
|
||||
items = commands,
|
||||
key = { "${it.category}:${it.command}" }
|
||||
) { cmd ->
|
||||
CommandRow(
|
||||
command = cmd,
|
||||
onClick = { onSelect(cmd) },
|
||||
compact = true
|
||||
)
|
||||
if (cmd != commands.last()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Format category name: "software-development" → "Software Development" */
|
||||
private fun formatCategoryName(category: String): String {
|
||||
return category.split("-").joinToString(" ") { word ->
|
||||
word.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
|
||||
@Composable
|
||||
fun CompactToolCall(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
else -> "running"
|
||||
}
|
||||
|
||||
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
|
||||
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
|
||||
String.format("%.1fs", seconds)
|
||||
} else null
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.semantics {
|
||||
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Tool type icon
|
||||
Icon(
|
||||
imageVector = toolIcon(toolCall.name),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Tool name
|
||||
Text(
|
||||
text = toolCall.name,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
// Status indicator
|
||||
when {
|
||||
!toolCall.isComplete -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(10.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
toolCall.success == true -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Completed",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Failed",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Duration
|
||||
if (duration != null) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = duration,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Animated connection status indicator — a colored dot with an optional pulsing ring.
|
||||
*
|
||||
* - **Connected (green):** solid dot + slow heartbeat pulse (1.5 s)
|
||||
* - **Connecting / Reconnecting (amber):** solid dot + faster pulse (0.8 s)
|
||||
* - **Disconnected (red):** solid dot, no pulse
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionStatusBadge(
|
||||
isConnected: Boolean,
|
||||
isConnecting: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 12.dp
|
||||
) {
|
||||
val dotColor: Color
|
||||
val showPulse: Boolean
|
||||
val pulseDurationMs: Int
|
||||
val statusLabel: String
|
||||
|
||||
when {
|
||||
isConnected -> {
|
||||
dotColor = Color(0xFF4CAF50) // Material green 500
|
||||
showPulse = true
|
||||
pulseDurationMs = 1500
|
||||
statusLabel = "Connected"
|
||||
}
|
||||
isConnecting -> {
|
||||
dotColor = Color(0xFFFFA726) // Material amber/orange 400
|
||||
showPulse = true
|
||||
pulseDurationMs = 800
|
||||
statusLabel = "Connecting"
|
||||
}
|
||||
else -> {
|
||||
dotColor = MaterialTheme.colorScheme.error
|
||||
showPulse = false
|
||||
pulseDurationMs = 1500 // unused but required for val init
|
||||
statusLabel = "Disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
// Pulse animation — only runs when showPulse is true
|
||||
val pulseScale: Float
|
||||
val pulseAlpha: Float
|
||||
|
||||
if (showPulse) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
pulseScale = infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.8f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = pulseDurationMs, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "pulseScale"
|
||||
).value
|
||||
pulseAlpha = infiniteTransition.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = pulseDurationMs, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "pulseAlpha"
|
||||
).value
|
||||
} else {
|
||||
pulseScale = 1f
|
||||
pulseAlpha = 0f
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.semantics { contentDescription = statusLabel }
|
||||
.drawBehind {
|
||||
// Pulse ring (expanding, fading circle outline)
|
||||
if (showPulse && pulseAlpha > 0f) {
|
||||
val ringRadius = (this.size.minDimension / 2f) * pulseScale
|
||||
drawCircle(
|
||||
color = dotColor.copy(alpha = pulseAlpha),
|
||||
radius = ringRadius,
|
||||
style = Stroke(width = 2.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
// Solid inner dot
|
||||
drawCircle(color = dotColor)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row showing [ConnectionStatusBadge] alongside a text label and optional status text / test button.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionStatusRow(
|
||||
label: String,
|
||||
isConnected: Boolean,
|
||||
isConnecting: Boolean = false,
|
||||
statusText: String,
|
||||
onTest: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = isConnected,
|
||||
isConnecting = isConnecting
|
||||
)
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = when {
|
||||
isConnected -> Color(0xFF4CAF50)
|
||||
isConnecting -> Color(0xFFFFA726)
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
)
|
||||
|
||||
if (onTest != null) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
OutlinedButton(onClick = onTest) {
|
||||
Text("Test")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
||||
import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans
|
||||
import com.mikepenz.markdown.compose.extendedspans.RoundedCornerSpanPainter
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun MarkdownContent(
|
||||
content: String,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
|
||||
Markdown(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
colors = markdownColor(
|
||||
text = textColor,
|
||||
codeText = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
|
||||
linkText = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
typography = markdownTypography(
|
||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownCodeBlock(it.content, it.node) { code, language ->
|
||||
CodeBlockWithCopyButton(code, language, highlightsBuilder)
|
||||
}
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownCodeFence(it.content, it.node) { code, language ->
|
||||
CodeBlockWithCopyButton(code, language, highlightsBuilder)
|
||||
}
|
||||
}
|
||||
),
|
||||
extendedSpans = markdownExtendedSpans {
|
||||
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlockWithCopyButton(
|
||||
code: String,
|
||||
language: String?,
|
||||
highlightsBuilder: Highlights.Builder
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(2000)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
MarkdownHighlightedCode(code, language, highlightsBuilder)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(code))
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copied = true
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = if (copied) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: ChatMessage,
|
||||
modifier: Modifier = Modifier,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
showThinking: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {}
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
|
||||
val backgroundColor = when (message.role) {
|
||||
MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.surfaceVariant
|
||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
}
|
||||
|
||||
val textColor = when (message.role) {
|
||||
MessageRole.USER -> MaterialTheme.colorScheme.onPrimary
|
||||
MessageRole.ASSISTANT -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
MessageRole.SYSTEM -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
}
|
||||
|
||||
// Grouped bubble shapes — flat edges where consecutive messages meet
|
||||
val topStart = if (isUser) { if (isFirstInGroup) 16.dp else 16.dp } else { if (isFirstInGroup) 16.dp else 4.dp }
|
||||
val topEnd = if (isUser) { if (isFirstInGroup) 16.dp else 4.dp } else { if (isFirstInGroup) 16.dp else 16.dp }
|
||||
val bottomStart = if (isUser) 16.dp else 4.dp // tail side always small
|
||||
val bottomEnd = if (isUser) 4.dp else 16.dp // tail side always small
|
||||
|
||||
val bubbleShape = when (message.role) {
|
||||
MessageRole.USER -> RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
MessageRole.ASSISTANT -> RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
MessageRole.SYSTEM -> RoundedCornerShape(12.dp)
|
||||
}
|
||||
|
||||
val alignment = if (isUser) Alignment.End else Alignment.Start
|
||||
val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
val a11yDescription = "${message.role.name.lowercase()} message: ${message.content.take(100)}"
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
// Agent name label (above assistant bubbles, only first in group)
|
||||
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
Text(
|
||||
text = message.agentName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 2.dp, start = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Thinking block (above the bubble, only for assistant messages)
|
||||
if (!isUser && showThinking && message.thinkingContent.isNotEmpty()) {
|
||||
ThinkingBlock(
|
||||
thinkingContent = message.thinkingContent,
|
||||
isStreaming = message.isThinkingStreaming,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Message bubble
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
Modifier.leftEdgeGlow(
|
||||
alpha = 0.12f,
|
||||
width = 28.dp,
|
||||
isDarkTheme = true
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { onCopyMessage(message.content) }
|
||||
)
|
||||
.semantics { contentDescription = a11yDescription }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
SelectionContainer {
|
||||
if (isUser || isSystem) {
|
||||
// Plain text for user and system messages
|
||||
Text(
|
||||
text = message.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Markdown for assistant messages
|
||||
if (message.content.isNotEmpty()) {
|
||||
MarkdownContent(
|
||||
content = message.content,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
message.attachments.forEach { attachment ->
|
||||
if (attachment.isImage) {
|
||||
val imageBitmap = remember(attachment.content) {
|
||||
try {
|
||||
val bytes = android.util.Base64.decode(attachment.content, android.util.Base64.DEFAULT)
|
||||
android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?.asImageBitmap()
|
||||
} catch (_: Exception) { null as androidx.compose.ui.graphics.ImageBitmap? }
|
||||
}
|
||||
if (imageBitmap != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = attachment.fileName,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth - 24.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.padding(vertical = 2.dp),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83D\uDCC4",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = attachment.fileName ?: attachment.contentType,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator
|
||||
if (message.isStreaming) {
|
||||
StreamingDots(
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.5f)
|
||||
)
|
||||
|
||||
// Token display (assistant messages only)
|
||||
if (!isUser && (message.inputTokens != null || message.outputTokens != null)) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
TokenDisplay(
|
||||
inputTokens = message.inputTokens,
|
||||
outputTokens = message.outputTokens
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three dots that animate opacity in sequence to indicate streaming is in progress.
|
||||
*/
|
||||
@Composable
|
||||
fun StreamingDots(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "streaming")
|
||||
|
||||
val dot1Alpha by transition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 600),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "dot1"
|
||||
)
|
||||
val dot2Alpha by transition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 600, delayMillis = 200),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "dot2"
|
||||
)
|
||||
val dot3Alpha by transition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 600, delayMillis = 400),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "dot3"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "\u2022",
|
||||
fontSize = 14.sp,
|
||||
color = color.copy(alpha = dot1Alpha)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
Text(
|
||||
text = "\u2022",
|
||||
fontSize = 14.sp,
|
||||
color = color.copy(alpha = dot2Alpha)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(3.dp))
|
||||
Text(
|
||||
text = "\u2022",
|
||||
fontSize = 14.sp,
|
||||
color = color.copy(alpha = dot3Alpha)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* ASCII art sphere inspired by the AMP Code CLI.
|
||||
*
|
||||
* Renders rows of monospace characters (`. : - = + * # % @`) arranged
|
||||
* in a sphere shape. Characters cycle organically over time and the
|
||||
* sphere boundary subtly morphs. Pure Compose Canvas — no OpenGL.
|
||||
*/
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val transition = rememberInfiniteTransition(label = "sphere")
|
||||
val time by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1_000_000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "time"
|
||||
)
|
||||
|
||||
// Slow color pulse: green → purple → green
|
||||
val colorPhase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 6.2832f, // 2π
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 8000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "colorPulse"
|
||||
)
|
||||
|
||||
val density = " .:-=+*#%@"
|
||||
// Monospace chars are ~1.8x taller than wide. Use more columns to compensate
|
||||
// so the sphere appears circular, not egg-shaped.
|
||||
val cols = 52
|
||||
val rows = 30
|
||||
|
||||
val paint = remember {
|
||||
Paint().apply {
|
||||
typeface = Typeface.MONOSPACE
|
||||
isAntiAlias = true
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
val canvasW = size.width
|
||||
val canvasH = size.height
|
||||
|
||||
// Size each character cell to fit the grid centered in the canvas
|
||||
val cellW = canvasW / cols
|
||||
val cellH = canvasH / rows
|
||||
// Size characters to fill cells tightly — less gap between rows
|
||||
val charSize = (cellW * 0.95f).coerceAtMost(cellH * 0.85f)
|
||||
paint.textSize = charSize
|
||||
|
||||
// Sphere is centered in the grid
|
||||
val cx = cols / 2f
|
||||
val cy = rows / 2f
|
||||
|
||||
// Character cell aspect ratio (width / height) — monospace chars are tall
|
||||
val charAspect = cellW / cellH
|
||||
|
||||
// Radius in row-units (vertical). The sphere is measured in rows,
|
||||
// and column-extent is scaled by charAspect to make it circular on screen.
|
||||
val baseRadius = (rows / 2f) * 0.85f
|
||||
|
||||
// Color pulse: interpolate green → purple
|
||||
val pulse = (sin(colorPhase) * 0.5f + 0.5f) // 0..1
|
||||
val r = lerp(0.25f, 0.61f, pulse) // green→purple R
|
||||
val g = lerp(0.85f, 0.42f, pulse) // green→purple G
|
||||
val b = lerp(0.40f, 0.94f, pulse) // green→purple B
|
||||
|
||||
val t = time
|
||||
|
||||
for (row in 0 until rows) {
|
||||
val ny = (row - cy) / baseRadius // normalized Y: -1..1
|
||||
|
||||
// Sphere radius at this Y slice (circle equation)
|
||||
val sliceRadiusSq = 1f - ny * ny
|
||||
if (sliceRadiusSq <= 0f) continue
|
||||
val sliceRadius = sqrt(sliceRadiusSq)
|
||||
|
||||
// Subtle boundary morph per row
|
||||
val morph = 1f + 0.03f * sin(t * 0.5f + row * 0.4f) +
|
||||
0.02f * sin(t * 0.3f + row * 0.7f)
|
||||
|
||||
// Slice radius in column-units, corrected for character aspect ratio
|
||||
val sliceCols = sliceRadius * baseRadius * morph / charAspect
|
||||
|
||||
for (col in 0 until cols) {
|
||||
val dx = col - cx // distance from center in columns
|
||||
if (kotlin.math.abs(dx) > sliceCols) continue
|
||||
|
||||
// Normalized X on sphere surface (corrected for aspect)
|
||||
val nxNorm = dx * charAspect / baseRadius
|
||||
// Approximate Z from sphere surface: z = sqrt(1 - x² - y²)
|
||||
val zSq = 1f - nxNorm * nxNorm - ny * ny
|
||||
val nz = if (zSq > 0f) sqrt(zSq) else 0f
|
||||
|
||||
// Simple diffuse lighting (light from upper-right-front)
|
||||
val lightDot = (nxNorm * 0.3f + ny * (-0.4f) + nz * 0.86f)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
// Map brightness to ASCII density (boosted for denser fill)
|
||||
val brightness = lightDot * 0.6f + 0.3f // higher ambient = denser characters
|
||||
|
||||
// Time-based character cycling: shift the character selection
|
||||
val charNoise = sin(t * 0.4f + col * 1.3f + row * 0.9f) * 0.12f +
|
||||
sin(t * 0.25f + col * 0.7f - row * 1.1f) * 0.08f
|
||||
val charIndex = ((brightness + charNoise) * (density.length - 1))
|
||||
.toInt().coerceIn(0, density.length - 1)
|
||||
|
||||
val ch = density[charIndex]
|
||||
if (ch == ' ') continue
|
||||
|
||||
// Position on canvas
|
||||
val px = col * cellW
|
||||
val py = row * cellH + cellH * 0.8f // baseline offset
|
||||
|
||||
// Brightness-based alpha: brighter chars more opaque
|
||||
val alpha = (brightness * 0.6f + 0.35f).coerceIn(0.3f, 0.95f)
|
||||
|
||||
// Edge fade: only the very outermost characters fade slightly
|
||||
val edgeDist = kotlin.math.abs(dx) / sliceCols
|
||||
val edgeFade = if (edgeDist > 0.92f) 0.5f + 0.5f * (1f - (edgeDist - 0.92f) / 0.08f) else 1f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * edgeFade * 255).toInt().coerceIn(0, 255),
|
||||
(r * 255).toInt(),
|
||||
(g * 255).toInt(),
|
||||
(b * 255).toInt()
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
ch.toString(), px, py, paint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.text.style.TextOverflow
|
||||
|
||||
/**
|
||||
* Personality picker — shows server-configured personalities from GET /api/config.
|
||||
* "Default" uses the server's active personality (config.display.personality).
|
||||
* Other entries are from config.agent.personalities.
|
||||
*/
|
||||
@Composable
|
||||
fun PersonalityPicker(
|
||||
selected: String,
|
||||
personalities: List<String>,
|
||||
defaultName: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val displayName = if (selected == "default") {
|
||||
if (defaultName.isNotBlank()) {
|
||||
defaultName.replaceFirstChar { it.uppercase() }
|
||||
} else "Default"
|
||||
} else {
|
||||
selected.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
TextButton(onClick = { expanded = true }) {
|
||||
Text(
|
||||
text = displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Select personality"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
// Default (server's active personality)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = if (defaultName.isNotBlank()) {
|
||||
"${defaultName.replaceFirstChar { it.uppercase() }} (default)"
|
||||
} else "Default",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onSelect("default")
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
|
||||
if (personalities.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
|
||||
personalities.filter { it != defaultName }.forEach { personality ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = personality.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onSelect(personality)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Parsed result from a Hermes pairing QR code.
|
||||
*
|
||||
* QR payload format:
|
||||
* ```json
|
||||
* {"hermes":1,"host":"172.16.24.250","port":8642,"key":"bearer-token","tls":false}
|
||||
* ```
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesPairingPayload(
|
||||
val hermes: Int,
|
||||
val host: String,
|
||||
val port: Int = 8642,
|
||||
val key: String = "",
|
||||
val tls: Boolean = false
|
||||
) {
|
||||
/** Build the full server URL from host, port, and tls flag. */
|
||||
val serverUrl: String
|
||||
get() = "${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Try to parse a scanned string as a Hermes pairing QR payload.
|
||||
* Returns null if it's not a valid Hermes QR (no "hermes":1 field).
|
||||
*/
|
||||
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
// Quick check: must contain "hermes" key with value 1
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
val version = obj["hermes"]?.jsonPrimitive?.int ?: return null
|
||||
if (version != 1) return null
|
||||
json.decodeFromString<HermesPairingPayload>(raw)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen QR code scanner overlay.
|
||||
* Detects Hermes pairing QR codes and calls [onPairingDetected] with the parsed payload.
|
||||
*/
|
||||
@Composable
|
||||
fun QrPairingScanner(
|
||||
onPairingDetected: (HermesPairingPayload) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// AtomicBoolean for thread-safe detection flag (accessed from camera executor thread)
|
||||
val hasDetected = remember { AtomicBoolean(false) }
|
||||
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||
|
||||
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
cameraProviderRef.value?.unbindAll()
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.95f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Header bar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.CenterStart)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close scanner",
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Scan Hermes QR",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Camera preview
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(280.dp)
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
cameraProviderRef.value = cameraProvider
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = previewView.surfaceProvider
|
||||
}
|
||||
|
||||
val barcodeScanner = BarcodeScanning.getClient()
|
||||
|
||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { analysis ->
|
||||
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage != null && !hasDetected.get()) {
|
||||
val inputImage = InputImage.fromMediaImage(
|
||||
mediaImage,
|
||||
imageProxy.imageInfo.rotationDegrees
|
||||
)
|
||||
barcodeScanner.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
if (barcode.valueType == Barcode.TYPE_TEXT ||
|
||||
barcode.valueType == Barcode.TYPE_UNKNOWN
|
||||
) {
|
||||
val rawValue = barcode.rawValue ?: continue
|
||||
val payload = parseHermesPairingQr(rawValue)
|
||||
if (payload != null && hasDetected.compareAndSet(false, true)) {
|
||||
onPairingDetected(payload)
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener {
|
||||
imageProxy.close()
|
||||
}
|
||||
} else {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("QrPairingScanner", "Camera bind failed", e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
|
||||
previewView
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Instructions
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Point at a Hermes pairing QR code",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Generate one on your server with: hermes-pair",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
currentSessionId: String?,
|
||||
onNewChat: () -> Unit,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onRenameSession: (String, String) -> Unit
|
||||
) {
|
||||
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
|
||||
ModalDrawerSheet(modifier = Modifier.width(300.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header
|
||||
Text(
|
||||
text = "Sessions",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// New Chat button
|
||||
Button(
|
||||
onClick = onNewChat,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("New Chat")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "No sessions yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Start a conversation to see it here",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(sessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
isActive = session.sessionId == currentSessionId,
|
||||
onClick = { onSelectSession(session.sessionId) },
|
||||
onRename = { renameDialogSession = session },
|
||||
onDelete = { deleteDialogSession = session }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename dialog
|
||||
renameDialogSession?.let { session ->
|
||||
var newTitle by remember(session) { mutableStateOf(session.title ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renameDialogSession = null },
|
||||
title = { Text("Rename Session") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = newTitle,
|
||||
onValueChange = { newTitle = it },
|
||||
label = { Text("Title") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (newTitle.isNotBlank()) {
|
||||
onRenameSession(session.sessionId, newTitle)
|
||||
}
|
||||
renameDialogSession = null
|
||||
}) {
|
||||
Text("Rename")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { renameDialogSession = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
deleteDialogSession?.let { session ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteDialogSession = null },
|
||||
title = { Text("Delete Session?") },
|
||||
text = {
|
||||
Text("This will permanently delete \"${session.title ?: "Untitled"}\" and its message history.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
onDeleteSession(session.sessionId)
|
||||
deleteDialogSession = null
|
||||
}) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { deleteDialogSession = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionItem(
|
||||
session: ChatSession,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = session.title ?: "Untitled",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (isActive) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (session.updatedAt > 0) {
|
||||
Text(
|
||||
text = formatTimestamp(session.updatedAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (session.messageCount > 0) {
|
||||
Text(
|
||||
text = "${session.messageCount} msgs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRename, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = "Rename",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.padding(0.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
return when {
|
||||
diff < 60_000 -> "Just now"
|
||||
diff < 3_600_000 -> "${diff / 60_000}m ago"
|
||||
diff < 86_400_000 -> SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis))
|
||||
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(Date(millis))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.data.AppStats
|
||||
|
||||
// Brand gradient colors
|
||||
private val GradientStart = Color(0xFF9B6BF0)
|
||||
private val GradientEnd = Color(0xFF6B35E8)
|
||||
|
||||
@Composable
|
||||
fun StatsForNerds() {
|
||||
val appStats by AppAnalytics.stats.collectAsState()
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
var showResetDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Header row — tap to expand/collapse
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Analytics",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Row {
|
||||
if (expanded) {
|
||||
TextButton(onClick = { showResetDialog = true }) {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { expanded = !expanded }) {
|
||||
Text(if (expanded) "Collapse" else "Expand")
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary line always visible
|
||||
val totalTokens = appStats.totalTokensIn + appStats.totalTokensOut
|
||||
val tokensPerMsg = if (appStats.totalMessagesSent > 0)
|
||||
totalTokens / appStats.totalMessagesSent else 0L
|
||||
Text(
|
||||
text = "${appStats.totalMessagesSent} messages | " +
|
||||
"${formatTokenCount(totalTokens)} tokens" +
|
||||
(if (tokensPerMsg > 0) " (~${formatTokenCount(tokensPerMsg)}/msg)" else "") +
|
||||
" | ${appStats.sessionCount} sessions",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
// -- Response Time Chart (TTFT) --
|
||||
if (appStats.recentResponseTimesMs.isNotEmpty()) {
|
||||
ChartSection(
|
||||
title = "Time to First Token",
|
||||
data = appStats.recentResponseTimesMs,
|
||||
unit = "ms"
|
||||
)
|
||||
}
|
||||
|
||||
// -- Completion Time Chart --
|
||||
if (appStats.recentCompletionTimesMs.isNotEmpty()) {
|
||||
ChartSection(
|
||||
title = "Completion Time",
|
||||
data = appStats.recentCompletionTimesMs,
|
||||
unit = "ms"
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// -- Token Usage --
|
||||
TokenUsageSection(appStats)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// -- Connection Health --
|
||||
ConnectionHealthSection(appStats)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// -- Stream Stats --
|
||||
StreamStatsSection(appStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset confirmation dialog
|
||||
if (showResetDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text("Reset Analytics?") },
|
||||
text = { Text("This will clear all recorded stats including token counts, response times, and stream history. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
AppAnalytics.resetAll()
|
||||
showResetDialog = false
|
||||
}) {
|
||||
Text("Reset", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chart ---
|
||||
|
||||
@Composable
|
||||
private fun ChartSection(
|
||||
title: String,
|
||||
data: List<Long>,
|
||||
unit: String
|
||||
) {
|
||||
val minVal = data.minOrNull() ?: 0L
|
||||
val maxVal = data.maxOrNull() ?: 0L
|
||||
val avgVal = if (data.isNotEmpty()) data.sum() / data.size else 0L
|
||||
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Min / Avg / Max labels
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
StatLabel("Min", formatMsWithSeconds(minVal))
|
||||
StatLabel("Avg", formatMsWithSeconds(avgVal))
|
||||
StatLabel("Max", formatMsWithSeconds(maxVal))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Bar chart drawn with Canvas
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp)
|
||||
) {
|
||||
val barCount = data.size
|
||||
if (barCount == 0) return@Canvas
|
||||
|
||||
val chartMax = maxVal.coerceAtLeast(1L)
|
||||
val barSpacing = 2.dp.toPx()
|
||||
val totalSpacing = barSpacing * (barCount - 1).coerceAtLeast(0)
|
||||
val barWidth = ((size.width - totalSpacing) / barCount).coerceAtLeast(2f)
|
||||
val bottomPadding = 16.dp.toPx()
|
||||
val chartHeight = size.height - bottomPadding
|
||||
|
||||
val gradient = Brush.verticalGradient(
|
||||
colors = listOf(GradientStart, GradientEnd),
|
||||
startY = 0f,
|
||||
endY = chartHeight
|
||||
)
|
||||
|
||||
// Draw bars
|
||||
data.forEachIndexed { index, value ->
|
||||
val barHeight = (value.toFloat() / chartMax) * chartHeight
|
||||
val x = index * (barWidth + barSpacing)
|
||||
val y = chartHeight - barHeight
|
||||
|
||||
drawRect(
|
||||
brush = gradient,
|
||||
topLeft = Offset(x, y),
|
||||
size = Size(barWidth, barHeight)
|
||||
)
|
||||
}
|
||||
|
||||
// Draw average line
|
||||
val avgY = chartHeight - (avgVal.toFloat() / chartMax) * chartHeight
|
||||
drawLine(
|
||||
color = GradientStart.copy(alpha = 0.5f),
|
||||
start = Offset(0f, avgY),
|
||||
end = Offset(size.width, avgY),
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(
|
||||
floatArrayOf(8f, 6f), 0f
|
||||
)
|
||||
)
|
||||
|
||||
// X-axis label area
|
||||
val textPaint = android.graphics.Paint().apply {
|
||||
color = labelColor.hashCode()
|
||||
textSize = 9.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
// Draw a few index labels (first, middle, last)
|
||||
val indicesToLabel = when {
|
||||
barCount <= 5 -> data.indices.toList()
|
||||
else -> listOf(0, barCount / 2, barCount - 1)
|
||||
}
|
||||
indicesToLabel.forEach { idx ->
|
||||
val x = idx * (barWidth + barSpacing) + barWidth / 2
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
"${idx + 1}",
|
||||
x,
|
||||
size.height,
|
||||
textPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatLabel(label: String, value: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Token Usage ---
|
||||
|
||||
@Composable
|
||||
private fun TokenUsageSection(stats: AppStats) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Token Usage",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
// Current session
|
||||
Text(
|
||||
text = "Current Session",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TokenStat("In", formatTokenCount(stats.currentSessionTokensIn))
|
||||
TokenStat("Out", formatTokenCount(stats.currentSessionTokensOut))
|
||||
TokenStat("Total", formatTokenCount(stats.currentSessionTokensIn + stats.currentSessionTokensOut))
|
||||
}
|
||||
|
||||
// Lifetime
|
||||
Text(
|
||||
text = "Lifetime",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TokenStat("In", formatTokenCount(stats.totalTokensIn))
|
||||
TokenStat("Out", formatTokenCount(stats.totalTokensOut))
|
||||
TokenStat("Total", formatTokenCount(stats.totalTokensIn + stats.totalTokensOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TokenStat(label: String, value: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Connection Health ---
|
||||
|
||||
@Composable
|
||||
private fun ConnectionHealthSection(stats: AppStats) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Connection Health",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
if (stats.healthChecksTotal == 0) {
|
||||
Text(
|
||||
text = "No health checks recorded yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
// Success rate bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Success rate",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${(stats.healthCheckSuccessRate * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = when {
|
||||
stats.healthCheckSuccessRate >= 0.9f -> MaterialTheme.colorScheme.primary
|
||||
stats.healthCheckSuccessRate >= 0.5f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { stats.healthCheckSuccessRate },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
// Avg latency
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Avg latency",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = formatMsWithSeconds(stats.avgHealthLatencyMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
// Total checks
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Total checks",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${stats.healthChecksTotal}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stream Stats ---
|
||||
|
||||
@Composable
|
||||
private fun StreamStatsSection(stats: AppStats) {
|
||||
val totalStreams = stats.streamsCompleted + stats.streamsErrored + stats.streamsCancelled
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Stream Stats",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
if (totalStreams == 0) {
|
||||
Text(
|
||||
text = "No streams recorded yet",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
// Success rate bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Success rate",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${(stats.streamSuccessRate * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = when {
|
||||
stats.streamSuccessRate >= 0.9f -> MaterialTheme.colorScheme.primary
|
||||
stats.streamSuccessRate >= 0.5f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { stats.streamSuccessRate },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Breakdown
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
StreamCounter("Completed", stats.streamsCompleted, MaterialTheme.colorScheme.primary)
|
||||
StreamCounter("Errored", stats.streamsErrored, MaterialTheme.colorScheme.error)
|
||||
StreamCounter("Cancelled", stats.streamsCancelled, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
// Avg times
|
||||
if (stats.avgResponseTimeMs > 0) {
|
||||
val peakTtft = stats.recentResponseTimesMs.maxOrNull() ?: 0L
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Avg TTFT",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = formatMsWithSeconds(stats.avgResponseTimeMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
if (peakTtft > stats.avgResponseTimeMs) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Peak TTFT",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = formatMsWithSeconds(peakTtft),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stats.avgCompletionTimeMs > 0) {
|
||||
val worstCompletion = stats.recentCompletionTimesMs.maxOrNull() ?: 0L
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Avg completion",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = formatMsWithSeconds(stats.avgCompletionTimeMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
if (worstCompletion > stats.avgCompletionTimeMs) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Slowest",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = formatMsWithSeconds(worstCompletion),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamCounter(label: String, count: Int, color: Color) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "$count",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = color
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Formatting helpers ---
|
||||
|
||||
private fun formatTokenCount(tokens: Long): String = when {
|
||||
tokens >= 1_000_000 -> "${tokens / 1_000_000}.${(tokens % 1_000_000) / 100_000}M"
|
||||
tokens >= 1_000 -> "${tokens / 1_000}.${(tokens % 1_000) / 100}k"
|
||||
else -> "$tokens"
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Long): String = when {
|
||||
ms >= 60_000 -> "${ms / 60_000}m ${(ms % 60_000) / 1_000}s"
|
||||
ms >= 1_000 -> "${ms / 1_000}.${(ms % 1_000) / 100}s"
|
||||
else -> "${ms}ms"
|
||||
}
|
||||
|
||||
/** Format ms with seconds subtext: "1234ms (1.2s)" */
|
||||
private fun formatMsWithSeconds(ms: Long): String = when {
|
||||
ms >= 1_000 -> "${ms}ms (${"%.1f".format(ms / 1000.0)}s)"
|
||||
else -> "${ms}ms"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
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.material.icons.filled.Psychology
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ThinkingBlock(
|
||||
thinkingContent: String,
|
||||
isStreaming: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
// Header row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Psychology,
|
||||
contentDescription = "Thinking",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isStreaming) "Thinking..." else "Thought process",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible content
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
Text(
|
||||
text = thinkingContent,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Default,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
maxLines = if (isStreaming) Int.MAX_VALUE else 50,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TokenDisplay(
|
||||
inputTokens: Int?,
|
||||
outputTokens: Int?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (inputTokens == null && outputTokens == null) return
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
if (inputTokens != null) {
|
||||
parts.add("↑${formatTokens(inputTokens)}")
|
||||
}
|
||||
if (outputTokens != null) {
|
||||
parts.add("↓${formatTokens(outputTokens)}")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = parts.joinToString(" ") + " tokens",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTokens(count: Int): String {
|
||||
return when {
|
||||
count >= 1_000_000 -> "${String.format("%.1f", count / 1_000_000.0)}M"
|
||||
count >= 1_000 -> "${String.format("%.1f", count / 1_000.0)}K"
|
||||
else -> count.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TouchApp
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
|
||||
@Composable
|
||||
fun ToolProgressCard(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(!toolCall.isComplete) }
|
||||
|
||||
// Auto-collapse when tool completes
|
||||
LaunchedEffect(toolCall.isComplete) {
|
||||
if (toolCall.isComplete) expanded = false
|
||||
}
|
||||
|
||||
val statusIcon: ImageVector
|
||||
val statusColor = when {
|
||||
toolCall.isComplete && toolCall.success == true -> {
|
||||
statusIcon = Icons.Filled.Check
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
toolCall.isComplete && toolCall.success == false -> {
|
||||
statusIcon = Icons.Filled.Close
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
else -> {
|
||||
statusIcon = Icons.Filled.HourglassTop
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
val toolIcon = toolIcon(toolCall.name)
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
else -> "running"
|
||||
}
|
||||
|
||||
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
|
||||
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
|
||||
String.format("%.1fs", seconds)
|
||||
} else null
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Tool ${toolCall.name} $statusText${duration?.let { " in $it" } ?: ""}"
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
// Header row
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Tool type icon
|
||||
Icon(
|
||||
imageVector = toolIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
// Tool name
|
||||
Text(
|
||||
text = toolCall.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Duration
|
||||
if (duration != null) {
|
||||
Text(
|
||||
text = duration,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
// Status icon
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = statusText,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
// Expand/collapse
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar while running
|
||||
if (!toolCall.isComplete) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
|
||||
// Expandable details
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
// Arguments
|
||||
toolCall.args?.let { args ->
|
||||
Text(
|
||||
text = "Arguments:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = args,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Error (tool.failed)
|
||||
toolCall.error?.let { error ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Error:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Result
|
||||
toolCall.result?.let { result ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Result:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = result,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toolIcon(toolName: String): ImageVector = when {
|
||||
toolName.contains("screenshot") || toolName.contains("read_screen") || toolName.contains("vision") -> Icons.Filled.Description
|
||||
toolName.contains("tap") || toolName.contains("swipe") || toolName.contains("click") || toolName.contains("scroll") -> Icons.Filled.TouchApp
|
||||
toolName.contains("type") || toolName.contains("input") || toolName.contains("keyboard") -> Icons.Filled.Keyboard
|
||||
toolName.contains("launch") || toolName.contains("open") -> Icons.Filled.OpenInNew
|
||||
toolName.contains("bash") || toolName.contains("terminal") || toolName.contains("execute") || toolName.contains("shell") -> Icons.Filled.Code
|
||||
toolName.contains("file") || toolName.contains("read") || toolName.contains("write") -> Icons.Filled.Description
|
||||
toolName.contains("search") || toolName.contains("web") -> Icons.Filled.Search
|
||||
else -> Icons.Filled.Build
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun WhatsNewDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val changelogText = remember { loadWhatsNew(context) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("What's New") },
|
||||
text = {
|
||||
Text(
|
||||
text = changelogText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Default
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Got it")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadWhatsNew(context: Context): String {
|
||||
return try {
|
||||
context.assets.open("whats_new.txt").bufferedReader().readText()
|
||||
} catch (_: Exception) {
|
||||
"No release notes available."
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hermesandroid.companion.ui.onboarding
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,7 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
@Composable
|
||||
fun OnboardingPage(
|
||||
@@ -71,7 +71,7 @@ fun OnboardingPage(
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun OnboardingPagePreview() {
|
||||
HermesCompanionTheme {
|
||||
HermesRelayTheme {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Chat,
|
||||
title = "Talk to Your Agent",
|
||||
@@ -83,11 +83,11 @@ private fun OnboardingPagePreview() {
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun OnboardingPageWithContentPreview() {
|
||||
HermesCompanionTheme {
|
||||
HermesRelayTheme {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Chat,
|
||||
title = "Let's Connect",
|
||||
description = "Enter your companion relay server URL to get started."
|
||||
description = "Enter your relay server URL to get started."
|
||||
) {
|
||||
Text(
|
||||
text = "Custom content slot",
|
||||
@@ -0,0 +1,681 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.Dns
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material.icons.outlined.Hub
|
||||
import androidx.compose.material.icons.outlined.PhonelinkSetup
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.QrPairingScanner
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private sealed interface TestResult {
|
||||
data object Success : TestResult
|
||||
data class Failure(val message: String) : TestResult
|
||||
}
|
||||
|
||||
/** Page identifiers for dynamic onboarding flow. */
|
||||
private enum class OnboardingPage { Welcome, Chat, Terminal, Bridge, Connect, Relay }
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onComplete: (apiServerUrl: String, apiKey: String, relayUrl: String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Build page list dynamically based on feature flags
|
||||
val pages = remember(relayEnabled) {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
if (relayEnabled) {
|
||||
add(OnboardingPage.Terminal)
|
||||
add(OnboardingPage.Bridge)
|
||||
}
|
||||
add(OnboardingPage.Connect)
|
||||
if (relayEnabled) {
|
||||
add(OnboardingPage.Relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
val pageCount = pages.size
|
||||
val lastPage = pageCount - 1
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var apiServerUrl by rememberSaveable { mutableStateOf("http://localhost:8642") }
|
||||
var apiKey by rememberSaveable { mutableStateOf("") }
|
||||
var relayUrl by rememberSaveable { mutableStateOf("wss://localhost:8767") }
|
||||
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
if (showSkipConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSkipConfirm = false },
|
||||
title = { Text("Skip setup?") },
|
||||
text = {
|
||||
Text("You can configure your server connection later in Settings. Without an API key, your connection will not be authenticated.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showSkipConfirm = false
|
||||
onComplete(apiServerUrl, apiKey, relayUrl)
|
||||
}) {
|
||||
Text("Skip anyway")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showSkipConfirm = false }) {
|
||||
Text("Go back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Top bar with Skip
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Skip button - always visible
|
||||
TextButton(onClick = { showSkipConfirm = true }) {
|
||||
Text(
|
||||
text = "Skip",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pager content
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { pageIndex ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (pages[pageIndex]) {
|
||||
OnboardingPage.Welcome -> WelcomePage()
|
||||
OnboardingPage.Chat -> ChatPage()
|
||||
OnboardingPage.Terminal -> TerminalPage()
|
||||
OnboardingPage.Bridge -> BridgePage()
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
apiServerUrl = apiServerUrl,
|
||||
onApiServerUrlChange = { apiServerUrl = it },
|
||||
apiKey = apiKey,
|
||||
onApiKeyChange = { apiKey = it }
|
||||
)
|
||||
OnboardingPage.Relay -> RelayPage(
|
||||
relayUrl = relayUrl,
|
||||
onRelayUrlChange = { relayUrl = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom section: page indicator + navigation buttons
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Page indicator
|
||||
PageIndicator(
|
||||
pageCount = pageCount,
|
||||
currentPage = pagerState.currentPage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Navigation buttons row: Back (left) + Next/Get Started (right)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Back button — only visible when not on first page
|
||||
AnimatedVisibility(
|
||||
visible = pagerState.currentPage > 0,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage - 1)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(text = "Back")
|
||||
}
|
||||
}
|
||||
|
||||
// Invisible spacer when Back is hidden so Next/Get Started stays right-aligned
|
||||
if (pagerState.currentPage == 0) {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
}
|
||||
|
||||
// Next / Get Started button
|
||||
if (pagerState.currentPage < lastPage) {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(text = "Next")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { onComplete(apiServerUrl, apiKey, relayUrl) },
|
||||
enabled = apiServerUrl.isNotBlank()
|
||||
) {
|
||||
Text(text = "Get Started")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomePage() {
|
||||
val context = LocalContext.current
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
title = "Hermes Relay",
|
||||
description = "Your Hermes agent, in your pocket."
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "hermes-agent.nousresearch.com",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com")))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Forum,
|
||||
title = "Chat",
|
||||
description = "Talk to any Hermes agent profile with real-time streaming responses, tool progress, and full markdown."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TerminalPage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Terminal,
|
||||
title = "Terminal",
|
||||
description = "Secure remote shell access to your server via tmux. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BridgePage() {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.PhonelinkSetup,
|
||||
title = "Bridge",
|
||||
description = "Let your agent control your device — taps, typing, screenshots, and automation. Coming soon."
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
apiServerUrl: String,
|
||||
onApiServerUrlChange: (String) -> Unit,
|
||||
apiKey: String,
|
||||
onApiKeyChange: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var apiKeyVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var testResult by remember { mutableStateOf<TestResult?>(null) }
|
||||
var isTesting by rememberSaveable { mutableStateOf(false) }
|
||||
var showApiKeyHelp by rememberSaveable { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
showQrScanner = true
|
||||
} else {
|
||||
Toast.makeText(context, "Camera permission needed to scan QR codes", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// API Key help dialog
|
||||
if (showApiKeyHelp) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showApiKeyHelp = false },
|
||||
title = { Text("Where to find your API key") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Your API key is the API_SERVER_KEY value from your Hermes server configuration.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"Location: ~/.hermes/.env",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
"If you haven't set a key yet, add one to your server config — it secures all API communication.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"Default port: 8642",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val helpContext = LocalContext.current
|
||||
Text(
|
||||
text = "View full setup guide",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable {
|
||||
helpContext.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-agent.nousresearch.com"))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showApiKeyHelp = false }) {
|
||||
Text("Got it")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Dns,
|
||||
title = "Connect",
|
||||
description = "Enter your Hermes server address and API key to connect securely."
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// API Server URL
|
||||
OutlinedTextField(
|
||||
value = apiServerUrl,
|
||||
onValueChange = {
|
||||
onApiServerUrlChange(it)
|
||||
testResult = null
|
||||
},
|
||||
label = { Text("API Server URL") },
|
||||
placeholder = { Text("http://your-server:8642") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// API Key with help icon
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = {
|
||||
onApiKeyChange(it)
|
||||
testResult = null
|
||||
},
|
||||
label = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("API Key")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||
contentDescription = "API key help",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.clickable { showApiKeyHelp = true },
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
placeholder = { Text("Your API_SERVER_KEY value") },
|
||||
singleLine = true,
|
||||
visualTransformation = if (apiKeyVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
|
||||
Icon(
|
||||
imageVector = if (apiKeyVisible) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription = if (apiKeyVisible) "Hide" else "Show"
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// "How do I set this up?" clickable text
|
||||
Text(
|
||||
text = "How do I set this up?",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.clickable { showApiKeyHelp = true }
|
||||
.padding(top = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Test Connection button + result
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (apiServerUrl.isNotBlank()) {
|
||||
isTesting = true
|
||||
testResult = null
|
||||
coroutineScope.launch {
|
||||
testResult = performHealthCheck(apiServerUrl, apiKey)
|
||||
isTesting = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = apiServerUrl.isNotBlank() && !isTesting
|
||||
) {
|
||||
Text("Test Connection")
|
||||
}
|
||||
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
|
||||
testResult?.let { result ->
|
||||
when (result) {
|
||||
is TestResult.Success -> {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = true,
|
||||
modifier = Modifier.size(14.dp),
|
||||
size = 14.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Connected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
is TestResult.Failure -> {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = false,
|
||||
modifier = Modifier.size(14.dp),
|
||||
size = 14.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error message text below the row
|
||||
testResult?.let { result ->
|
||||
if (result is TestResult.Failure) {
|
||||
Text(
|
||||
text = result.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// QR code scanning option
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Run hermes-pair on your server to generate a QR code",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.Start)
|
||||
)
|
||||
}
|
||||
|
||||
// QR Scanner overlay
|
||||
if (showQrScanner) {
|
||||
QrPairingScanner(
|
||||
onPairingDetected = { payload ->
|
||||
onApiServerUrlChange(payload.serverUrl)
|
||||
onApiKeyChange(payload.key)
|
||||
showQrScanner = false
|
||||
// Auto-trigger test
|
||||
isTesting = true
|
||||
testResult = null
|
||||
coroutineScope.launch {
|
||||
testResult = performHealthCheck(payload.serverUrl, payload.key)
|
||||
isTesting = false
|
||||
}
|
||||
},
|
||||
onDismiss = { showQrScanner = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayPage(
|
||||
relayUrl: String,
|
||||
onRelayUrlChange: (String) -> Unit
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Hub,
|
||||
title = "Relay Server",
|
||||
description = "Optional — for Bridge and Terminal features. You can set this up later in Settings."
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = relayUrl,
|
||||
onValueChange = onRelayUrlChange,
|
||||
label = { Text("Relay URL (optional)") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Needed for Bridge and Terminal features",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a direct OkHttp health check against the Hermes API server.
|
||||
* Returns a [TestResult] with either success or a descriptive error message.
|
||||
*/
|
||||
private suspend fun performHealthCheck(apiServerUrl: String, apiKey: String): TestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
try {
|
||||
val builder = Request.Builder()
|
||||
.url("$apiServerUrl/health")
|
||||
.get()
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
val request = builder.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
when {
|
||||
response.isSuccessful -> TestResult.Success
|
||||
response.code == 401 -> TestResult.Failure("Unauthorized \u2014 check your API key")
|
||||
else -> TestResult.Failure("Server returned HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: SSLException) {
|
||||
// User entered https:// but server runs plain HTTP, or vice versa
|
||||
if (apiServerUrl.startsWith("https://", ignoreCase = true)) {
|
||||
TestResult.Failure("TLS handshake failed \u2014 try http:// if your server doesn't use HTTPS")
|
||||
} else {
|
||||
TestResult.Failure("SSL error: ${e.message}")
|
||||
}
|
||||
} catch (e: ConnectException) {
|
||||
TestResult.Failure("Connection refused \u2014 check the URL and port")
|
||||
} catch (e: UnknownHostException) {
|
||||
TestResult.Failure("Server not found \u2014 check the hostname")
|
||||
} catch (e: SocketTimeoutException) {
|
||||
TestResult.Failure("Connection timed out \u2014 is the server running?")
|
||||
} catch (e: IOException) {
|
||||
val msg = e.message ?: ""
|
||||
when {
|
||||
msg.contains("401") -> TestResult.Failure("Unauthorized \u2014 check your API key")
|
||||
msg.contains("tls", ignoreCase = true) || msg.contains("ssl", ignoreCase = true) ->
|
||||
TestResult.Failure("TLS error \u2014 try http:// if your server doesn't use HTTPS")
|
||||
else -> TestResult.Failure("Connection failed: $msg")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TestResult.Failure("Connection failed: ${e.message}")
|
||||
} finally {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
private fun OnboardingScreenPreview() {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun OnboardingScreenDarkPreview() {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hermesandroid.companion.ui.onboarding
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.companion.ui.theme.HermesCompanionTheme
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
@Composable
|
||||
fun PageIndicator(
|
||||
@@ -62,7 +62,7 @@ fun PageIndicator(
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PageIndicatorPreview() {
|
||||
HermesCompanionTheme {
|
||||
HermesRelayTheme {
|
||||
PageIndicator(pageCount = 5, currentPage = 2)
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ private fun PageIndicatorPreview() {
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PageIndicatorFirstPagePreview() {
|
||||
HermesCompanionTheme {
|
||||
HermesRelayTheme {
|
||||
PageIndicator(pageCount = 5, currentPage = 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BridgeScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 300.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PhoneAndroid,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.alpha(0.6f),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Device Bridge",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Let your Hermes agent interact with your phone \u2014 " +
|
||||
"tap, type, read the screen, and automate workflows.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = {
|
||||
Text(
|
||||
text = "Coming Soon",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
val plannedFeatures = listOf(
|
||||
"Agent-controlled device interaction",
|
||||
"Accessibility service integration",
|
||||
"Activity log and command history",
|
||||
"Permission management"
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
plannedFeatures.forEach { feature ->
|
||||
Text(
|
||||
text = "\u2022 $feature",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun BridgeScreenPreview() {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TerminalScreen() {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Terminal") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 300.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.alpha(0.6f),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Remote Terminal",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Secure shell access to your Hermes server via tmux. " +
|
||||
"Connect, run commands, and manage sessions \u2014 right from your phone.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
AssistChip(
|
||||
onClick = { },
|
||||
label = {
|
||||
Text(
|
||||
text = "Coming Soon",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
val plannedFeatures = listOf(
|
||||
"Full ANSI terminal emulator",
|
||||
"tmux session management",
|
||||
"Biometric authentication",
|
||||
"Soft keyboard shortcuts"
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
plannedFeatures.forEach { feature ->
|
||||
Text(
|
||||
text = "\u2022 $feature",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TerminalScreenPreview() {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// Brand colors for glow effects
|
||||
private val GlowPurple = Color(0xFF9B6BF0)
|
||||
private val GlowPurpleDark = Color(0xFF6B35E8)
|
||||
private val NavySurface = Color(0xFF1E1E34)
|
||||
private val NavyDeep = Color(0xFF12121E)
|
||||
|
||||
/**
|
||||
* Purple glow behind an element. Uses a radial gradient from purple to transparent.
|
||||
* Only applies in dark theme — in light theme this is a no-op.
|
||||
*/
|
||||
fun Modifier.purpleGlow(
|
||||
radius: Dp = 20.dp,
|
||||
alpha: Float = 0.3f,
|
||||
isDarkTheme: Boolean = true
|
||||
): Modifier {
|
||||
if (!isDarkTheme) return this
|
||||
return this.drawBehind {
|
||||
val radiusPx = radius.toPx()
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
GlowPurple.copy(alpha = alpha),
|
||||
GlowPurpleDark.copy(alpha = alpha * 0.4f),
|
||||
Color.Transparent
|
||||
),
|
||||
center = center,
|
||||
radius = radiusPx
|
||||
),
|
||||
radius = radiusPx,
|
||||
center = center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gradient border on a card/surface. Uses a linear gradient from light purple to dark purple.
|
||||
* Only applies in dark theme — in light theme this is a no-op.
|
||||
*/
|
||||
fun Modifier.gradientBorder(
|
||||
width: Dp = 1.dp,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
colors: List<Color> = listOf(GlowPurple, GlowPurpleDark),
|
||||
isDarkTheme: Boolean = true
|
||||
): Modifier {
|
||||
if (!isDarkTheme) return this
|
||||
return this.border(
|
||||
width = width,
|
||||
brush = Brush.linearGradient(colors),
|
||||
shape = shape
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Radial background gradient — dark center, darker edges.
|
||||
* Provides subtle depth instead of flat color.
|
||||
* Only applies in dark theme — in light theme this is a no-op.
|
||||
*/
|
||||
fun Modifier.radialNavyBackground(
|
||||
isDarkTheme: Boolean = true
|
||||
): Modifier {
|
||||
if (!isDarkTheme) return this
|
||||
return this.drawBehind {
|
||||
drawRect(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(NavySurface, NavyDeep),
|
||||
center = Offset(size.width / 2f, size.height / 3f),
|
||||
radius = size.maxDimension * 0.8f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small purple glow on the left edge of an element (used for assistant bubbles).
|
||||
* Only applies in dark theme.
|
||||
*/
|
||||
fun Modifier.leftEdgeGlow(
|
||||
alpha: Float = 0.15f,
|
||||
width: Dp = 24.dp,
|
||||
isDarkTheme: Boolean = true
|
||||
): Modifier {
|
||||
if (!isDarkTheme) return this
|
||||
return this.drawBehind {
|
||||
val widthPx = width.toPx()
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
GlowPurple.copy(alpha = alpha),
|
||||
Color.Transparent
|
||||
),
|
||||
startX = 0f,
|
||||
endX = widthPx
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable helper to check dark theme and apply glow modifiers.
|
||||
* Use this in @Composable contexts where isSystemInDarkTheme() is available.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.purpleGlowAuto(
|
||||
radius: Dp = 20.dp,
|
||||
alpha: Float = 0.3f
|
||||
): Modifier = purpleGlow(radius = radius, alpha = alpha, isDarkTheme = isSystemInDarkTheme())
|
||||
|
||||
@Composable
|
||||
fun Modifier.gradientBorderAuto(
|
||||
width: Dp = 1.dp,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
colors: List<Color> = listOf(GlowPurple, GlowPurpleDark)
|
||||
): Modifier = gradientBorder(width = width, shape = shape, colors = colors, isDarkTheme = isSystemInDarkTheme())
|
||||
|
||||
@Composable
|
||||
fun Modifier.radialNavyBackgroundAuto(): Modifier = radialNavyBackground(isDarkTheme = isSystemInDarkTheme())
|
||||
|
||||
@Composable
|
||||
fun Modifier.leftEdgeGlowAuto(
|
||||
alpha: Float = 0.15f,
|
||||
width: Dp = 24.dp
|
||||
): Modifier = leftEdgeGlow(alpha = alpha, width = width, isDarkTheme = isSystemInDarkTheme())
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
// Brand palette — derived from assets/logo.svg
|
||||
private val HermesPrimary = Color(0xFF6B35E8) // Logo primary purple
|
||||
private val HermesPrimaryLight = Color(0xFF9B6BF0) // Logo accent purple
|
||||
private val HermesPrimaryDark = Color(0xFF4A1DB8) // Deeper variant for containers
|
||||
private val HermesNavy = Color(0xFF1A1A2E) // Logo background navy
|
||||
private val HermesNavySurface = Color(0xFF1E1E34) // Slightly lifted surface
|
||||
private val HermesNavyVariant = Color(0xFF2A2A44) // Card/surface variant
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = HermesPrimaryLight,
|
||||
onPrimary = Color(0xFF1A0049),
|
||||
primaryContainer = HermesPrimary,
|
||||
onPrimaryContainer = Color(0xFFE8DEFF),
|
||||
secondary = Color(0xFFB8AACC),
|
||||
onSecondary = Color(0xFF2B2040),
|
||||
secondaryContainer = Color(0xFF413558),
|
||||
onSecondaryContainer = Color(0xFFE8DEFF),
|
||||
tertiary = Color(0xFF9B6BF0),
|
||||
onTertiary = Color(0xFF1A0049),
|
||||
tertiaryContainer = Color(0xFF3D1F8C),
|
||||
onTertiaryContainer = Color(0xFFE8DEFF),
|
||||
background = HermesNavy,
|
||||
onBackground = Color(0xFFE4E1E9),
|
||||
surface = HermesNavy,
|
||||
onSurface = Color(0xFFE4E1E9),
|
||||
surfaceVariant = HermesNavyVariant,
|
||||
onSurfaceVariant = Color(0xFFC9C3D4),
|
||||
surfaceContainerLowest = Color(0xFF151524),
|
||||
surfaceContainerLow = Color(0xFF1C1C30),
|
||||
surfaceContainer = HermesNavySurface,
|
||||
surfaceContainerHigh = Color(0xFF24243C),
|
||||
surfaceContainerHighest = Color(0xFF2E2E48),
|
||||
outline = Color(0xFF5A5470),
|
||||
outlineVariant = Color(0xFF3D3854)
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = HermesPrimary,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFE8DEFF),
|
||||
onPrimaryContainer = Color(0xFF1A0049),
|
||||
secondary = Color(0xFF5E5474),
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = Color(0xFFE8DEFF),
|
||||
onSecondaryContainer = Color(0xFF1B1030),
|
||||
tertiary = HermesPrimaryDark,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = Color(0xFFE8DEFF),
|
||||
onTertiaryContainer = Color(0xFF1A0049),
|
||||
background = Color(0xFFFCF8FF),
|
||||
onBackground = Color(0xFF1B1B22),
|
||||
surface = Color(0xFFFCF8FF),
|
||||
onSurface = Color(0xFF1B1B22),
|
||||
surfaceVariant = Color(0xFFEAE4F2),
|
||||
onSurfaceVariant = Color(0xFF48444E),
|
||||
surfaceContainerLowest = Color.White,
|
||||
surfaceContainerLow = Color(0xFFF7F2FC),
|
||||
surfaceContainer = Color(0xFFF1ECF6),
|
||||
surfaceContainerHigh = Color(0xFFEBE6F0),
|
||||
surfaceContainerHighest = Color(0xFFE5E0EA),
|
||||
outline = Color(0xFF79747E),
|
||||
outlineVariant = Color(0xFFCBC4D0)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HermesRelayTheme(
|
||||
themePreference: String = "auto",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val useDarkTheme = when (themePreference) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
val colorScheme = when {
|
||||
// Dynamic colors available on Android 12+ (API 31)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (useDarkTheme) dynamicDarkColorScheme(context)
|
||||
else dynamicLightColorScheme(context)
|
||||
}
|
||||
useDarkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.hermesandroid.companion.ui.theme
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -0,0 +1,517 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.HermesApiClient
|
||||
import com.hermesandroid.relay.network.handlers.ChatHandler
|
||||
import com.hermesandroid.relay.network.models.SkillInfo
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.sse.EventSource
|
||||
import java.util.UUID
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
private var chatHandler: ChatHandler? = null
|
||||
private var activeStream: EventSource? = null
|
||||
private var intentionallyCancelled = false
|
||||
private var firstTokenNotified = false
|
||||
|
||||
/** Callback to persist session ID — set by RelayApp */
|
||||
var onSessionChanged: ((String?) -> Unit)? = null
|
||||
|
||||
// --- Message queue ---
|
||||
private val _queuedMessages = MutableStateFlow<List<String>>(emptyList())
|
||||
val queuedMessages: StateFlow<List<String>> = _queuedMessages.asStateFlow()
|
||||
|
||||
// --- Pending attachments ---
|
||||
private val _pendingAttachments = MutableStateFlow<List<Attachment>>(emptyList())
|
||||
val pendingAttachments: StateFlow<List<Attachment>> = _pendingAttachments.asStateFlow()
|
||||
|
||||
fun addAttachment(attachment: Attachment) {
|
||||
_pendingAttachments.update { it + attachment }
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) {
|
||||
_pendingAttachments.update { list ->
|
||||
list.filterIndexed { i, _ -> i != index }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAttachments() {
|
||||
_pendingAttachments.value = emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Brief app context sent as system_message when enabled in settings. */
|
||||
const val APP_CONTEXT_PROMPT = "The user is chatting via the Hermes Relay Android app. Keep responses mobile-friendly and concise when possible."
|
||||
}
|
||||
|
||||
// Server-side personality selection
|
||||
private val _selectedPersonality = MutableStateFlow("default")
|
||||
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
|
||||
|
||||
private val _personalityNames = MutableStateFlow<List<String>>(emptyList())
|
||||
val personalityNames: StateFlow<List<String>> = _personalityNames.asStateFlow()
|
||||
|
||||
/** Default personality name from server (config.display.personality) */
|
||||
private val _defaultPersonality = MutableStateFlow("")
|
||||
val defaultPersonality: StateFlow<String> = _defaultPersonality.asStateFlow()
|
||||
|
||||
/** Personality name → system prompt. Used to send the right prompt when switching. */
|
||||
private var personalityPrompts: Map<String, String> = emptyMap()
|
||||
|
||||
/** Model name from the server's /api/config response (e.g. "claude-opus-4-6") */
|
||||
private val _serverModelName = MutableStateFlow("")
|
||||
val serverModelName: StateFlow<String> = _serverModelName.asStateFlow()
|
||||
|
||||
/** Whether to include the brief app context system message */
|
||||
var appContextEnabled: Boolean = true
|
||||
|
||||
/** Streaming endpoint: "sessions" or "runs" */
|
||||
var streamingEndpoint: String = "sessions"
|
||||
|
||||
fun selectPersonality(name: String) {
|
||||
_selectedPersonality.value = name
|
||||
}
|
||||
|
||||
/** The display name of the currently active personality (for chat bubbles). */
|
||||
val activePersonalityName: String
|
||||
get() {
|
||||
val selected = _selectedPersonality.value
|
||||
return if (selected == "default") _defaultPersonality.value else selected
|
||||
}
|
||||
|
||||
private val _isLoadingHistory = MutableStateFlow(false)
|
||||
val isLoadingHistory: StateFlow<Boolean> = _isLoadingHistory.asStateFlow()
|
||||
|
||||
private val _availableSkills = MutableStateFlow<List<SkillInfo>>(emptyList())
|
||||
val availableSkills: StateFlow<List<SkillInfo>> = _availableSkills.asStateFlow()
|
||||
|
||||
// Cached fallback StateFlows to avoid creating new instances on each access
|
||||
private val _emptyMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
private val _emptyStreaming = MutableStateFlow(false)
|
||||
private val _emptySessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
private val _emptyError = MutableStateFlow<String?>(null)
|
||||
private val _emptySessionId = MutableStateFlow<String?>(null)
|
||||
|
||||
// Delegated to ChatHandler
|
||||
val messages: StateFlow<List<ChatMessage>>
|
||||
get() = chatHandler?.messages ?: _emptyMessages
|
||||
|
||||
val isStreaming: StateFlow<Boolean>
|
||||
get() = chatHandler?.isStreaming ?: _emptyStreaming
|
||||
|
||||
val sessions: StateFlow<List<ChatSession>>
|
||||
get() = chatHandler?.sessions ?: _emptySessions
|
||||
|
||||
val error: StateFlow<String?>
|
||||
get() = chatHandler?.error ?: _emptyError
|
||||
|
||||
val currentSessionId: StateFlow<String?>
|
||||
get() = chatHandler?.currentSessionId ?: _emptySessionId
|
||||
|
||||
fun initialize(apiClient: HermesApiClient, chatHandler: ChatHandler) {
|
||||
this.apiClient = apiClient
|
||||
this.chatHandler = chatHandler
|
||||
fetchSkills()
|
||||
fetchPersonalities()
|
||||
}
|
||||
|
||||
fun fetchSkills() {
|
||||
val client = apiClient ?: return
|
||||
viewModelScope.launch {
|
||||
val skills = client.getSkills()
|
||||
_availableSkills.value = skills
|
||||
}
|
||||
}
|
||||
|
||||
fun updateApiClient(client: HermesApiClient) {
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
this.apiClient = client
|
||||
fetchSkills()
|
||||
fetchPersonalities()
|
||||
}
|
||||
|
||||
private fun fetchPersonalities() {
|
||||
val client = apiClient ?: return
|
||||
viewModelScope.launch {
|
||||
val config = client.getPersonalities()
|
||||
_personalityNames.value = config.names
|
||||
_defaultPersonality.value = config.defaultName
|
||||
personalityPrompts = config.prompts
|
||||
_serverModelName.value = config.modelName
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session management ---
|
||||
|
||||
fun refreshSessions() {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
viewModelScope.launch {
|
||||
val sessions = client.listSessions()
|
||||
handler.updateSessions(sessions)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewChat() {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// Cancel any in-flight stream
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
|
||||
viewModelScope.launch {
|
||||
val session = client.createSession()
|
||||
if (session != null) {
|
||||
val chatSession = ChatSession(
|
||||
sessionId = session.id,
|
||||
title = session.title ?: "New Chat",
|
||||
model = session.model
|
||||
)
|
||||
handler.addSession(chatSession)
|
||||
handler.setSessionId(session.id)
|
||||
handler.clearMessages()
|
||||
onSessionChanged?.invoke(session.id)
|
||||
AppAnalytics.onSessionCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// Cancel any in-flight stream
|
||||
intentionallyCancelled = true
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
|
||||
handler.setSessionId(sessionId)
|
||||
handler.clearMessages()
|
||||
onSessionChanged?.invoke(sessionId)
|
||||
AppAnalytics.onSessionSwitched()
|
||||
|
||||
// Load message history
|
||||
_isLoadingHistory.value = true
|
||||
viewModelScope.launch {
|
||||
val messages = client.getMessages(sessionId)
|
||||
handler.loadMessageHistory(messages)
|
||||
_isLoadingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a session by ID (e.g., from persisted last session).
|
||||
* Only loads history, doesn't create a new session.
|
||||
*/
|
||||
fun resumeSession(sessionId: String) {
|
||||
switchSession(sessionId)
|
||||
}
|
||||
|
||||
fun deleteSession(sessionId: String) {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// Save reference before removing (for rollback on failure)
|
||||
val removedSession = handler.sessions.value.find { it.sessionId == sessionId }
|
||||
|
||||
// Optimistic removal
|
||||
handler.removeSession(sessionId)
|
||||
if (handler.currentSessionId.value == null) {
|
||||
onSessionChanged?.invoke(null)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val success = client.deleteSession(sessionId)
|
||||
if (!success && removedSession != null) {
|
||||
// Restore on failure
|
||||
handler.addSession(removedSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun renameSession(sessionId: String, newTitle: String) {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// Optimistic rename
|
||||
handler.renameSessionLocal(sessionId, newTitle)
|
||||
|
||||
viewModelScope.launch {
|
||||
client.renameSession(sessionId, newTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Message sending ---
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// If currently streaming, queue the message instead of cancelling
|
||||
if (activeStream != null) {
|
||||
_queuedMessages.update { it + text.trim() }
|
||||
return
|
||||
}
|
||||
|
||||
sendMessageInternal(client, handler, text)
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
_queuedMessages.value = emptyList()
|
||||
}
|
||||
|
||||
private fun drainQueue() {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
val next = _queuedMessages.value.firstOrNull() ?: return
|
||||
_queuedMessages.update { it.drop(1) }
|
||||
sendMessageInternal(client, handler, next)
|
||||
}
|
||||
|
||||
private fun sendMessageInternal(client: HermesApiClient, handler: ChatHandler, text: String) {
|
||||
AppAnalytics.onMessageSent()
|
||||
|
||||
// Snapshot and clear pending attachments
|
||||
val attachments = _pendingAttachments.value.ifEmpty { null }
|
||||
_pendingAttachments.value = emptyList()
|
||||
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
|
||||
// Add user message locally (with attachments for display)
|
||||
handler.addUserMessage(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.USER,
|
||||
content = text.trim(),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
attachments = attachments ?: emptyList()
|
||||
)
|
||||
)
|
||||
handler.setLastSentMessage(text.trim())
|
||||
|
||||
val assistantMessageId = UUID.randomUUID().toString()
|
||||
val sessionId = handler.currentSessionId.value
|
||||
|
||||
if (streamingEndpoint == "runs") {
|
||||
startStream(client, handler, sessionId ?: "", text.trim(), assistantMessageId, attachments)
|
||||
} else if (sessionId != null) {
|
||||
startStream(client, handler, sessionId, text.trim(), assistantMessageId, attachments)
|
||||
} else {
|
||||
viewModelScope.launch {
|
||||
val session = client.createSession()
|
||||
if (session != null) {
|
||||
val chatSession = ChatSession(
|
||||
sessionId = session.id,
|
||||
title = null,
|
||||
model = session.model
|
||||
)
|
||||
handler.addSession(chatSession)
|
||||
handler.setSessionId(session.id)
|
||||
onSessionChanged?.invoke(session.id)
|
||||
startStream(client, handler, session.id, text.trim(), assistantMessageId, attachments)
|
||||
|
||||
// Auto-title: use first ~50 chars of user message
|
||||
val autoTitle = text.trim().take(50).let {
|
||||
if (text.length > 50) "$it..." else it
|
||||
}
|
||||
client.renameSession(session.id, autoTitle)
|
||||
handler.renameSessionLocal(session.id, autoTitle)
|
||||
} else {
|
||||
handler.onStreamError("Failed to create chat session")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStream(
|
||||
client: HermesApiClient,
|
||||
handler: ChatHandler,
|
||||
sessionId: String,
|
||||
message: String,
|
||||
assistantMessageId: String,
|
||||
attachments: List<Attachment>? = null
|
||||
) {
|
||||
// Build system_message from personality prompt + app context
|
||||
val selected = _selectedPersonality.value
|
||||
val personalityPrompt = if (selected != "default" && selected != _defaultPersonality.value) {
|
||||
// Non-default personality selected — send its system prompt to override server default
|
||||
personalityPrompts[selected]
|
||||
} else null
|
||||
val appContext = if (appContextEnabled) APP_CONTEXT_PROMPT else null
|
||||
val systemMsg = listOfNotNull(personalityPrompt, appContext)
|
||||
.joinToString("\n\n")
|
||||
.ifBlank { null }
|
||||
|
||||
// Set agent name for display on chat bubbles
|
||||
handler.activeAgentName = activePersonalityName.replaceFirstChar { it.uppercase() }
|
||||
.ifBlank { null }
|
||||
|
||||
firstTokenNotified = false
|
||||
var lastInputTokens: Int? = null
|
||||
var lastOutputTokens: Int? = null
|
||||
|
||||
// Track the current message ID — starts with our generated ID,
|
||||
// but updates when the server sends message.started with its own ID.
|
||||
var currentMessageId = assistantMessageId
|
||||
|
||||
// Show placeholder "thinking" message immediately — filled when first delta arrives
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = assistantMessageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
agentName = handler.activeAgentName
|
||||
)
|
||||
)
|
||||
|
||||
// Shared callbacks for both endpoints
|
||||
val onMessageStartedCb = { serverMsgId: String ->
|
||||
// Server assigned a new message ID for this turn — update tracking
|
||||
currentMessageId = serverMsgId
|
||||
}
|
||||
val onTextDeltaCb = { delta: String ->
|
||||
if (!firstTokenNotified) {
|
||||
firstTokenNotified = true
|
||||
AppAnalytics.onFirstTokenReceived()
|
||||
}
|
||||
handler.onTextDelta(currentMessageId, delta)
|
||||
}
|
||||
val onThinkingDeltaCb = { delta: String ->
|
||||
handler.onThinkingDelta(currentMessageId, delta)
|
||||
}
|
||||
val onToolCallStartCb = { toolCallId: String, toolName: String ->
|
||||
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
|
||||
}
|
||||
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
|
||||
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
|
||||
}
|
||||
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
|
||||
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
|
||||
}
|
||||
// Turn complete — one assistant message finished, but the run may continue
|
||||
val onTurnCompleteCb = {
|
||||
handler.onTurnComplete(currentMessageId)
|
||||
}
|
||||
val onCompleteCb = {
|
||||
handler.onStreamComplete(currentMessageId)
|
||||
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
|
||||
activeStream = null
|
||||
drainQueue()
|
||||
}
|
||||
val onUsageCb = { usage: UsageInfo? ->
|
||||
if (usage != null) {
|
||||
val tokIn = usage.resolvedInputTokens
|
||||
val tokOut = usage.resolvedOutputTokens
|
||||
if (tokIn != null || tokOut != null) {
|
||||
lastInputTokens = tokIn
|
||||
lastOutputTokens = tokOut
|
||||
handler.onUsageReceived(
|
||||
currentMessageId,
|
||||
tokIn,
|
||||
tokOut,
|
||||
usage.resolvedTotalTokens,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
if (intentionallyCancelled) {
|
||||
intentionallyCancelled = false
|
||||
// Don't surface cancellation errors
|
||||
} else {
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(errorMsg)
|
||||
}
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
}
|
||||
|
||||
activeStream = if (streamingEndpoint == "runs") {
|
||||
client.sendRunStream(
|
||||
message = message,
|
||||
systemMessage = systemMsg,
|
||||
attachments = attachments,
|
||||
onSessionId = { sid ->
|
||||
handler.setSessionId(sid)
|
||||
onSessionChanged?.invoke(sid)
|
||||
},
|
||||
onMessageStarted = onMessageStartedCb,
|
||||
onTextDelta = onTextDeltaCb,
|
||||
onThinkingDelta = onThinkingDeltaCb,
|
||||
onToolCallStart = onToolCallStartCb,
|
||||
onToolCallDone = onToolCallDoneCb,
|
||||
onToolCallFailed = onToolCallFailedCb,
|
||||
onTurnComplete = onTurnCompleteCb,
|
||||
onComplete = onCompleteCb,
|
||||
onUsage = onUsageCb,
|
||||
onError = onErrorCb
|
||||
)
|
||||
} else {
|
||||
client.sendChatStream(
|
||||
sessionId = sessionId,
|
||||
message = message,
|
||||
systemMessage = systemMsg,
|
||||
attachments = attachments,
|
||||
onSessionId = { /* already set */ },
|
||||
onMessageStarted = onMessageStartedCb,
|
||||
onTextDelta = onTextDeltaCb,
|
||||
onThinkingDelta = onThinkingDeltaCb,
|
||||
onToolCallStart = onToolCallStartCb,
|
||||
onToolCallDone = onToolCallDoneCb,
|
||||
onToolCallFailed = onToolCallFailedCb,
|
||||
onTurnComplete = onTurnCompleteCb,
|
||||
onComplete = onCompleteCb,
|
||||
onUsage = onUsageCb,
|
||||
onError = onErrorCb
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelStream() {
|
||||
intentionallyCancelled = true
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
AppAnalytics.onStreamCancelled()
|
||||
chatHandler?.let { handler ->
|
||||
val streamingMsg = handler.messages.value.findLast { it.isStreaming }
|
||||
if (streamingMsg != null) {
|
||||
handler.onStreamComplete(streamingMsg.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
chatHandler?.clearError()
|
||||
}
|
||||
|
||||
fun retryLastMessage() {
|
||||
val lastMsg = chatHandler?.lastSentMessage?.value ?: return
|
||||
sendMessage(lastMsg)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
activeStream?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.DataManager
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.ConnectivityObserver
|
||||
import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.ConnectionManager
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.network.HermesApiClient
|
||||
import com.hermesandroid.relay.network.handlers.ChatHandler
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
// API Server (direct chat)
|
||||
private val KEY_API_SERVER_URL = stringPreferencesKey("api_server_url")
|
||||
private const val DEFAULT_API_URL = "http://localhost:8642"
|
||||
|
||||
// Relay Server (bridge/terminal)
|
||||
private val KEY_RELAY_URL = stringPreferencesKey("relay_url")
|
||||
private val KEY_SERVER_URL = stringPreferencesKey("server_url") // legacy migration
|
||||
private const val DEFAULT_RELAY_URL = "wss://localhost:8767"
|
||||
|
||||
// Shared
|
||||
private val KEY_THEME = stringPreferencesKey("theme")
|
||||
private val KEY_INSECURE_MODE = booleanPreferencesKey("insecure_mode")
|
||||
private val KEY_LAST_SEEN_VERSION = stringPreferencesKey("last_seen_version")
|
||||
private val KEY_LAST_SESSION_ID = stringPreferencesKey("last_session_id")
|
||||
private val KEY_SHOW_THINKING = booleanPreferencesKey("show_thinking")
|
||||
private val KEY_TOOL_DISPLAY = stringPreferencesKey("tool_display")
|
||||
private val KEY_APP_CONTEXT = booleanPreferencesKey("app_context_prompt")
|
||||
private val KEY_STREAMING_ENDPOINT = stringPreferencesKey("streaming_endpoint")
|
||||
private val KEY_PARSE_TOOL_ANNOTATIONS = booleanPreferencesKey("parse_tool_annotations")
|
||||
private val KEY_MAX_ATTACHMENT_MB = intPreferencesKey("max_attachment_mb")
|
||||
private val KEY_MAX_MESSAGE_LENGTH = intPreferencesKey("max_message_length")
|
||||
|
||||
// Animation
|
||||
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
||||
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
|
||||
}
|
||||
|
||||
// --- Core networking components ---
|
||||
|
||||
// Relay (bridge/terminal)
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val chatHandler = ChatHandler()
|
||||
private val connectionManager = ConnectionManager(multiplexer)
|
||||
val authManager = AuthManager(application, multiplexer, viewModelScope)
|
||||
|
||||
// Data management
|
||||
val dataManager = DataManager(application)
|
||||
|
||||
// --- Relay connection state ---
|
||||
val relayConnectionState: StateFlow<ConnectionState> = connectionManager.connectionState
|
||||
val authState: StateFlow<AuthState> = authManager.authState
|
||||
val insecureMode: StateFlow<Boolean> = connectionManager.insecureMode
|
||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||
|
||||
// --- API Server state ---
|
||||
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
|
||||
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
|
||||
|
||||
private val _apiServerReachable = MutableStateFlow(false)
|
||||
val apiServerReachable: StateFlow<Boolean> = _apiServerReachable.asStateFlow()
|
||||
|
||||
private val _apiClient = MutableStateFlow<HermesApiClient?>(null)
|
||||
val apiClient: StateFlow<HermesApiClient?> = _apiClient.asStateFlow()
|
||||
|
||||
private val _chatMode = MutableStateFlow(ChatMode.DISCONNECTED)
|
||||
val chatMode: StateFlow<ChatMode> = _chatMode.asStateFlow()
|
||||
|
||||
// Chat is ready when API client exists and server is reachable
|
||||
val chatReady: StateFlow<Boolean> = combine(_apiClient, _apiServerReachable) { client, reachable ->
|
||||
client != null && reachable
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// --- Relay URL ---
|
||||
private val _relayUrl = MutableStateFlow(DEFAULT_RELAY_URL)
|
||||
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
||||
|
||||
// Backward compat: expose as serverUrl for any remaining references
|
||||
@Deprecated("Use relayUrl or apiServerUrl", replaceWith = ReplaceWith("relayUrl"))
|
||||
val serverUrl: StateFlow<String> = _relayUrl
|
||||
|
||||
// Backward compat: expose relay state as connectionState
|
||||
@Deprecated("Use relayConnectionState", replaceWith = ReplaceWith("relayConnectionState"))
|
||||
val connectionState: StateFlow<ConnectionState> = relayConnectionState
|
||||
|
||||
// Theme preference
|
||||
val theme: StateFlow<String> = application.relayDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[KEY_THEME] ?: "auto"
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
|
||||
|
||||
// Onboarding state
|
||||
private val _onboardingCompleted = MutableStateFlow(true) // default true to avoid flash
|
||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted.asStateFlow()
|
||||
|
||||
// Pairing code from AuthManager
|
||||
val pairingCode: StateFlow<String> = authManager.pairingCode
|
||||
|
||||
// What's New tracking
|
||||
private val _showWhatsNew = MutableStateFlow(false)
|
||||
val showWhatsNew: StateFlow<Boolean> = _showWhatsNew.asStateFlow()
|
||||
|
||||
// Last session ID persistence
|
||||
private val _lastSessionId = MutableStateFlow<String?>(null)
|
||||
val lastSessionId: StateFlow<String?> = _lastSessionId.asStateFlow()
|
||||
|
||||
// Connectivity
|
||||
private val connectivityObserver = ConnectivityObserver(application)
|
||||
val networkStatus: StateFlow<ConnectivityObserver.Status> = connectivityObserver.observe()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectivityObserver.Status.Available)
|
||||
|
||||
// Splash readiness — true once initial DataStore load + onboarding check is done
|
||||
private val _isReady = MutableStateFlow(false)
|
||||
val isReady: StateFlow<Boolean> = _isReady.asStateFlow()
|
||||
|
||||
// Show thinking toggle
|
||||
val showThinking: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_SHOW_THINKING] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setShowThinking(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_SHOW_THINKING] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool call display mode: "off", "compact", "detailed"
|
||||
val toolDisplay: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_TOOL_DISPLAY] ?: "detailed" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "detailed")
|
||||
|
||||
fun setToolDisplay(mode: String) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TOOL_DISPLAY] = mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App context prompt toggle
|
||||
val appContextEnabled: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_APP_CONTEXT] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setAppContext(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_APP_CONTEXT] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming endpoint: "sessions" = /api/sessions/{id}/chat/stream, "runs" = /v1/runs
|
||||
val streamingEndpoint: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_STREAMING_ENDPOINT] ?: "sessions" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "sessions")
|
||||
|
||||
fun setStreamingEndpoint(endpoint: String) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_STREAMING_ENDPOINT] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tool annotations from text markers toggle
|
||||
val parseToolAnnotations: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_PARSE_TOOL_ANNOTATIONS] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setParseToolAnnotations(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_PARSE_TOOL_ANNOTATIONS] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation settings
|
||||
val animationEnabled: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_ANIMATION_ENABLED] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val animationBehindChat: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setAnimationEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_ANIMATION_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAnimationBehindChat(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_ANIMATION_BEHIND_CHAT] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max attachment size in MB (default 10)
|
||||
val maxAttachmentMb: StateFlow<Int> = application.relayDataStore.data
|
||||
.map { it[KEY_MAX_ATTACHMENT_MB] ?: 10 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, 10)
|
||||
|
||||
fun setMaxAttachmentMb(mb: Int) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_MAX_ATTACHMENT_MB] = mb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max message character length (default 4096)
|
||||
val maxMessageLength: StateFlow<Int> = application.relayDataStore.data
|
||||
.map { it[KEY_MAX_MESSAGE_LENGTH] ?: 4096 }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, 4096)
|
||||
|
||||
fun setMaxMessageLength(length: Int) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_MAX_MESSAGE_LENGTH] = length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Wire multiplexer to connection manager (for relay/bridge/terminal)
|
||||
multiplexer.setSendCallback { envelope ->
|
||||
connectionManager.send(envelope)
|
||||
}
|
||||
|
||||
// Auto-authenticate on relay connect
|
||||
multiplexer.setOnConnectedCallback {
|
||||
authManager.authenticate()
|
||||
}
|
||||
|
||||
// Load saved state — split into fast (UI-blocking) and slow (network) paths
|
||||
viewModelScope.launch {
|
||||
_onboardingCompleted.value = dataManager.isOnboardingCompleted()
|
||||
|
||||
var prevApiUrl: String? = null
|
||||
var prevApiKey: String? = null
|
||||
|
||||
application.relayDataStore.data.collect { preferences ->
|
||||
// Restore insecure mode
|
||||
val insecure = preferences[KEY_INSECURE_MODE] ?: false
|
||||
connectionManager.setInsecureMode(insecure)
|
||||
|
||||
// Load API server URL
|
||||
val savedApiUrl = preferences[KEY_API_SERVER_URL]
|
||||
if (savedApiUrl != null) {
|
||||
_apiServerUrl.value = savedApiUrl
|
||||
}
|
||||
|
||||
// Load relay URL (with migration from old server_url key)
|
||||
val savedRelayUrl = preferences[KEY_RELAY_URL]
|
||||
?: preferences[KEY_SERVER_URL] // legacy migration
|
||||
if (savedRelayUrl != null) {
|
||||
_relayUrl.value = savedRelayUrl
|
||||
}
|
||||
|
||||
// Load last session ID
|
||||
val savedSessionId = preferences[KEY_LAST_SESSION_ID]
|
||||
if (savedSessionId != null) {
|
||||
_lastSessionId.value = savedSessionId
|
||||
}
|
||||
|
||||
// Check if this is a new version → show What's New
|
||||
val currentVersion = getAppVersionName()
|
||||
val lastSeen = preferences[KEY_LAST_SEEN_VERSION]
|
||||
if (lastSeen != null && lastSeen != currentVersion) {
|
||||
_showWhatsNew.value = true
|
||||
}
|
||||
|
||||
// Mark ready after first DataStore emission (UI can render)
|
||||
if (!_isReady.value) {
|
||||
_isReady.value = true
|
||||
}
|
||||
|
||||
// Rebuild API client in a separate coroutine so it doesn't block
|
||||
// the DataStore flow (getApiKey() awaits Tink crypto init on first call)
|
||||
val currentUrl = _apiServerUrl.value
|
||||
launch {
|
||||
val currentKey = authManager.getApiKey() ?: ""
|
||||
if (currentUrl != prevApiUrl || currentKey != prevApiKey) {
|
||||
prevApiUrl = currentUrl
|
||||
prevApiKey = currentKey
|
||||
rebuildApiClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic health check — only runs when an API client is configured
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
if (_apiClient.value != null) {
|
||||
checkApiHealth()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- API Server methods ---
|
||||
|
||||
fun updateApiServerUrl(url: String) {
|
||||
_apiServerUrl.value = url
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_API_SERVER_URL] = url
|
||||
}
|
||||
rebuildApiClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateApiKey(key: String) {
|
||||
viewModelScope.launch {
|
||||
authManager.setApiKey(key)
|
||||
rebuildApiClient()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkApiHealth() {
|
||||
viewModelScope.launch {
|
||||
val client = _apiClient.value
|
||||
_apiServerReachable.value = client?.checkHealth() == true
|
||||
}
|
||||
}
|
||||
|
||||
fun testApiConnection(onResult: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val client = _apiClient.value
|
||||
val reachable = client?.checkHealth() == true
|
||||
_apiServerReachable.value = reachable
|
||||
onResult(reachable)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun rebuildApiClient() {
|
||||
val url = _apiServerUrl.value
|
||||
val key = authManager.getApiKey() ?: ""
|
||||
|
||||
val oldClient = _apiClient.value
|
||||
|
||||
if (url.isNotBlank()) {
|
||||
val client = HermesApiClient(baseUrl = url, apiKey = key)
|
||||
_apiClient.value = client
|
||||
oldClient?.shutdown()
|
||||
_apiServerReachable.value = client.checkHealth()
|
||||
|
||||
// Detect chat mode
|
||||
val mode = client.detectChatMode()
|
||||
_chatMode.value = mode
|
||||
} else {
|
||||
_apiClient.value = null
|
||||
oldClient?.shutdown()
|
||||
_apiServerReachable.value = false
|
||||
_chatMode.value = ChatMode.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
// --- Relay methods ---
|
||||
|
||||
fun connectRelay(url: String) {
|
||||
_relayUrl.value = url
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_RELAY_URL] = url
|
||||
}
|
||||
}
|
||||
connectionManager.connect(url)
|
||||
}
|
||||
|
||||
fun connectRelay() {
|
||||
connectionManager.connect(_relayUrl.value)
|
||||
}
|
||||
|
||||
fun disconnectRelay() {
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
|
||||
fun updateRelayUrl(url: String) {
|
||||
_relayUrl.value = url
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_RELAY_URL] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compat wrappers
|
||||
@Deprecated("Use connectRelay", replaceWith = ReplaceWith("connectRelay(url)"))
|
||||
fun connect(url: String) = connectRelay(url)
|
||||
|
||||
@Deprecated("Use connectRelay", replaceWith = ReplaceWith("connectRelay()"))
|
||||
fun connect() = connectRelay()
|
||||
|
||||
@Deprecated("Use disconnectRelay", replaceWith = ReplaceWith("disconnectRelay()"))
|
||||
fun disconnect() = disconnectRelay()
|
||||
|
||||
@Deprecated("Use updateRelayUrl", replaceWith = ReplaceWith("updateRelayUrl(url)"))
|
||||
fun updateServerUrl(url: String) = updateRelayUrl(url)
|
||||
|
||||
// --- What's New + Version tracking ---
|
||||
|
||||
fun dismissWhatsNew() {
|
||||
_showWhatsNew.value = false
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_LAST_SEEN_VERSION] = getAppVersionName()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markVersionSeen() {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_LAST_SEEN_VERSION] = getAppVersionName()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAppVersionName(): String {
|
||||
return try {
|
||||
val app = getApplication<Application>()
|
||||
app.packageManager.getPackageInfo(app.packageName, 0).versionName ?: "0.0.0"
|
||||
} catch (_: Exception) {
|
||||
"0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session persistence ---
|
||||
|
||||
fun saveLastSessionId(sessionId: String?) {
|
||||
_lastSessionId.value = sessionId
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
if (sessionId != null) {
|
||||
preferences[KEY_LAST_SESSION_ID] = sessionId
|
||||
} else {
|
||||
preferences.remove(KEY_LAST_SESSION_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared methods ---
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_THEME] = theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setInsecureMode(enabled: Boolean) {
|
||||
connectionManager.setInsecureMode(enabled)
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_INSECURE_MODE] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
_onboardingCompleted.value = true
|
||||
viewModelScope.launch {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
viewModelScope.launch {
|
||||
dataManager.resetOnboarding()
|
||||
_onboardingCompleted.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAppData() {
|
||||
viewModelScope.launch {
|
||||
disconnectRelay()
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
_apiServerUrl.value = DEFAULT_API_URL
|
||||
_relayUrl.value = DEFAULT_RELAY_URL
|
||||
_apiClient.value?.shutdown()
|
||||
_apiClient.value = null
|
||||
_apiServerReachable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(onResult: (String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val json = dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
profiles = authManager.profiles.value,
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
onResult(json)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeBackupToUri(uri: Uri, backup: String, onResult: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val success = dataManager.writeBackupToUri(uri, backup)
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromUri(uri: Uri, onResult: (Boolean) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val jsonString = dataManager.readBackupFromUri(uri) ?: run {
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
val backup = dataManager.importSettings(jsonString) ?: run {
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
// Apply imported settings
|
||||
// Prefer v2 fields, fall back to v1 serverUrl for relay
|
||||
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
setTheme(backup.theme)
|
||||
if (backup.onboardingCompleted) {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
_onboardingCompleted.value = true
|
||||
}
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun regeneratePairingCode() {
|
||||
authManager.regeneratePairingCode()
|
||||
}
|
||||
|
||||
fun clearSession() {
|
||||
authManager.clearSession()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
connectionManager.shutdown()
|
||||
_apiClient.value?.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Splash icon animation: scale up from 0.7x with overshoot + fade in -->
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:ordering="together">
|
||||
<objectAnimator
|
||||
android:propertyName="scaleX"
|
||||
android:valueFrom="0.7"
|
||||
android:valueTo="1.0"
|
||||
android:valueType="floatType"
|
||||
android:duration="600"
|
||||
android:interpolator="@android:interpolator/overshoot"/>
|
||||
<objectAnimator
|
||||
android:propertyName="scaleY"
|
||||
android:valueFrom="0.7"
|
||||
android:valueTo="1.0"
|
||||
android:valueType="floatType"
|
||||
android:duration="600"
|
||||
android:interpolator="@android:interpolator/overshoot"/>
|
||||
<objectAnimator
|
||||
android:propertyName="alpha"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="1"
|
||||
android:valueType="floatType"
|
||||
android:duration="300"
|
||||
android:interpolator="@android:interpolator/decelerate_cubic"/>
|
||||
</set>
|
||||
@@ -1,8 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Hermes Companion foreground icon — Wing-H monogram
|
||||
Viewport 108x108, content within 72dp safe zone (18dp inset).
|
||||
Coordinates scaled from 512 SVG viewport: factor = 108/512 = 0.2109375
|
||||
Hermes Relay foreground icon — Chevron Compass with ghost V-crossbar + feathers
|
||||
Viewport 108x108. Scaled to 75% centered at (54,54) to fit within the 72dp
|
||||
adaptive icon safe zone with breathing room.
|
||||
Source: assets/logo.svg
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
@@ -11,135 +12,143 @@
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Left vertical of the H -->
|
||||
<path
|
||||
android:fillColor="#6B35E8"
|
||||
android:pathData="
|
||||
M39.66,31.22
|
||||
L44.72,31.22
|
||||
L44.72,76.78
|
||||
L39.66,76.78
|
||||
Z"/>
|
||||
<!-- Scale artwork to 75% around center (54,54) for adaptive icon safe zone -->
|
||||
<group
|
||||
android:scaleX="0.75"
|
||||
android:scaleY="0.75"
|
||||
android:pivotX="54"
|
||||
android:pivotY="54">
|
||||
|
||||
<!-- Right vertical of the H -->
|
||||
<path
|
||||
android:fillColor="#6B35E8"
|
||||
android:pathData="
|
||||
M63.28,31.22
|
||||
L68.34,31.22
|
||||
L68.34,76.78
|
||||
L63.28,76.78
|
||||
Z"/>
|
||||
<!-- ═══ Ghost layer: V-crossbar + diagonal feathers (no verticals) ═══ -->
|
||||
|
||||
<!-- Crossbar of the H -->
|
||||
<path
|
||||
android:fillColor="#6B35E8"
|
||||
android:pathData="
|
||||
M44.72,51.47
|
||||
L63.28,51.47
|
||||
L63.28,56.53
|
||||
L44.72,56.53
|
||||
Z"/>
|
||||
<!-- Crossbar + wings (V shape) -->
|
||||
<path
|
||||
android:pathData="M17.6,36.8 L37.8,57 L70.2,57 L90.4,36.8"
|
||||
android:strokeWidth="4.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="108"
|
||||
android:endY="108"
|
||||
android:startColor="#596B35E8"
|
||||
android:endColor="#0D9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Left wing — primary feather (longest) -->
|
||||
<path
|
||||
android:fillColor="#6B35E8"
|
||||
android:pathData="
|
||||
M44.72,51.47
|
||||
L44.72,48.94
|
||||
L21.09,40.50
|
||||
L20.25,43.88
|
||||
Z"/>
|
||||
<!-- Top-left feather -->
|
||||
<path
|
||||
android:pathData="M17.6,18.6 L37.8,38.8"
|
||||
android:strokeWidth="4.5"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="108"
|
||||
android:endY="108"
|
||||
android:startColor="#596B35E8"
|
||||
android:endColor="#0D9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Left wing — secondary feather -->
|
||||
<path
|
||||
android:pathData="
|
||||
M44.72,53.16
|
||||
L44.72,51.05
|
||||
L24.47,46.0
|
||||
L23.63,49.37
|
||||
Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="23.63"
|
||||
android:startY="49.37"
|
||||
android:endX="44.72"
|
||||
android:endY="51.05"
|
||||
android:startColor="#9B6BF0"
|
||||
android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<!-- Top-right feather -->
|
||||
<path
|
||||
android:pathData="M90.4,18.6 L70.2,38.8"
|
||||
android:strokeWidth="4.5"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="108"
|
||||
android:endY="108"
|
||||
android:startColor="#596B35E8"
|
||||
android:endColor="#0D9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Left wing — tertiary feather -->
|
||||
<path
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="
|
||||
M44.72,55.69
|
||||
L44.72,53.58
|
||||
L27.84,50.63
|
||||
L27.42,53.58
|
||||
Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="27.42"
|
||||
android:startY="53.58"
|
||||
android:endX="44.72"
|
||||
android:endY="53.58"
|
||||
android:startColor="#9B6BF0"
|
||||
android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<!-- ═══ Drop shadow (foreground offset +1.5dp Y, 20% black) ═══ -->
|
||||
|
||||
<!-- Right wing — primary feather (longest) -->
|
||||
<path
|
||||
android:fillColor="#6B35E8"
|
||||
android:pathData="
|
||||
M63.28,51.47
|
||||
L63.28,48.94
|
||||
L86.91,40.50
|
||||
L87.75,43.88
|
||||
Z"/>
|
||||
<path
|
||||
android:pathData="M54,43.5 L66,55.5 L54,67.5 L42,55.5 Z"
|
||||
android:strokeColor="#33000000"
|
||||
android:strokeWidth="7.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M30,43.5 L54,19.5 L78,43.5"
|
||||
android:strokeColor="#33000000"
|
||||
android:strokeWidth="7.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M30,67.5 L54,91.5 L78,67.5"
|
||||
android:strokeColor="#33000000"
|
||||
android:strokeWidth="7.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
|
||||
<!-- Right wing — secondary feather -->
|
||||
<path
|
||||
android:pathData="
|
||||
M63.28,53.16
|
||||
L63.28,51.05
|
||||
L83.53,46.0
|
||||
L84.38,49.37
|
||||
Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="63.28"
|
||||
android:startY="51.05"
|
||||
android:endX="84.38"
|
||||
android:endY="49.37"
|
||||
android:startColor="#6B35E8"
|
||||
android:endColor="#9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<!-- ═══ Foreground: Chevron Compass ═══ -->
|
||||
|
||||
<!-- Right wing — tertiary feather -->
|
||||
<path
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="
|
||||
M63.28,55.69
|
||||
L63.28,53.58
|
||||
L80.16,50.63
|
||||
L80.58,53.58
|
||||
Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="63.28"
|
||||
android:startY="53.58"
|
||||
android:endX="80.58"
|
||||
android:endY="53.58"
|
||||
android:startColor="#6B35E8"
|
||||
android:endColor="#9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<!-- Central diamond -->
|
||||
<path
|
||||
android:pathData="M54,42 L66,54 L54,66 L42,54 Z"
|
||||
android:strokeWidth="6.75"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="42"
|
||||
android:startY="42"
|
||||
android:endX="66"
|
||||
android:endY="66"
|
||||
android:startColor="#9B6BF0"
|
||||
android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Upper chevron -->
|
||||
<path
|
||||
android:pathData="M30,42 L54,18 L78,42"
|
||||
android:strokeWidth="6.75"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="30"
|
||||
android:startY="18"
|
||||
android:endX="78"
|
||||
android:endY="42"
|
||||
android:startColor="#9B6BF0"
|
||||
android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Lower chevron -->
|
||||
<path
|
||||
android:pathData="M30,66 L54,90 L78,66"
|
||||
android:strokeWidth="6.75"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="30"
|
||||
android:startY="66"
|
||||
android:endX="78"
|
||||
android:endY="90"
|
||||
android:startColor="#9B6BF0"
|
||||
android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Hermes Relay splash screen icon — 0.9x scale with named group for animation.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group
|
||||
android:name="main_group"
|
||||
android:pivotX="54"
|
||||
android:pivotY="54">
|
||||
|
||||
<!-- Ghost layer -->
|
||||
<path
|
||||
android:pathData="M21.2,38.5 L39.4,56.7 L68.6,56.7 L86.8,38.5"
|
||||
android:strokeWidth="4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0" android:startY="0"
|
||||
android:endX="108" android:endY="108"
|
||||
android:startColor="#596B35E8"
|
||||
android:endColor="#0D9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M21.2,22.1 L39.4,40.3"
|
||||
android:strokeWidth="4"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0" android:startY="0"
|
||||
android:endX="108" android:endY="108"
|
||||
android:startColor="#596B35E8"
|
||||
android:endColor="#0D9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M86.8,22.1 L68.6,40.3"
|
||||
android:strokeWidth="4"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="0" android:startY="0"
|
||||
android:endX="108" android:endY="108"
|
||||
android:startColor="#596B35E8"
|
||||
android:endColor="#0D9B6BF0"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Drop shadow -->
|
||||
<path android:pathData="M54,44.6 L64.8,55.4 L54,66.2 L43.2,55.4 Z"
|
||||
android:strokeColor="#33000000" android:strokeWidth="6.75"
|
||||
android:strokeLineCap="round" android:strokeLineJoin="round"/>
|
||||
<path android:pathData="M32.4,44.6 L54,23 L75.6,44.6"
|
||||
android:strokeColor="#33000000" android:strokeWidth="6.75"
|
||||
android:strokeLineCap="round" android:strokeLineJoin="round"/>
|
||||
<path android:pathData="M32.4,66.2 L54,87.8 L75.6,66.2"
|
||||
android:strokeColor="#33000000" android:strokeWidth="6.75"
|
||||
android:strokeLineCap="round" android:strokeLineJoin="round"/>
|
||||
|
||||
<!-- Foreground: Chevron Compass -->
|
||||
<path
|
||||
android:pathData="M54,43.2 L64.8,54 L54,64.8 L43.2,54 Z"
|
||||
android:strokeWidth="6.1"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient android:type="linear"
|
||||
android:startX="43.2" android:startY="43.2"
|
||||
android:endX="64.8" android:endY="64.8"
|
||||
android:startColor="#9B6BF0" android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M32.4,43.2 L54,21.6 L75.6,43.2"
|
||||
android:strokeWidth="6.1"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient android:type="linear"
|
||||
android:startX="32.4" android:startY="21.6"
|
||||
android:endX="75.6" android:endY="43.2"
|
||||
android:startColor="#9B6BF0" android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M32.4,64.8 L54,86.4 L75.6,64.8"
|
||||
android:strokeWidth="6.1"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient android:type="linear"
|
||||
android:startX="32.4" android:startY="64.8"
|
||||
android:endX="75.6" android:endY="86.4"
|
||||
android:startColor="#9B6BF0" android:endColor="#6B35E8"/>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Animated splash icon: scales in with overshoot + fades in -->
|
||||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/splash_icon">
|
||||
<target
|
||||
android:name="main_group"
|
||||
android:animation="@animator/splash_anim"/>
|
||||
</animated-vector>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -3,4 +3,5 @@
|
||||
<color name="purple_primary">#6B35E8</color>
|
||||
<color name="purple_light">#9B6BF0</color>
|
||||
<color name="dark_background">#1A1A2E</color>
|
||||
<color name="splash_icon_bg">#252540</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HermesCompanion.Splash" parent="Theme.SplashScreen">
|
||||
<style name="Theme.HermesRelay.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/dark_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.HermesCompanion</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_animated</item>
|
||||
<item name="windowSplashScreenIconBackgroundColor">@color/splash_icon_bg</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.HermesRelay</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Companion</string>
|
||||
<string name="app_name">Hermes Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Minimal theme — Compose handles all theming via Material 3 -->
|
||||
<style name="Theme.HermesCompanion" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.HermesRelay" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Hermes Relay connects to user-configured servers on arbitrary LAN IPs/hostnames.
|
||||
Cleartext (HTTP) is permitted because most local Hermes installs don't use TLS
|
||||
and Android's network-security-config doesn't support IP range restrictions.
|
||||
|
||||
Security is enforced at the application layer instead:
|
||||
- "Allow insecure connections" toggle in Settings (default: off for relay WSS)
|
||||
- Visible warning badge when connected over http:// or ws://
|
||||
- Users are encouraged to set up TLS for production deployments
|
||||
|
||||
The app makes NO connections to external services — only user-configured endpoints.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for AuthManager's pure logic: pairing code generation and AuthState types.
|
||||
*
|
||||
* AuthManager itself requires Android Context (EncryptedSharedPreferences) and
|
||||
* ChannelMultiplexer, so we cannot instantiate it in JVM tests. Instead, we test
|
||||
* the pairing code algorithm and the AuthState sealed class directly.
|
||||
*/
|
||||
class AuthManagerTest {
|
||||
|
||||
// --- Pairing code generation ---
|
||||
// Mirror the companion object constants from AuthManager:
|
||||
// PAIRING_CODE_LENGTH = 6, PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
private val codeLength = 6
|
||||
private val allowedChars = (('A'..'Z') + ('0'..'9')).toSet()
|
||||
|
||||
// Excluded ambiguous characters that should NOT be in PAIRING_CODE_CHARS
|
||||
// Note: The current AuthManager uses ALL A-Z and 0-9. This test documents
|
||||
// that behavior. If ambiguous chars (0, O, 1, I) should be excluded,
|
||||
// the implementation would need updating.
|
||||
private val ambiguousChars = setOf('0', 'O', '1', 'I')
|
||||
|
||||
private fun generatePairingCode(): String {
|
||||
val chars = ('A'..'Z') + ('0'..'9')
|
||||
return (1..codeLength)
|
||||
.map { chars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingCode_hasCorrectLength() {
|
||||
val code = generatePairingCode()
|
||||
assertEquals(codeLength, code.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingCode_onlyContainsAllowedCharacters() {
|
||||
repeat(50) {
|
||||
val code = generatePairingCode()
|
||||
for (char in code) {
|
||||
assertTrue(
|
||||
"Character '$char' not in allowed set",
|
||||
char in allowedChars
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingCode_doesNotContainLowercase() {
|
||||
repeat(50) {
|
||||
val code = generatePairingCode()
|
||||
for (char in code) {
|
||||
assertFalse(
|
||||
"Code should not contain lowercase: $char",
|
||||
char.isLowerCase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingCode_doesNotContainSpecialCharacters() {
|
||||
repeat(50) {
|
||||
val code = generatePairingCode()
|
||||
for (char in code) {
|
||||
assertTrue(
|
||||
"Code should only contain alphanumeric: $char",
|
||||
char.isLetterOrDigit()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingCode_regeneration_producesDifferentCode() {
|
||||
// With 36^6 = ~2.2 billion combinations, collision probability is negligible
|
||||
val codes = (1..20).map { generatePairingCode() }.toSet()
|
||||
assertTrue(
|
||||
"Expected multiple unique codes out of 20 generated, got ${codes.size}",
|
||||
codes.size > 1
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingCode_allCharactersInRange() {
|
||||
// Generate many codes and verify all characters are within expected bounds
|
||||
val allChars = (1..100).flatMap { generatePairingCode().toList() }.toSet()
|
||||
for (char in allChars) {
|
||||
assertTrue(
|
||||
"Character '$char' outside allowed range",
|
||||
char in 'A'..'Z' || char in '0'..'9'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- AuthState sealed class ---
|
||||
|
||||
@Test
|
||||
fun authState_unpaired_isCorrectType() {
|
||||
val state: AuthState = AuthState.Unpaired
|
||||
assertTrue(state is AuthState.Unpaired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_pairing_isCorrectType() {
|
||||
val state: AuthState = AuthState.Pairing
|
||||
assertTrue(state is AuthState.Pairing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_paired_holdsToken() {
|
||||
val state = AuthState.Paired("my-secret-token")
|
||||
assertTrue(state is AuthState.Paired)
|
||||
assertEquals("my-secret-token", state.token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_failed_holdsReason() {
|
||||
val state = AuthState.Failed("Invalid pairing code")
|
||||
assertTrue(state is AuthState.Failed)
|
||||
assertEquals("Invalid pairing code", state.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_allTypesAreDistinct() {
|
||||
val states: List<AuthState> = listOf(
|
||||
AuthState.Unpaired,
|
||||
AuthState.Pairing,
|
||||
AuthState.Paired("token"),
|
||||
AuthState.Failed("reason")
|
||||
)
|
||||
|
||||
assertEquals(4, states.map { it::class }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_unpaired_singletonEquality() {
|
||||
val a = AuthState.Unpaired
|
||||
val b = AuthState.Unpaired
|
||||
assertEquals(a, b)
|
||||
assertTrue(a === b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_pairing_singletonEquality() {
|
||||
val a = AuthState.Pairing
|
||||
val b = AuthState.Pairing
|
||||
assertEquals(a, b)
|
||||
assertTrue(a === b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_paired_dataClassEquality() {
|
||||
val a = AuthState.Paired("token123")
|
||||
val b = AuthState.Paired("token123")
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_paired_differentTokens_notEqual() {
|
||||
val a = AuthState.Paired("token-A")
|
||||
val b = AuthState.Paired("token-B")
|
||||
assertNotEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_failed_dataClassEquality() {
|
||||
val a = AuthState.Failed("bad code")
|
||||
val b = AuthState.Failed("bad code")
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_failed_differentReasons_notEqual() {
|
||||
val a = AuthState.Failed("reason A")
|
||||
val b = AuthState.Failed("reason B")
|
||||
assertNotEquals(a, b)
|
||||
}
|
||||
|
||||
// --- State transition patterns ---
|
||||
|
||||
@Test
|
||||
fun authState_transitionSequence_unpairedToPairingToPaired() {
|
||||
var state: AuthState = AuthState.Unpaired
|
||||
assertTrue(state is AuthState.Unpaired)
|
||||
|
||||
state = AuthState.Pairing
|
||||
assertTrue(state is AuthState.Pairing)
|
||||
|
||||
state = AuthState.Paired("session-token-abc")
|
||||
assertTrue(state is AuthState.Paired)
|
||||
assertEquals("session-token-abc", (state as AuthState.Paired).token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_transitionSequence_unpairedToFailed() {
|
||||
var state: AuthState = AuthState.Unpaired
|
||||
assertTrue(state is AuthState.Unpaired)
|
||||
|
||||
state = AuthState.Failed("Server unreachable")
|
||||
assertTrue(state is AuthState.Failed)
|
||||
assertEquals("Server unreachable", (state as AuthState.Failed).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authState_transitionSequence_pairingToFailed() {
|
||||
var state: AuthState = AuthState.Pairing
|
||||
assertTrue(state is AuthState.Pairing)
|
||||
|
||||
state = AuthState.Failed("Code expired")
|
||||
assertTrue(state is AuthState.Failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for ChatMessage, ToolCall, MessageRole, and ChatSession data models.
|
||||
*/
|
||||
class ChatMessageTest {
|
||||
|
||||
// --- ChatMessage creation with defaults ---
|
||||
|
||||
@Test
|
||||
fun chatMessage_creation_withRequiredFields() {
|
||||
val msg = ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.USER,
|
||||
content = "Hello",
|
||||
timestamp = 1700000000L
|
||||
)
|
||||
|
||||
assertEquals("msg-1", msg.id)
|
||||
assertEquals(MessageRole.USER, msg.role)
|
||||
assertEquals("Hello", msg.content)
|
||||
assertEquals(1700000000L, msg.timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_defaults_isStreamingFalse() {
|
||||
val msg = ChatMessage(
|
||||
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
|
||||
)
|
||||
assertFalse(msg.isStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_defaults_emptyToolCalls() {
|
||||
val msg = ChatMessage(
|
||||
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
|
||||
)
|
||||
assertTrue(msg.toolCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_defaults_emptyThinkingContent() {
|
||||
val msg = ChatMessage(
|
||||
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
|
||||
)
|
||||
assertEquals("", msg.thinkingContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_defaults_isThinkingStreamingFalse() {
|
||||
val msg = ChatMessage(
|
||||
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
|
||||
)
|
||||
assertFalse(msg.isThinkingStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_defaults_nullTokenFields() {
|
||||
val msg = ChatMessage(
|
||||
id = "1", role = MessageRole.USER, content = "", timestamp = 0L
|
||||
)
|
||||
assertNull(msg.inputTokens)
|
||||
assertNull(msg.outputTokens)
|
||||
assertNull(msg.totalTokens)
|
||||
assertNull(msg.estimatedCost)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_withAllFields() {
|
||||
val toolCalls = listOf(
|
||||
ToolCall(id = "tc-1", name = "read_file", args = "{}", result = "contents", success = true, isComplete = true)
|
||||
)
|
||||
val msg = ChatMessage(
|
||||
id = "msg-2",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Here is the file",
|
||||
timestamp = 1700000000L,
|
||||
isStreaming = true,
|
||||
toolCalls = toolCalls,
|
||||
thinkingContent = "Let me check...",
|
||||
isThinkingStreaming = true,
|
||||
inputTokens = 50,
|
||||
outputTokens = 100,
|
||||
totalTokens = 150,
|
||||
estimatedCost = 0.003
|
||||
)
|
||||
|
||||
assertTrue(msg.isStreaming)
|
||||
assertEquals(1, msg.toolCalls.size)
|
||||
assertEquals("Let me check...", msg.thinkingContent)
|
||||
assertTrue(msg.isThinkingStreaming)
|
||||
assertEquals(50, msg.inputTokens)
|
||||
assertEquals(100, msg.outputTokens)
|
||||
assertEquals(150, msg.totalTokens)
|
||||
assertEquals(0.003, msg.estimatedCost!!, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatMessage_copy_preservesFields() {
|
||||
val original = ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Hello",
|
||||
timestamp = 1000L,
|
||||
isStreaming = true
|
||||
)
|
||||
|
||||
val copy = original.copy(content = "Hello world", isStreaming = false)
|
||||
|
||||
assertEquals("msg-1", copy.id)
|
||||
assertEquals(MessageRole.ASSISTANT, copy.role)
|
||||
assertEquals("Hello world", copy.content)
|
||||
assertEquals(1000L, copy.timestamp)
|
||||
assertFalse(copy.isStreaming)
|
||||
}
|
||||
|
||||
// --- MessageRole enum ---
|
||||
|
||||
@Test
|
||||
fun messageRole_hasAllExpectedValues() {
|
||||
val values = MessageRole.values()
|
||||
assertEquals(3, values.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageRole_user() {
|
||||
assertEquals(MessageRole.USER, MessageRole.valueOf("USER"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageRole_assistant() {
|
||||
assertEquals(MessageRole.ASSISTANT, MessageRole.valueOf("ASSISTANT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageRole_system() {
|
||||
assertEquals(MessageRole.SYSTEM, MessageRole.valueOf("SYSTEM"))
|
||||
}
|
||||
|
||||
// --- ToolCall ---
|
||||
|
||||
@Test
|
||||
fun toolCall_creation_withDefaults() {
|
||||
val tc = ToolCall(
|
||||
name = "read_file",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null
|
||||
)
|
||||
|
||||
assertNull(tc.id)
|
||||
assertEquals("read_file", tc.name)
|
||||
assertNull(tc.args)
|
||||
assertNull(tc.result)
|
||||
assertNull(tc.success)
|
||||
assertFalse(tc.isComplete)
|
||||
assertNull(tc.error)
|
||||
assertNotNull(tc.startedAt) // Default to System.currentTimeMillis()
|
||||
assertNull(tc.completedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCall_pending_state() {
|
||||
val tc = ToolCall(
|
||||
id = "call-1",
|
||||
name = "write_file",
|
||||
args = """{"path": "test.txt"}""",
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
)
|
||||
|
||||
assertFalse(tc.isComplete)
|
||||
assertNull(tc.success)
|
||||
assertNull(tc.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCall_completed_state() {
|
||||
val now = System.currentTimeMillis()
|
||||
val tc = ToolCall(
|
||||
id = "call-1",
|
||||
name = "read_file",
|
||||
args = """{"path": "data.json"}""",
|
||||
result = "file contents here",
|
||||
success = true,
|
||||
isComplete = true,
|
||||
completedAt = now
|
||||
)
|
||||
|
||||
assertTrue(tc.isComplete)
|
||||
assertEquals(true, tc.success)
|
||||
assertEquals("file contents here", tc.result)
|
||||
assertEquals(now, tc.completedAt)
|
||||
assertNull(tc.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCall_failed_state() {
|
||||
val tc = ToolCall(
|
||||
id = "call-2",
|
||||
name = "dangerous_operation",
|
||||
args = null,
|
||||
result = null,
|
||||
success = false,
|
||||
isComplete = true,
|
||||
error = "Permission denied",
|
||||
completedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
assertTrue(tc.isComplete)
|
||||
assertEquals(false, tc.success)
|
||||
assertEquals("Permission denied", tc.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCall_copy_updatesCompletion() {
|
||||
val pending = ToolCall(
|
||||
id = "call-1",
|
||||
name = "tool",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
)
|
||||
|
||||
val completed = pending.copy(
|
||||
success = true,
|
||||
isComplete = true,
|
||||
result = "done",
|
||||
completedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
assertFalse(pending.isComplete)
|
||||
assertTrue(completed.isComplete)
|
||||
assertEquals(true, completed.success)
|
||||
assertEquals("done", completed.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCall_startedAt_defaultsToCurrentTime() {
|
||||
val before = System.currentTimeMillis()
|
||||
val tc = ToolCall(name = "tool", args = null, result = null, success = null)
|
||||
val after = System.currentTimeMillis()
|
||||
|
||||
assertTrue(tc.startedAt >= before)
|
||||
assertTrue(tc.startedAt <= after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolCall_duration_calculable() {
|
||||
val startTime = 1000L
|
||||
val endTime = 2500L
|
||||
val tc = ToolCall(
|
||||
name = "slow_tool",
|
||||
args = null,
|
||||
result = "done",
|
||||
success = true,
|
||||
isComplete = true,
|
||||
startedAt = startTime,
|
||||
completedAt = endTime
|
||||
)
|
||||
|
||||
val duration = tc.completedAt!! - tc.startedAt
|
||||
assertEquals(1500L, duration)
|
||||
}
|
||||
|
||||
// --- ChatSession ---
|
||||
|
||||
@Test
|
||||
fun chatSession_creation_withAllFields() {
|
||||
val session = ChatSession(
|
||||
sessionId = "sess-1",
|
||||
title = "My Session",
|
||||
model = "gpt-4",
|
||||
messageCount = 10,
|
||||
updatedAt = 1700000000L
|
||||
)
|
||||
|
||||
assertEquals("sess-1", session.sessionId)
|
||||
assertEquals("My Session", session.title)
|
||||
assertEquals("gpt-4", session.model)
|
||||
assertEquals(10, session.messageCount)
|
||||
assertEquals(1700000000L, session.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatSession_defaults() {
|
||||
val session = ChatSession(
|
||||
sessionId = "sess-2",
|
||||
title = null,
|
||||
model = null
|
||||
)
|
||||
|
||||
assertNull(session.title)
|
||||
assertNull(session.model)
|
||||
assertEquals(0, session.messageCount)
|
||||
assertEquals(0L, session.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatSession_nullableFields() {
|
||||
val session = ChatSession(
|
||||
sessionId = "s1",
|
||||
title = null,
|
||||
model = null,
|
||||
messageCount = 5
|
||||
)
|
||||
|
||||
assertNull(session.title)
|
||||
assertNull(session.model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatSession_copy_updatesTitle() {
|
||||
val original = ChatSession(sessionId = "s1", title = "Old", model = "gpt-4")
|
||||
val renamed = original.copy(title = "New Title")
|
||||
|
||||
assertEquals("New Title", renamed.title)
|
||||
assertEquals("s1", renamed.sessionId)
|
||||
assertEquals("gpt-4", renamed.model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatSession_equality() {
|
||||
val a = ChatSession(sessionId = "s1", title = "Test", model = "gpt-4")
|
||||
val b = ChatSession(sessionId = "s1", title = "Test", model = "gpt-4")
|
||||
assertEquals(a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for DataManager.AppBackup serialization and import logic.
|
||||
*
|
||||
* DataManager itself requires Android Context, but AppBackup is a
|
||||
* kotlinx.serialization data class and importSettings() is pure logic,
|
||||
* so we can test the serialization round-trip and field validation on JVM.
|
||||
*/
|
||||
class DataManagerTest {
|
||||
|
||||
private lateinit var json: Json
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
||||
// --- Serialization produces valid JSON ---
|
||||
|
||||
@Test
|
||||
fun backup_serialization_producesValidJson() {
|
||||
val backup = DataManager.AppBackup(
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
relayUrl = "wss://localhost:8767",
|
||||
theme = "dark"
|
||||
)
|
||||
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
assertNotNull(jsonStr)
|
||||
assertTrue(jsonStr.isNotEmpty())
|
||||
|
||||
// Should be parseable back
|
||||
val parsed = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
assertNotNull(parsed)
|
||||
}
|
||||
|
||||
// --- Backup contains expected keys ---
|
||||
|
||||
@Test
|
||||
fun backup_containsVersionKey() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertTrue("JSON should contain 'version'", jsonStr.contains("\"version\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_containsApiServerUrlKey() {
|
||||
val backup = DataManager.AppBackup(apiServerUrl = "http://localhost:8642")
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertTrue("JSON should contain 'apiServerUrl'", jsonStr.contains("\"apiServerUrl\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_containsRelayUrlKey() {
|
||||
val backup = DataManager.AppBackup(relayUrl = "wss://localhost:8767")
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertTrue("JSON should contain 'relayUrl'", jsonStr.contains("\"relayUrl\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_containsThemeKey() {
|
||||
val backup = DataManager.AppBackup(theme = "dark")
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertTrue("JSON should contain 'theme'", jsonStr.contains("\"theme\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_containsExportedAtKey() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertTrue("JSON should contain 'exportedAt'", jsonStr.contains("\"exportedAt\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_containsOnboardingCompletedKey() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertTrue("JSON should contain 'onboardingCompleted'", jsonStr.contains("\"onboardingCompleted\""))
|
||||
}
|
||||
|
||||
// --- Backup does NOT contain sensitive data ---
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainApiKey() {
|
||||
val backup = DataManager.AppBackup(
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
theme = "auto"
|
||||
)
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'apiKey'", jsonStr.contains("\"apiKey\""))
|
||||
assertFalse("Backup should not contain 'api_key'", jsonStr.contains("\"api_key\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainSessionToken() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'session_token'", jsonStr.contains("\"session_token\""))
|
||||
assertFalse("Backup should not contain 'sessionToken'", jsonStr.contains("\"sessionToken\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainDeviceId() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'device_id'", jsonStr.contains("\"device_id\""))
|
||||
assertFalse("Backup should not contain 'deviceId'", jsonStr.contains("\"deviceId\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_doesNotContainBearerToken() {
|
||||
val backup = DataManager.AppBackup()
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
|
||||
assertFalse("Backup should not contain 'token'", jsonStr.contains("\"token\""))
|
||||
assertFalse("Backup should not contain 'bearer'", jsonStr.lowercase().contains("\"bearer\""))
|
||||
}
|
||||
|
||||
// --- Serialization round-trip ---
|
||||
|
||||
@Test
|
||||
fun backup_roundTrip_preservesAllFields() {
|
||||
val original = DataManager.AppBackup(
|
||||
version = 2,
|
||||
serverUrl = "http://old-server:8642",
|
||||
apiServerUrl = "http://localhost:8642",
|
||||
relayUrl = "wss://localhost:8767",
|
||||
theme = "dark",
|
||||
onboardingCompleted = true,
|
||||
profiles = listOf("default", "coder"),
|
||||
exportedAt = 1700000000000L
|
||||
)
|
||||
|
||||
val jsonStr = json.encodeToString(original)
|
||||
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertEquals(original.version, restored.version)
|
||||
assertEquals(original.serverUrl, restored.serverUrl)
|
||||
assertEquals(original.apiServerUrl, restored.apiServerUrl)
|
||||
assertEquals(original.relayUrl, restored.relayUrl)
|
||||
assertEquals(original.theme, restored.theme)
|
||||
assertEquals(original.onboardingCompleted, restored.onboardingCompleted)
|
||||
assertEquals(original.profiles, restored.profiles)
|
||||
assertEquals(original.exportedAt, restored.exportedAt)
|
||||
}
|
||||
|
||||
// --- Restore from valid JSON ---
|
||||
|
||||
@Test
|
||||
fun importSettings_validJson_returnsBackup() {
|
||||
val jsonStr = """
|
||||
{
|
||||
"version": 2,
|
||||
"apiServerUrl": "http://myserver:8642",
|
||||
"relayUrl": "wss://myserver:8767",
|
||||
"theme": "light",
|
||||
"onboardingCompleted": true,
|
||||
"profiles": ["default"],
|
||||
"exportedAt": 1700000000000
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(2, result.version)
|
||||
assertEquals("http://myserver:8642", result.apiServerUrl)
|
||||
assertEquals("wss://myserver:8767", result.relayUrl)
|
||||
assertEquals("light", result.theme)
|
||||
assertTrue(result.onboardingCompleted)
|
||||
assertEquals(listOf("default"), result.profiles)
|
||||
}
|
||||
|
||||
// --- Restore from malformed JSON ---
|
||||
|
||||
@Test
|
||||
fun importSettings_malformedJson_returnsNull() {
|
||||
val malformed = "this is not json {{"
|
||||
val result = try {
|
||||
json.decodeFromString<DataManager.AppBackup>(malformed)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importSettings_emptyString_returnsNull() {
|
||||
val result = try {
|
||||
json.decodeFromString<DataManager.AppBackup>("")
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importSettings_emptyJsonObject_usesDefaults() {
|
||||
val jsonStr = "{}"
|
||||
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals(2, result.version)
|
||||
assertNull(result.serverUrl)
|
||||
assertNull(result.apiServerUrl)
|
||||
assertNull(result.relayUrl)
|
||||
assertEquals("auto", result.theme)
|
||||
assertFalse(result.onboardingCompleted)
|
||||
assertTrue(result.profiles.isEmpty())
|
||||
}
|
||||
|
||||
// --- Unknown fields are ignored ---
|
||||
|
||||
@Test
|
||||
fun importSettings_unknownFields_ignored() {
|
||||
val jsonStr = """
|
||||
{
|
||||
"version": 2,
|
||||
"apiServerUrl": "http://localhost:8642",
|
||||
"theme": "auto",
|
||||
"unknownField": "should be ignored",
|
||||
"anotherUnknown": 42
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
assertNotNull(result)
|
||||
assertEquals("http://localhost:8642", result.apiServerUrl)
|
||||
}
|
||||
|
||||
// --- Format version handling ---
|
||||
|
||||
@Test
|
||||
fun backup_defaultVersion_isTwo() {
|
||||
val backup = DataManager.AppBackup()
|
||||
assertEquals(2, backup.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importSettings_version1_compat() {
|
||||
// v1 used 'serverUrl' instead of 'apiServerUrl'/'relayUrl'
|
||||
val jsonStr = """
|
||||
{
|
||||
"version": 1,
|
||||
"serverUrl": "wss://oldserver:8767",
|
||||
"theme": "auto"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
assertEquals(1, result.version)
|
||||
assertEquals("wss://oldserver:8767", result.serverUrl)
|
||||
assertNull(result.apiServerUrl) // Not present in v1
|
||||
}
|
||||
|
||||
// --- Default values ---
|
||||
|
||||
@Test
|
||||
fun backup_defaultTheme_isAuto() {
|
||||
val backup = DataManager.AppBackup()
|
||||
assertEquals("auto", backup.theme)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_defaultOnboardingCompleted_isFalse() {
|
||||
val backup = DataManager.AppBackup()
|
||||
assertFalse(backup.onboardingCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_defaultProfiles_isEmpty() {
|
||||
val backup = DataManager.AppBackup()
|
||||
assertTrue(backup.profiles.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_nullableFields_defaultToNull() {
|
||||
val backup = DataManager.AppBackup()
|
||||
assertNull(backup.serverUrl)
|
||||
assertNull(backup.apiServerUrl)
|
||||
assertNull(backup.relayUrl)
|
||||
}
|
||||
|
||||
// --- Profiles list serialization ---
|
||||
|
||||
@Test
|
||||
fun backup_profilesList_roundTrip() {
|
||||
val profiles = listOf("default", "coder", "researcher", "creative")
|
||||
val backup = DataManager.AppBackup(profiles = profiles)
|
||||
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertEquals(profiles, restored.profiles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backup_emptyProfilesList_roundTrip() {
|
||||
val backup = DataManager.AppBackup(profiles = emptyList())
|
||||
|
||||
val jsonStr = json.encodeToString(backup)
|
||||
val restored = json.decodeFromString<DataManager.AppBackup>(jsonStr)
|
||||
|
||||
assertTrue(restored.profiles.isEmpty())
|
||||
}
|
||||
}
|
||||