Compare commits

...
17 Commits
Author SHA1 Message Date
ARIA d717c10256 README: rewrite for users — what it is, feature list, build-from-source (gateway/Android/desktop/pairing), contributing, Apache-2.0 license; add LICENSE file 2026-08-21 01:17:23 +02:00
ARIA 2f0cdbba17 HTML artifacts (Android): enable DOM storage + pin a real base URL so localStorage/sessionStorage work — the library defaults domStorageEnabled off and a null base URL leaves the page on the opaque about:blank origin where the storage APIs throw, so interactive artifacts that persist state appeared broken; basic JS (DOM/event handlers) already ran 2026-08-21 01:05:31 +02:00
ARIA 0951ebbbce android gateway: raise MAX_MESSAGE_LENGTH to 1M so long replies (and complete HTML artifacts) aren't chunked across fence-reopened messages — the stream consumer defaults to 4096 when the adapter sets no cap, and WS has no message-size limit 2026-08-21 00:43:42 +02:00
ARIA a8c4418aff HTML artifacts: render agent-sent HTML/CSS/JS code blocks in an in-app WebView (Android platform WebView, desktop JCEF/KCEF) via a full-screen preview; artifact card with Preview button + code toggle, shown only once the message stops streaming; webview-multiplatform 1.9.40 + KCEF deps, detection + unit tests 2026-08-21 00:43:39 +02:00
ARIA 8100730cba User bubbles: move timestamp + delivery checkmarks to their own bottom-right line (Telegram look, matching agent bubbles) instead of inline at the end of the text 2026-08-21 00:01:13 +02:00
ARIA bff363d579 Chat bubbles: render markdown (bold/italic/tables/syntax-highlighted code) for agent + user messages; tap-to-copy inline code with flash, code-block copy button; preserve user newlines; removed stale HANDOFF doc 2026-08-20 23:29:43 +02:00
ARIA 5b9427b10d Channel badges: flat Material star (default) + heart (favorite) icons instead of text glyphs, so favorites no longer look like the default channel 2026-08-20 21:07:52 +02:00
ARIA f5e39c0752 Channel context menu: hide Delete for the default channel (it can be renamed but not deleted; server already enforces this) 2026-08-20 21:00:36 +02:00
ARIA b681e547e7 Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices 2026-08-20 20:55:24 +02:00
ARIA 0d99ee5cf5 Back-to-bottom button: chevron FAB appears when scrolled up, tap glides to latest; new messages no longer yank the view while reading history; lane switch always lands on latest 2026-08-20 20:15:12 +02:00
ARIA 8b40eb6e4c App icon: winged-bubble adaptive icon (Android) + window/jpackage icon (desktop); concept art in app/icons/ 2026-08-20 19:48:57 +02:00
ARIA ccc40213ca Compact single-row header: tappable status bubble (green/yellow-pulsing/red) + search, status toast on tap; removed overflow menu (rename/forget) 2026-08-20 19:33:45 +02:00
ARIA c668b209b2 User theme: customizable bubble colors + background (color/image) in Settings → Appearance
- UserTheme model (ARGB ints) + LocalUserTheme, persisted via SecureStore
- HSV color picker dialog (Apply keeps open, OK applies+closes, Cancel)
- Background: solid color or image (SAF on Android, JFileChooser on desktop)
- Reasoning block: 0.6 black overlay on agent bubble color (adapts automatically)
- Contrast-aware text on bubbles + pinned contentColor on root Surface
2026-08-20 19:14:35 +02:00
ARIA de3b5fd956 Added official hermes-gateway status messages (new comment category instead of tool calls and messages) 2026-08-20 18:11:36 +02:00
ARIA 6f4bf3cb7f Long-press message selection + delete (message.delete frame, outbox removal, all-device sync) 2026-08-20 16:51:19 +02:00
ARIA 60ec2b44a7 Auto-threading, history pagination, streaming toggle + tool/reasoning display settings
- Auto-threading (Telegram topic-mode workflow): message.send {auto_thread}
  mints a fresh AI-named thread (instant derived title, LLM upgrade via
  channel.renamed); channel.created {auto:true}; the app jumps into the new
  thread and relocates the optimistic pending bubble.
- history frame: paged full message history for initial channel open /
  scroll-up pagination (reconstructed from the outbox log).
- Streaming on/off: gateway side (display.platforms.android.streaming) plus a
  per-device app toggle (Settings → Streaming); reasoning/model/tokens carried
  on message frames.
- Context menu: long-press (touch) / right-click (desktop) thread affordances
  via a KMP rightClick expect/actual.
- Settings → Reasoning: auto-collapse long reasoning blocks (default on).
- Tool detail: the gateway now always supplies full tool data — it forces
  verbose tool progress (full args → tool.start.args) and captures each
  completed call via the post_tool_call hook (output/duration/ok → tool.end).
  The app reveals the full call + output on expand (Truncated) and
  auto-expands cards in Everything mode.
2026-08-20 16:26:52 +02:00
ARIA 1d67de5f12 Composer auto-grow (1→5 lines) + in-field attach; settings screen + persisted prefs
- Composer input starts at one line and auto-grows up to 5 lines (then scrolls),
  measured via onTextLayout; attach (paperclip) moved inside the field, right of
  the text, per the Telegram reference.

- New SettingsScreen (drawer/rail entry) for Threads + Tool detail; both now
  persist via SecureStore (Android prefs / desktop JSON).

- Add AGENTS.md; rename reference screenshot to telegram_screenshot_reference.jpg.
2026-08-20 13:12:24 +02:00
65 changed files with 4855 additions and 365 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# AGENTS.md
## Hard rules
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+120 -39
View File
@@ -1,57 +1,138 @@
# Iris × Hermes # Iris × Hermes
A **native Android + Desktop** experience for [hermes-agent](https://github.com/NousResearch/hermes-agent), connected through a **gateway platform plugin**. A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
> ## ⚠️ HARD RULES Iris pairs with your running `hermes gateway` over a private WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
> 1. **`hermes-agent/` is a read-only research reference. It is git-ignored and
> must NEVER be committed, pushed, or shipped.** We only *install* our plugin
> into a live hermes home (`~/.hermes/plugins/android`); we never modify hermes
> core.
> 2. **ADB is installed and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
> Android 10 / API 29). Use it to install/launch/debug the app on-device.
## Start here ## Features
📚 **The full implementation reference library is in [`docs/`](docs/README.md).** - **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
Read `docs/00-overview.md` first, then follow the numbered docs. - **Absolute Privacy!** — everything stays on your own infrastructure
- **No file limit**
- **No character limit**
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
- **Channels** — create channels to keep track of your reports, cronjobs, webhook calls
- **Pin channels, rename them, change the color/icon**
- **Customize how your chat should look like** — color? Check! Images? Check!
- **Reasoning collapse/expandable** in the chat bubble
- **Change the default behavior** of tool & reasoning verbosity
- **Threads** — create your own, or let the AI create them with a title
- **Search messages from everywhere**
## Quickstart ## How it works
Full walkthrough: [`docs/setup.md`](docs/setup.md). ```
hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (Android / Desktop)
```
1. **Gateway:** install the plugin (`ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android`), run `hermes gateway setup` (generates the pairing token, prints the server URL), then `hermes gateway`. - `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
2. **App:** `cd app && ./gradlew :androidApp:installDebug` (Android; ADB device connected) or `./gradlew :desktopApp:run` (desktop). `hermes gateway` process and opens a WebSocket server the apps connect to.
3. **Pair:** on the app's Connect screen, enter the server URL (`ws://<gateway-ip>:8790/ws`) + pairing token, then **Test & Connect**. Zero new Python dependencies, zero hermes-core changes.
4. **Chat.** - `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
(the same app, tweaked for a big screen).
- The app is a first-class hermes *messaging platform*, so everything the gateway
already does just works: slash commands, cron delivery, `send_message` routing,
coexistence with Telegram/Discord/etc.
- Push notifications: FCM (primary) or ntfy (fallback).
### Pairing notes ## Build from source
- **Token location:** `~/.hermes/.env` on the gateway host (`ANDROID_TOKEN`), or the `hermes gateway setup` output (printed once, at generation). ### Prerequisites
- **Manual entry only:** the app has no QR scanner yet — the server prints a QR payload, but you type the URL + token.
- **Default bind is `127.0.0.1`:** for a phone on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
## The three deliverables (this monorepo) | Where | You need |
|---|---|
| Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) |
| Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device |
| Desktop build machine | JDK 17 only |
No system Gradle needed — both apps use the project wrapper (`./gradlew`).
### 1. Gateway (on the gateway host)
```bash
# hermes-agent is a separate project (not part of this repo)
cd hermes-agent && uv sync
# install the Iris plugin into the live hermes home
mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
hermes gateway status # should list "android"
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL
hermes gateway # run the gateway
```
### 2. Android app
```bash
cd app
./gradlew :androidApp:installDebug # build + install on the connected ADB device
# or just build the APK:
./gradlew :androidApp:assembleDebug # → app/androidApp/build/outputs/apk/debug/
```
### 3. Desktop app
```bash
cd app
./gradlew :desktopApp:run # dev run
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
```
### 4. Pair
On the app's **Connect** screen:
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`).
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env`
on the gateway host.
3. **Test & Connect.**
Notes:
- The app has **no QR scanner** — pairing is manual URL + token entry.
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
Full walkthrough, push setup (FCM/ntfy), and troubleshooting:
[`docs/setup.md`](docs/setup.md).
## Contributing
Contributions are welcome! Before you start:
1. **Read the docs.** The full reference library is in [`docs/`](docs/README.md) —
start with [`docs/00-overview.md`](docs/00-overview.md), then follow the
numbered docs (architecture, wire protocol, plugin design, testing, …).
2. **Know the layout.**
| Path | What | | Path | What |
|---|---| |---|---|
| `gateway-plugin/` | Python hermes **platform plugin** (`android`). Runs in the `hermes gateway` process; opens a WebSocket server the apps connect to. Zero new Python deps, zero hermes-core changes. | | `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth |
| `app/androidApp` | Native **Kotlin + Jetpack Compose** client. | | `app/shared` | KMP module with most of the client code (shared by Android + Desktop) |
| `app/desktopApp` | **Kotlin + Compose Multiplatform** client — the Android app, tweaked for a big screen (shares `app/shared`). | | `app/androidApp` | Thin Android shell (package `dev.iris.app`) |
| `app/desktopApp` | Thin desktop shell |
| `docs/` | Numbered reference library |
The Android and Desktop clients live in **one Compose Multiplatform Gradle 3. **Run the tests.**
project** (`app/`) with a shared KMP module (`app/shared`). - Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
- Live check (gateway must be running): `gateway-plugin/tests/ws_probe.py` and
`gateway-plugin/tests/e2e.py` — see [`gateway-plugin/tests/README.md`](gateway-plugin/tests/README.md).
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
must always agree.
5. **Pre-commit hooks** are configured (`.pre-commit-config.yaml`); run
`pre-commit install` once after cloning.
## Status Open an issue first for anything big, then send a pull request.
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress. ## License
- **Milestones:** see [`docs/14-milestones.md`](docs/14-milestones.md).
- **Locked decisions:** see [`docs/16-open-questions.md`](docs/16-open-questions.md).
## Quick orientation Apache License 2.0 — see [LICENSE](LICENSE).
- Architecture + rationale → [`docs/01-architecture.md`](docs/01-architecture.md)
- Wire protocol → [`docs/04-wire-protocol.md`](docs/04-wire-protocol.md) (+ [`docs/protocol/frames.schema.json`](docs/protocol/frames.schema.json))
- Gateway plugin design → [`docs/03-gateway-plugin.md`](docs/03-gateway-plugin.md)
- hermes source cheat-sheet → [`docs/15-hermes-reference.md`](docs/15-hermes-reference.md)
- Toolchain setup → [`docs/12-toolchain.md`](docs/12-toolchain.md)
</file>
@@ -11,6 +11,8 @@
<application <application
android:label="Iris" android:label="Iris"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="true" android:allowBackup="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:startColor="#262B3A"
android:endColor="#14171F"
android:startX="0"
android:startY="0"
android:endX="0"
android:endY="108" />
</aapt:attr>
</path>
<path android:pathData="M33.96,51.18a29.75,29.75 0 1,0 59.5,0a29.75,29.75 0 1,0 -59.5,0z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="63.71"
android:centerY="51.18"
android:gradientRadius="29.75"
android:startColor="#384F7CFF"
android:endColor="#004F7CFF" />
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<path
android:pathData="M41.79,43.67Q32.39,42.10 24.57,45.55"
android:strokeWidth="4.07"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startColor="#E8EAF0"
android:endColor="#7E9BFF"
android:startX="23"
android:startY="54"
android:endX="42"
android:endY="54" />
</aapt:attr>
</path>
<path
android:pathData="M41.16,50.56Q30.51,49.93 23.00,54.31"
android:strokeWidth="4.07"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startColor="#E8EAF0"
android:endColor="#7E9BFF"
android:startX="23"
android:startY="54"
android:endX="42"
android:endY="54" />
</aapt:attr>
</path>
<path
android:pathData="M41.79,57.44Q33.02,58.70 26.76,63.71"
android:strokeWidth="4.07"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient
android:startColor="#E8EAF0"
android:endColor="#7E9BFF"
android:startX="23"
android:startY="54"
android:endX="42"
android:endY="54" />
</aapt:attr>
</path>
<path
android:pathData="M49.62,66.21C47.42,71.97 44.92,73.10 41.79,75.61C46.49,74.35 50.87,71.54 54.94,66.84Z"
android:fillColor="#3D63E8" />
<path
android:pathData="M53.68,32.39H73.73A11.27,11.27 0 0,1 85,43.66V56.19A11.27,11.27 0 0,1 73.73,67.46H53.68A11.27,11.27 0 0,1 42.41,56.19V43.66A11.27,11.27 0 0,1 53.68,32.39Z">
<aapt:attr name="android:fillColor">
<gradient
android:startColor="#6E93FF"
android:endColor="#3D63E8"
android:startX="63.71"
android:startY="32.39"
android:endX="63.71"
android:endY="67.46" />
</aapt:attr>
</path>
<path
android:pathData="M50.56,51.81A13.15,13.15 0 0,1 76.86,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FF6B6B" />
<path
android:pathData="M53.06,51.81A10.65,10.65 0 0,1 74.36,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFC107" />
<path
android:pathData="M55.57,51.81A8.14,8.14 0 0,1 71.85,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#4CAF50" />
<path
android:pathData="M58.07,51.81A5.64,5.64 0 0,1 69.35,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#29B6F6" />
<path
android:pathData="M61.99,51.81a1.72,1.72 0 1,0 3.44,0a1.72,1.72 0 1,0 -3.44,0z"
android:fillColor="#7C4DFF" />
</vector>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M41.79,43.67Q32.39,42.10 24.57,45.55"
android:strokeWidth="4.07"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M41.16,50.56Q30.51,49.93 23.00,54.31"
android:strokeWidth="4.07"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M41.79,57.44Q33.02,58.70 26.76,63.71"
android:strokeWidth="4.07"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M49.62,66.21C47.42,71.97 44.92,73.10 41.79,75.61C46.49,74.35 50.87,71.54 54.94,66.84Z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M53.68,32.39H73.73A11.27,11.27 0 0,1 85,43.66V56.19A11.27,11.27 0 0,1 73.73,67.46H53.68A11.27,11.27 0 0,1 42.41,56.19V43.66A11.27,11.27 0 0,1 53.68,32.39Z"
android:strokeWidth="3"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M50.56,51.81A13.15,13.15 0 0,1 76.86,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M53.06,51.81A10.65,10.65 0 0,1 74.36,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M55.57,51.81A8.14,8.14 0 0,1 71.85,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M58.07,51.81A5.64,5.64 0 0,1 69.35,51.81"
android:strokeWidth="1.88"
android:strokeLineCap="round"
android:strokeColor="#FFFFFF" />
<path
android:pathData="M61.99,51.81a1.72,1.72 0 1,0 3.44,0a1.72,1.72 0 1,0 -3.44,0z"
android:fillColor="#FFFFFF" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
+14
View File
@@ -28,6 +28,16 @@ compose.desktop {
} }
} }
// M9: KCEF (JCEF/Chromium) for HTML artifact previews needs AWT reflection
// access on modern JDKs. Applied to the `run` task (JavaExec) and, below, to
// the jpackage `--java-options`.
afterEvaluate {
tasks.withType<JavaExec> {
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
}
}
// ── M6: native packaging (docs/11 §11.4) ───────────────────────────────── // ── M6: native packaging (docs/11 §11.4) ─────────────────────────────────
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under // `./gradlew :desktopApp:jpackage` builds a self-contained app image under
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage // build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
@@ -75,7 +85,11 @@ tasks.register<Exec>("jpackage") {
"--input", inputDir.absolutePath, "--input", inputDir.absolutePath,
"--main-jar", "desktopApp-all.jar", "--main-jar", "desktopApp-all.jar",
"--main-class", "iris.desktop.MainKt", "--main-class", "iris.desktop.MainKt",
"--icon", file("src/main/resources/icon.png").absolutePath,
"--dest", destDir.absolutePath, "--dest", destDir.absolutePath,
"--java-options", "-Xmx1g", "--java-options", "-Xmx1g",
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
) )
} }
@@ -21,6 +21,7 @@ import iris.platform.DesktopBridge
import iris.platform.DesktopSecureStore import iris.platform.DesktopSecureStore
import java.awt.Color import java.awt.Color
import java.awt.Graphics2D import java.awt.Graphics2D
import javax.imageio.ImageIO
import java.awt.RenderingHints import java.awt.RenderingHints
import java.awt.SystemTray import java.awt.SystemTray
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
@@ -45,6 +46,10 @@ import kotlinx.serialization.json.jsonPrimitive
*/ */
private val windowJson = File(System.getProperty("user.home"), ".iris/window.json") private val windowJson = File(System.getProperty("user.home"), ".iris/window.json")
// Window/taskbar icon (src/main/resources/icon.png).
private class IrisDesktop
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range. // Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
private const val TRAY_OFFLINE = 0xFF9E9E9E.toInt() private const val TRAY_OFFLINE = 0xFF9E9E9E.toInt()
private const val TRAY_CONNECTING = 0xFFFFC107.toInt() private const val TRAY_CONNECTING = 0xFFFFC107.toInt()
@@ -107,6 +112,7 @@ fun main() {
} }
Window( Window(
icon = BitmapPainter(windowIcon),
onCloseRequest = { onCloseRequest = {
if (closeToTray.get()) { if (closeToTray.get()) {
composeWindow?.isVisible = false composeWindow?.isVisible = false
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<linearGradient id="bubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6E93FF"/>
<stop offset="1" stop-color="#3D63E8"/>
</linearGradient>
<linearGradient id="wing" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="bgclip">
<rect x="16" y="16" width="480" height="480" rx="112"/>
</clipPath>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g clip-path="url(#bgclip)">
<circle cx="312" cy="240" r="190" fill="url(#glow)"/>
</g>
<g fill="none" stroke="url(#wing)" stroke-width="26" stroke-linecap="round">
<path d="M 172 192 Q 112 182 62 204"/>
<path d="M 168 236 Q 100 232 52 260"/>
<path d="M 172 280 Q 116 288 76 320"/>
</g>
<path d="M 222 336 C 208 360 192 380 172 396 C 202 388 230 370 256 340 Z" fill="#3D63E8"/>
<rect x="176" y="120" width="272" height="224" rx="72" fill="url(#bubble)"/>
<g fill="none" stroke-width="12" stroke-linecap="round">
<path d="M 228 244 A 84 84 0 0 1 396 244" stroke="#FF6B6B"/>
<path d="M 244 244 A 68 68 0 0 1 380 244" stroke="#FFC107"/>
<path d="M 260 244 A 52 52 0 0 1 364 244" stroke="#4CAF50"/>
<path d="M 276 244 A 36 36 0 0 1 348 244" stroke="#29B6F6"/>
</g>
<circle cx="312" cy="244" r="11" fill="#7C4DFF"/>
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+62
View File
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="wingL" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<linearGradient id="wingR" x1="1" y1="0" x2="0" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<clipPath id="bgclip">
<rect x="16" y="16" width="480" height="480" rx="112"/>
</clipPath>
<clipPath id="eye">
<path d="M 96 272 Q 256 148 416 272 Q 256 396 96 272 Z"/>
</clipPath>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g clip-path="url(#bgclip)">
<circle cx="256" cy="272" r="200" fill="url(#glow)"/>
</g>
<g fill="none" stroke-width="14" stroke-linecap="round">
<g stroke="url(#wingL)">
<path d="M 88 248 Q 56 236 32 244"/>
<path d="M 84 272 Q 52 268 28 280"/>
<path d="M 88 296 Q 56 300 36 314"/>
</g>
<g stroke="url(#wingR)">
<path d="M 424 248 Q 456 236 480 244"/>
<path d="M 428 272 Q 460 268 484 280"/>
<path d="M 424 296 Q 456 300 476 314"/>
</g>
</g>
<g clip-path="url(#eye)">
<path d="M 256 272 L 256 184 A 88 88 0 0 1 332 228 Z" fill="#7C4DFF"/>
<path d="M 256 272 L 332 228 A 88 88 0 0 1 332 316 Z" fill="#4F7CFF"/>
<path d="M 256 272 L 332 316 A 88 88 0 0 1 256 360 Z" fill="#29B6F6"/>
<path d="M 256 272 L 256 360 A 88 88 0 0 1 180 316 Z" fill="#4CAF50"/>
<path d="M 256 272 L 180 316 A 88 88 0 0 1 180 228 Z" fill="#FFC107"/>
<path d="M 256 272 L 180 228 A 88 88 0 0 1 256 184 Z" fill="#FF6B6B"/>
<circle cx="256" cy="272" r="38" fill="#10131C"/>
<circle cx="272" cy="254" r="10" fill="#FFFFFF" opacity="0.9"/>
<circle cx="242" cy="290" r="5" fill="#FFFFFF" opacity="0.35"/>
</g>
<path d="M 96 272 Q 256 148 416 272 Q 256 396 96 272 Z" fill="none" stroke="#E8EAF0" stroke-width="12" stroke-linejoin="round"/>
<path d="M 262 372 L 238 414 L 254 412 L 244 444 L 274 398 L 257 400 Z" fill="#FFC107"/>
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<linearGradient id="planeTop" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#8FB0FF"/>
<stop offset="1" stop-color="#5B86FF"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
<clipPath id="bgclip">
<rect x="16" y="16" width="480" height="480" rx="112"/>
</clipPath>
</defs>
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
<g clip-path="url(#bgclip)">
<circle cx="320" cy="220" r="190" fill="url(#glow)"/>
</g>
<g fill="none" stroke-width="16" stroke-linecap="round">
<path d="M 60 368 Q 172 348 258 284" stroke="#FF6B6B"/>
<path d="M 84 396 Q 196 376 284 312" stroke="#FFC107"/>
<path d="M 108 424 Q 220 404 308 340" stroke="#4CAF50"/>
<path d="M 132 452 Q 244 432 332 368" stroke="#29B6F6"/>
</g>
<path d="M 396 116 L 216 212 L 296 258 Z" fill="url(#planeTop)"/>
<path d="M 396 116 L 296 258 L 316 326 Z" fill="#4F7CFF"/>
<path d="M 216 212 L 296 258 L 316 326 Z" fill="#2A3550"/>
<path d="M 436 78 L 441 89 L 452 94 L 441 99 L 436 110 L 431 99 L 420 94 L 431 89 Z" fill="#E8EAF0" opacity="0.9"/>
<path d="M 458 143 L 461 149 L 467 152 L 461 155 L 458 161 L 455 155 L 449 152 L 455 149 Z" fill="#E8EAF0" opacity="0.6"/>
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#262B3A"/>
<stop offset="1" stop-color="#14171F"/>
</linearGradient>
<linearGradient id="bubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6E93FF"/>
<stop offset="1" stop-color="#3D63E8"/>
</linearGradient>
<linearGradient id="wing" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#E8EAF0"/>
<stop offset="1" stop-color="#7E9BFF"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<circle cx="308.92" cy="240.64" r="162.17" fill="url(#glow)"/>
<g fill="none" stroke="url(#wing)" stroke-width="22.19" stroke-linecap="round">
<path d="M 189.42 199.67 Q 138.21 191.13 95.53 209.91"/>
<path d="M 186.01 237.22 Q 127.97 233.81 87.01 257.71"/>
<path d="M 189.42 274.78 Q 141.63 281.61 107.48 308.92"/>
</g>
<path d="M 232.10 322.58 C 220.15 343.06 206.49 360.13 189.42 373.79 C 215.03 366.96 238.93 351.60 261.12 325.99 Z" fill="#3D63E8"/>
<rect x="192.84" y="138.21" width="232.16" height="191.19" rx="61.45" fill="url(#bubble)"/>
<g fill="none" stroke-width="10.24" stroke-linecap="round">
<path d="M 237.22 244.05 A 71.70 71.70 0 0 1 380.62 244.05" stroke="#FF6B6B"/>
<path d="M 250.88 244.05 A 58.04 58.04 0 0 1 366.96 244.05" stroke="#FFC107"/>
<path d="M 264.54 244.05 A 44.38 44.38 0 0 1 353.30 244.05" stroke="#4CAF50"/>
<path d="M 278.19 244.05 A 30.73 30.73 0 0 1 339.65 244.05" stroke="#29B6F6"/>
</g>
<circle cx="308.92" cy="244.05" r="9.39" fill="#7C4DFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+2
View File
@@ -10,6 +10,8 @@ dependencyResolutionManagement {
repositories { repositories {
mavenCentral() mavenCentral()
google() google()
// KCEF (desktop WebView backend) pulls jogamp artifacts from here.
maven("https://jogamp.org/deployment/maven")
} }
} }
+30
View File
@@ -10,6 +10,15 @@ val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0" val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3" val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0" val okhttpVersion = "4.12.0"
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
val webviewVersion = "1.9.40"
val kcefVersion = "2024.04.20.3"
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
// and the -code module for language-aware syntax highlighting (Highlights).
val markdownVersion = "0.33.0"
kotlin { kotlin {
androidTarget() androidTarget()
@@ -27,11 +36,32 @@ kotlin {
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion") implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$composeVersion") implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion") implementation("org.jetbrains.compose.ui:ui:$composeVersion")
// Full Material icon set (Icons.Filled.ContentCopy for the code
// copy buttons); -core only ships a small subset.
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based // OkHttp is a JVM library; both targets here are JVM-based
// (androidTarget + jvm("desktop")), so it resolves on both. // (androidTarget + jvm("desktop")), so it resolves on both.
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
// Markdown rendering for chat bubbles (M8): core parser/renderer,
// Material 3 themed defaults, and the -code module for syntax
// highlighting of fenced code blocks (Highlights).
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
// M9: HTML artifact previews — WebView composable (platform WebView
// on Android, KCEF/JCEF on desktop).
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
// webview library exposes it transitively, but we reference KCEF
// directly (init + progress) so declare it explicitly.
desktopMain.dependencies {
implementation("dev.datlag:kcef:$kcefVersion")
} }
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only). // M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies { androidMain.dependencies {
@@ -82,6 +82,31 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
} }
} }
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
}
try {
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val name = queryDisplayName(context, uri) ?: "background"
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
val dir = File(context.filesDir, "theme").apply { mkdirs() }
val target = File(dir, "background${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
}
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
}
private fun queryDisplayName(context: Context, uri: Uri): String? { private fun queryDisplayName(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { c -> context.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
@@ -102,6 +127,49 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
} }
} }
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
try {
// inJustDecodeBounds fills [bounds] and returns null by design — only
// the outWidth/outHeight are meaningful here.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null
val stream = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
bitmap.recycle()
android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP)
} catch (e: Exception) {
null
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
@Composable @Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
val path = media.localPath val path = media.localPath
@@ -6,6 +6,7 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import iris.data.SecureStore import iris.data.SecureStore
import iris.ui.theme.UserTheme
import java.util.UUID import java.util.UUID
/** /**
@@ -97,6 +98,42 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty() get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply() set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
override var threadsEnabled: Boolean
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
override var toolDetail: String
get() = prefs.getString(KEY_TOOL_DETAIL, "truncated").orEmpty().ifBlank { "truncated" }
set(value) = prefs.edit().putString(KEY_TOOL_DETAIL, value).apply()
override var streamingEnabled: Boolean
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
override var userBubbleColor: Int
get() = prefs.getInt(KEY_USER_BUBBLE_COLOR, UserTheme.DEFAULT_USER_BUBBLE)
set(value) = prefs.edit().putInt(KEY_USER_BUBBLE_COLOR, value).apply()
override var agentBubbleColor: Int
get() = prefs.getInt(KEY_AGENT_BUBBLE_COLOR, UserTheme.DEFAULT_AGENT_BUBBLE)
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int
get() = prefs.getInt(KEY_BACKGROUND_COLOR, UserTheme.DEFAULT_BACKGROUND)
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override fun savePairing(url: String, token: String) { override fun savePairing(url: String, token: String) {
serverUrl = url serverUrl = url
this.token = token this.token = token
@@ -116,5 +153,14 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_FCM_TOKEN = "fcm_token" const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server" const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
const val KEY_BACKGROUND_MODE = "background_mode"
const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
} }
} }
@@ -0,0 +1,48 @@
package iris.platform
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
/**
* M9: HTML artifact preview on Android — the platform WebView.
*
* JavaScript is on by default in the library's WebSettings, but interactive
* artifacts also need DOM storage (localStorage/sessionStorage), which the
* library leaves disabled and which additionally requires the document to sit
* on a real origin. Loading with a null base URL puts the page on the opaque
* `about:blank` origin, where the storage APIs are inaccessible, so we pin a
* real (unresolvable) base URL and switch DOM storage on.
*/
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier)
}
@Composable
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
if (dispatcher != null) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
}
dispatcher.addCallback(callback)
onDispose { callback.remove() }
}
onDispose { }
}
return this
}
@@ -0,0 +1,5 @@
package iris.ui
import androidx.compose.ui.Modifier
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier = this
@@ -1,24 +1,29 @@
package iris package iris
import androidx.compose.foundation.background import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import iris.data.SecureStore import iris.data.SecureStore
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.setActiveController import iris.platform.setActiveController
import iris.state.IrisController import iris.state.IrisController
import iris.ui.ArtifactPreviewOverlay
import iris.ui.screens.ChatScreen import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen import iris.ui.screens.ConnectScreen
import iris.ui.screens.ConnectingScreen
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme import iris.ui.theme.IrisTheme
import iris.util.hostFromUrl import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
@@ -49,9 +54,33 @@ fun IrisApp(
} }
} }
val state by controller.client.state.collectAsState() val state by controller.client.state.collectAsState()
val theme by controller.theme.collectAsState()
IrisTheme { IrisTheme {
Surface(modifier = Modifier.background(IrisColors.background)) { CompositionLocalProvider(LocalUserTheme provides theme) {
// The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the
// Surface's own colorScheme.surface fill and never show through.
// contentColor is pinned to the app's light text color — Surface
// would otherwise derive it from the user's background (black
// text on a light background), which would also darken text that
// sits on the app's own dark panels.
Surface(
modifier = Modifier.fillMaxSize(),
color = theme.background,
contentColor = IrisColors.onBackground,
) {
val bgImage = rememberBackgroundImage(theme)
Box(modifier = Modifier.fillMaxSize()) {
val bmp = bgImage
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
val s = state val s = state
when (s) { when (s) {
GatewayClient.State.Disconnected -> GatewayClient.State.Disconnected ->
@@ -63,11 +92,16 @@ fun IrisApp(
prefillToken = store.token, prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}", initialError = "Pairing rejected: ${s.message}",
) )
// M7: initial connect in flight — dedicated screen, not an empty chat. // Connecting / Reconnecting / Connected all render the chat; the header
GatewayClient.State.Connecting -> // status bubble + connection banner show the link state
ConnectingScreen(hostFromUrl(store.serverUrl)) // without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller) else -> ChatScreen(controller)
} }
// M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open.
ArtifactPreviewOverlay()
}
}
} }
} }
} }
@@ -54,6 +54,7 @@ class ChannelStore {
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
list.sortedWith( list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault } compareByDescending<ChannelInfo> { it.isDefault }
.thenByDescending { it.favorite }
.thenBy { it.kind != "thread" } .thenBy { it.kind != "thread" }
.thenBy { it.name.lowercase() }, .thenBy { it.name.lowercase() },
) )
@@ -22,6 +22,7 @@ import iris.protocol.TYPE_TOOL_START
import iris.protocol.ToolEndPayload import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload import iris.protocol.ToolStartPayload
import iris.util.nowMillis
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -50,7 +51,9 @@ enum class MsgStatus {
Failed, // send failed (error frame); tap the bubble to retry Failed, // send failed (error frame); tap the bubble to retry
} }
/** A chat message (user / assistant / commentary / streaming bubble). */ /** A chat message (user / assistant / commentary / streaming bubble).
* [isSystem] marks a locally generated, centered notice (gateway restart /
* online) — not a user or agent turn, not selectable or deletable. */
data class MessageItem( data class MessageItem(
override val id: String, override val id: String,
val role: String, val role: String,
@@ -64,6 +67,7 @@ data class MessageItem(
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val media: List<MediaItem> = emptyList(), val media: List<MediaItem> = emptyList(),
val isSystem: Boolean = false,
) : ChatItem ) : ChatItem
/** /**
@@ -106,6 +110,12 @@ class ChatStore {
private var localSeq = 0 private var localSeq = 0
private var toolSeq = 0 private var toolSeq = 0
/** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop`
* (Settings → "Streaming"). The gateway keeps streaming (other devices
* may want it); this is a per-device display preference. */
var streamingEnabled: Boolean = true
companion object { companion object {
const val DEFAULT_LANE = "android:default" const val DEFAULT_LANE = "android:default"
@@ -153,6 +163,17 @@ class ChatStore {
return id return id
} }
/** Append a locally generated, centered system notice (gateway restart /
* online) to [lane]. Not a user or agent turn: it is not selectable,
* deletable, or echoed to the server. */
fun addSystemMessage(lane: String, text: String) {
localSeq++
val id = "sys_$localSeq"
updateLane(lane) {
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
}
}
// ── Frame reconciliation ────────────────────────────────────────────── // ── Frame reconciliation ──────────────────────────────────────────────
/** Reconcile a server frame into the cache (routed by chat/thread lane). */ /** Reconcile a server frame into the cache (routed by chat/thread lane). */
@@ -176,6 +197,10 @@ class ChatStore {
private fun onMessage(lane: String, frame: Frame) { private fun onMessage(lane: String, frame: Frame) {
val p = frame.payloadAs<MessagePayload>() ?: return val p = frame.payloadAs<MessagePayload>() ?: return
// Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane.
// Relocate it so the echo (below) lands in the thread lane.
if (p.role == ROLE_USER) relocatePendingToThreadLane(lane, p)
updateLane(lane) { list -> updateLane(lane) { list ->
val byId = list.indexOfFirst { it.id == p.messageId } val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) { if (byId >= 0) {
@@ -222,6 +247,27 @@ class ChatStore {
} }
} }
/**
* Remove the matching optimistic user bubble from the parent flat lane of
* [lane] (an auto-created thread). The gateway moved the message into the
* fresh thread, so the pending bubble must follow it; the echo then replaces
* it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane.
*/
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
val (chatId, threadId) = parseLane(lane)
if (threadId == null) return
val flatLane = chatId
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx = flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
/** Merge server media refs into existing items, keeping local paths. */ /** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> { private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
if (incoming.isEmpty()) return existing if (incoming.isEmpty()) return existing
@@ -239,6 +285,7 @@ class ChatStore {
// ── message.start (open a live streaming bubble) ────────────────────── // ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(lane: String, frame: Frame) { private fun onMessageStart(lane: String, frame: Frame) {
if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list if (list.any { it.id == p.messageId }) list
@@ -336,6 +383,14 @@ class ChatStore {
private fun onCommentary(lane: String, frame: Frame) { private fun onCommentary(lane: String, frame: Frame) {
val p = frame.payloadAs<CommentaryPayload>() ?: return val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are rendered
// as a centered system notice by the controller, on the down/up state
// transition. The server also emits the "restarting" notice as a
// commentary frame (and the sync catch-up can replay it *after* the
// local "online" notice), so drop it here: the app is the source of
// truth for these notices, which keeps the order (restarting → online)
// and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list if (list.any { it.id == p.messageId }) list
else list + MessageItem( else list + MessageItem(
@@ -345,6 +400,14 @@ class ChatStore {
} }
} }
/** True for hermes gateway lifecycle notices (same wording on all apps). */
private fun isGatewayLifecycleNotice(text: String): Boolean {
val t = text.trim()
return t.contains("Gateway restarting") ||
t.contains("Gateway shutting down") ||
t.contains("Gateway online")
}
// ── M4: media.offer (agent produced media; pull it) ───────────────────── // ── M4: media.offer (agent produced media; pull it) ─────────────────────
/** /**
@@ -412,6 +475,51 @@ class ChatStore {
if (changed) _lanes.value = map if (changed) _lanes.value = map
} }
/**
* Remove messages by id from every lane (a `message.deleted` frame). The
* server is authoritative: the frame carries no lane, and a message id is
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
* id is absent, e.g. a local-only id another device never had).
*/
fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/**
* Finalize in-flight items after the gateway goes away (restart / network
* drop): open tool cards are closed as interrupted and live streaming
* bubbles are stopped, so nothing spins or streams forever. The in-flight
* turn is gone server-side, so its terminal frames (tool.end /
* message.stop) will never arrive to close them.
*/
fun finalizeInterrupted() {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
}
}
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/** M7: mark all pending user messages as failed (gateway error frame). */ /** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() { fun failPending() {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
@@ -441,6 +549,27 @@ class ChatStore {
} }
} }
/**
* Load a history page into [lane] (oldest → newest). The history is the
* authoritative full list of final messages for the lane; it replaces the
* lane's final messages and preserves non-final items (tool cards, live
* streaming bubbles, commentary) that are not part of the history. Used to
* restore the view on first open of a chat / after a process death, where
* the in-memory store is empty and the `sync` delta does not cover older
* messages.
*/
fun loadHistory(lane: String, messages: List<MessageItem>) {
updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet()
val preserved = list.filter { item ->
item !is MessageItem || item.id !in historyIds
}
(messages + preserved).sortedBy { item ->
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
}
}
}
fun clear() { fun clear() {
_lanes.value = emptyMap() _lanes.value = emptyMap()
} }
@@ -30,6 +30,33 @@ interface SecureStore {
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */ /** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
var ntfyServer: String var ntfyServer: String
/** UI setting: show threads (topics) in the chat view. */
var threadsEnabled: Boolean
/** UI setting: tool verbosity — "everything" / "truncated" / "nothing". */
var toolDetail: String
/** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean
/** UI setting: user bubble color (ARGB int). */
var userBubbleColor: Int
/** UI setting: agent bubble color (ARGB int). */
var agentBubbleColor: Int
/** UI setting: background mode — "color" or "image". */
var backgroundMode: String
/** UI setting: background color (ARGB int). */
var backgroundColor: Int
/** UI setting: background image path (empty when unused). */
var backgroundImagePath: String
fun savePairing(url: String, token: String) fun savePairing(url: String, token: String)
fun clear() fun clear()
} }
@@ -92,6 +92,10 @@ class GatewayClient(
private var socket: WebSocket? = null private var socket: WebSocket? = null
private var nextRequestId = 1 private var nextRequestId = 1
private var attempt = 0 private var attempt = 0
// True once a connection has been established this session; reset by
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow() private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>() private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
@@ -118,6 +122,7 @@ class GatewayClient(
fun start() { fun start() {
if (connectJob?.isActive == true) return if (connectJob?.isActive == true) return
attempt = 0 attempt = 0
hasConnected = false
connectJob = scope.launch { connectLoop() } connectJob = scope.launch { connectLoop() }
} }
@@ -144,7 +149,7 @@ class GatewayClient(
_state.value = State.Disconnected _state.value = State.Disconnected
return return
} }
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting _state.value = if (hasConnected) State.Reconnecting else State.Connecting
val dial = dial(url, token) val dial = dial(url, token)
when (val result = dial.result) { when (val result = dial.result) {
is DialResult.AuthFailed -> { is DialResult.AuthFailed -> {
@@ -153,6 +158,7 @@ class GatewayClient(
return return
} }
DialResult.Connected -> { DialResult.Connected -> {
hasConnected = true
attempt = 0 attempt = 0
lastLiveness = TimeSource.Monotonic.markNow() lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await() dial.closed.await()
@@ -289,16 +295,19 @@ class GatewayClient(
// ── Outbound ────────────────────────────────────────────────────────── // ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). /** Send a text message (fire-and-forget; the server echoes it back).
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs). */ * M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
* [autoThread] asks the gateway to mint a fresh thread for the message
* (auto-threading, docs/06 §6.3). */
fun sendMessage( fun sendMessage(
chatId: String, chatId: String,
text: String, text: String,
threadId: String? = null, threadId: String? = null,
mediaRefs: List<String> = emptyList(), mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
) { ) {
val ws = socket ?: return val ws = socket ?: return
val id = nextRequestId++ val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs).toWire()) ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
} }
// ── M4: media upload / pull ─────────────────────────────────────────── // ── M4: media upload / pull ───────────────────────────────────────────
@@ -20,12 +20,33 @@ data class PickedFile(
@Composable @Composable
expect fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) expect fun MediaFilePicker(onPicked: (PickedFile?) -> Unit)
/**
* Image-only file picker (SAF on Android, FileChooser on desktop). The picked
* image is copied into the app's persistent storage; [onPicked] receives the
* staged file, or null when the user cancels.
*/
@Composable
expect fun ImageFilePicker(onPicked: (PickedFile?) -> Unit)
/** Base directory for the media cache (app-specific cache dir). */ /** Base directory for the media cache (app-specific cache dir). */
expect fun mediaCacheBaseDir(): String expect fun mediaCacheBaseDir(): String
/** Load an image from a local path for display. Null on failure. */ /** Load an image from a local path for display. Null on failure. */
expect suspend fun loadMediaImage(path: String): ImageBitmap? expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
/**
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
* the longest edge. Null on failure. Used for channel icons (stored on the
* gateway, synced across devices).
*/
expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String?
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */ /** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable @Composable
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier) expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
@@ -0,0 +1,18 @@
package iris.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* M9: renders [html] in an in-app WebView with JavaScript enabled.
*
* Platform actuals: Android → the platform WebView; desktop → JCEF (KCEF),
* whose Chromium binary is downloaded on first use (the caller shows a
* progress indicator until it is ready).
*/
@Composable
expect fun PlatformWebView(html: String, modifier: Modifier)
/** Consumes the system back button (Android) while applied; no-op elsewhere. */
@Composable
expect fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier
@@ -3,6 +3,7 @@ package iris.protocol
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -41,6 +42,10 @@ const val TYPE_TYPING = "typing"
const val TYPE_MESSAGE_START = "message.start" const val TYPE_MESSAGE_START = "message.start"
const val TYPE_MESSAGE_UPDATE = "message.update" const val TYPE_MESSAGE_UPDATE = "message.update"
const val TYPE_MESSAGE_STOP = "message.stop" const val TYPE_MESSAGE_STOP = "message.stop"
// Message deletion (app requests; broadcast to all devices)
const val TYPE_MESSAGE_DELETE = "message.delete"
const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start" const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress" const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end" const val TYPE_TOOL_END = "tool.end"
@@ -64,6 +69,8 @@ const val TYPE_STATUS = "status"
const val TYPE_CHANNEL_CREATE = "channel.create" const val TYPE_CHANNEL_CREATE = "channel.create"
const val TYPE_CHANNEL_RENAME = "channel.rename" const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
const val TYPE_CHANNEL_ICON = "channel.icon"
const val TYPE_CHANNEL_DELETE = "channel.delete" const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created" const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed" const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -73,6 +80,7 @@ const val TYPE_SEARCH = "search"
const val TYPE_SEARCH_RESULTS = "search.results" const val TYPE_SEARCH_RESULTS = "search.results"
const val TYPE_SYNC = "sync" const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done" const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history"
// ── Error codes ───────────────────────────────────────────────────────── // ── Error codes ─────────────────────────────────────────────────────────
@@ -149,6 +157,17 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false, @SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null, @SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false, val archived: Boolean = false,
/** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false,
/** Cosmetic favorite flag (sorts to the top of the channel list). */
val favorite: Boolean = false,
/** Cosmetic icon: a base64-encoded image (PNG/JPEG), or null for the
* auto-generated letter avatar. */
val icon: String? = null,
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */
val color: String? = null,
) )
@Serializable @Serializable
@@ -289,6 +308,9 @@ data class MessageSendPayload(
val text: String, val text: String,
@SerialName("reply_to") val replyTo: String? = null, @SerialName("reply_to") val replyTo: String? = null,
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(), @SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
/** Ask the gateway to mint a fresh thread for this message (auto-
* threading; only honored in a channel's flat lane, docs/06 §6.3). */
@SerialName("auto_thread") val autoThread: Boolean = false,
) )
// ── typing / error / ping ─────────────────────────────────────────────── // ── typing / error / ping ───────────────────────────────────────────────
@@ -314,6 +336,17 @@ data class ChannelCreatePayload(
@Serializable @Serializable
data class ChannelRenamePayload(val name: String) data class ChannelRenamePayload(val name: String)
@Serializable
data class ChannelFavoritePayload(val on: Boolean)
@Serializable
data class ChannelIconPayload(
/** base64-encoded image, or null to clear the icon. */
val icon: String? = null,
/** "#RRGGBB" avatar color, or null to clear it. */
val color: String? = null,
)
@Serializable @Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList()) data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
@@ -356,6 +389,36 @@ data class SyncPayload(val cursor: Long)
@Serializable @Serializable
data class SyncDonePayload(val cursor: Long) data class SyncDonePayload(val cursor: Long)
// ── history (full message history for a chat/thread) ────────────────────
/** A final message in a history page (user echo / assistant reply). */
@Serializable
data class HistoryMessage(
@SerialName("message_id") val messageId: String,
val role: String,
val text: String,
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
val media: List<MediaRef> = emptyList(),
)
@Serializable
data class HistoryPayload(
val messages: List<HistoryMessage> = emptyList(),
@SerialName("has_more") val hasMore: Boolean = false,
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
)
// ── message deletion ──────────────────────────────────────────────────────
/** The message(s) removed from a chat/thread (response + broadcast). */
@Serializable
data class MessageDeletedPayload(
@SerialName("message_ids") val messageIds: List<String> = emptyList(),
)
// ── M5: push / notifications ──────────────────────────────────────────── // ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */ /** Notification kinds (mirror of protocol.NOTIF_*). */
@@ -424,6 +487,7 @@ fun messageSendFrame(
text: String, text: String,
threadId: String? = null, threadId: String? = null,
mediaRefs: List<String> = emptyList(), mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
): Frame = ): Frame =
Frame( Frame(
id = id, id = id,
@@ -432,7 +496,7 @@ fun messageSendFrame(
threadId = threadId, threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement( payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(), MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs), MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
), ),
) )
@@ -465,6 +529,28 @@ fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
fun channelSetDefaultFrame(id: Int, chatId: String): Frame = fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId) Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_FAVORITE,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on),
),
)
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_ICON,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color),
),
)
fun channelDeleteFrame(id: Int, chatId: String): Frame = fun channelDeleteFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId) Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
@@ -499,6 +585,45 @@ fun syncFrame(id: Int, cursor: Long): Frame =
), ),
) )
/** Request a page of full message history for a chat/thread (initial open /
* restart restore). [beforeMessageId] returns the page older than that id
* (null = newest page). */
fun historyFrame(
id: Int,
chatId: String,
threadId: String? = null,
beforeMessageId: String? = null,
limit: Int = 50,
): Frame =
Frame(
id = id,
type = TYPE_HISTORY,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit)
},
)
/** Request deletion of the given message(s) in a chat/thread. The server
* removes them from the outbox and broadcasts `message.deleted`. */
fun messageDeleteFrame(
id: Int,
chatId: String,
messageIds: List<String>,
threadId: String? = null,
): Frame =
Frame(
id = id,
type = TYPE_MESSAGE_DELETE,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
},
)
// ── M4 frame builders ──────────────────────────────────────────────────── // ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame( fun mediaUploadStartFrame(
@@ -13,8 +13,13 @@ import iris.platform.PickedFile
import iris.platform.isAppForeground import iris.platform.isAppForeground
import iris.platform.mediaCacheBaseDir import iris.platform.mediaCacheBaseDir
import iris.platform.postSystemNotification import iris.platform.postSystemNotification
import iris.protocol.ChannelDeletedPayload
import iris.protocol.ChannelInfo
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
import iris.protocol.HistoryMessage
import iris.protocol.HistoryPayload
import iris.protocol.MediaOfferPayload import iris.protocol.MediaOfferPayload
import iris.protocol.MessageDeletedPayload
import iris.protocol.MessagePayload import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload import iris.protocol.NotificationPayload
@@ -25,6 +30,7 @@ import iris.protocol.SearchResultsPayload
import iris.protocol.StatusPayload import iris.protocol.StatusPayload
import iris.protocol.SyncDonePayload import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_ERROR import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_NOTIFICATION import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED import iris.protocol.TYPE_CHANNEL_DELETED
@@ -33,6 +39,8 @@ import iris.protocol.TYPE_CHANNEL_RENAMED
import iris.protocol.TYPE_COMMENTARY import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE import iris.protocol.TYPE_MESSAGE_UPDATE
@@ -47,11 +55,17 @@ import iris.protocol.TYPE_TYPING
import iris.protocol.TypingPayload import iris.protocol.TypingPayload
import iris.protocol.channelCreateFrame import iris.protocol.channelCreateFrame
import iris.protocol.channelDeleteFrame import iris.protocol.channelDeleteFrame
import iris.protocol.channelFavoriteFrame
import iris.protocol.channelIconFrame
import iris.protocol.channelListFrame import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame import iris.protocol.channelRenameFrame
import iris.protocol.channelSetDefaultFrame import iris.protocol.channelSetDefaultFrame
import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
import iris.protocol.searchFrame import iris.protocol.searchFrame
import iris.protocol.syncFrame import iris.protocol.syncFrame
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -80,8 +94,9 @@ class IrisController(
private val _typing = MutableStateFlow(false) private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow() val typing: StateFlow<Boolean> = _typing.asStateFlow()
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */ /** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely.
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED) * Persisted (Settings → "Tool detail"). */
private val _toolDetail = MutableStateFlow(ToolDetail.fromName(store.toolDetail))
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow() val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() { fun cycleToolDetail() {
@@ -90,22 +105,103 @@ class IrisController(
ToolDetail.TRUNCATED -> ToolDetail.NOTHING ToolDetail.TRUNCATED -> ToolDetail.NOTHING
ToolDetail.NOTHING -> ToolDetail.EVERYTHING ToolDetail.NOTHING -> ToolDetail.EVERYTHING
} }
store.toolDetail = _toolDetail.value.name.lowercase()
}
fun setToolDetail(detail: ToolDetail) {
if (detail == _toolDetail.value) return
_toolDetail.value = detail
store.toolDetail = detail.name.lowercase()
} }
/** Home channel id (from hello.ack; default until then). */ /** Home channel id (from hello.ack; default until then). */
private val _homeChannel = MutableStateFlow("android:default") private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow() val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
/** Lanes whose full history has been loaded this session (in-memory; reset
* on a process death, which is exactly when a reload is needed). The
* `sync` delta can seed a lane with recent frames without it being opened,
* so "lane is empty" is not a reliable first-open signal. */
private val historyLoaded = mutableSetOf<String>()
/** Gateway health state (M5: status frame; null = never received). */ /** Gateway health state (M5: status frame; null = never received). */
private val _gatewayStatus = MutableStateFlow<String?>(null) private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── M3: threads toggle (per-app for now; per-channel lands later) ───── // ── M3: threads toggle (per-app for now; per-channel lands later) ─────
private val _threadsEnabled = MutableStateFlow(false) // Persisted (Settings → "Threads").
private val _threadsEnabled = MutableStateFlow(store.threadsEnabled)
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow() val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
fun toggleThreads() { fun toggleThreads() {
_threadsEnabled.value = !_threadsEnabled.value _threadsEnabled.value = !_threadsEnabled.value
store.threadsEnabled = _threadsEnabled.value
}
// ── Streaming toggle (Settings → "Streaming") ──────────────────────────
// Persisted. When off, the app ignores message.start/update frames and
// shows each reply as a single final message (per-device display
// preference, like tool verbosity — the gateway keeps streaming).
private val _streamingEnabled = MutableStateFlow(store.streamingEnabled)
val streamingEnabled: StateFlow<Boolean> = _streamingEnabled.asStateFlow()
fun toggleStreaming() {
_streamingEnabled.value = !_streamingEnabled.value
store.streamingEnabled = _streamingEnabled.value
chat.streamingEnabled = _streamingEnabled.value
}
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
// Persisted. When on, long reasoning blocks start collapsed (short ones
// stay expanded); when off, all reasoning blocks start expanded.
private val _reasoningAutoCollapse = MutableStateFlow(store.reasoningAutoCollapse)
val reasoningAutoCollapse: StateFlow<Boolean> = _reasoningAutoCollapse.asStateFlow()
fun toggleReasoningAutoCollapse() {
_reasoningAutoCollapse.value = !_reasoningAutoCollapse.value
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
}
// ── Appearance (Settings → Appearance) ────────────────────────────────
// User-customizable bubble colors + background (color or image).
// Persisted as ARGB ints + an image path (see UserTheme).
private val _theme = MutableStateFlow(
UserTheme(
userBubbleColor = store.userBubbleColor,
agentBubbleColor = store.agentBubbleColor,
backgroundMode = BackgroundMode.entries.firstOrNull {
it.name.equals(store.backgroundMode, ignoreCase = true)
} ?: BackgroundMode.Color,
backgroundColor = store.backgroundColor,
backgroundImagePath = store.backgroundImagePath.ifBlank { null },
)
)
val theme: StateFlow<UserTheme> = _theme.asStateFlow()
fun setUserBubbleColor(argb: Int) = updateTheme { it.copy(userBubbleColor = argb) }
fun setAgentBubbleColor(argb: Int) = updateTheme { it.copy(agentBubbleColor = argb) }
fun setBackgroundColor(argb: Int) = updateTheme { it.copy(backgroundColor = argb) }
fun setBackgroundMode(mode: BackgroundMode) = updateTheme { it.copy(backgroundMode = mode) }
fun setBackgroundImagePath(path: String?) = updateTheme { it.copy(backgroundImagePath = path) }
/** Clears the background image and falls back to the solid color. */
fun removeBackgroundImage() =
updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) }
fun resetTheme() = updateTheme { UserTheme() }
private fun updateTheme(transform: (UserTheme) -> UserTheme) {
val t = transform(_theme.value)
_theme.value = t
store.userBubbleColor = t.userBubbleColor
store.agentBubbleColor = t.agentBubbleColor
store.backgroundMode = t.backgroundMode.name.lowercase()
store.backgroundColor = t.backgroundColor
store.backgroundImagePath = t.backgroundImagePath.orEmpty()
} }
// ── M3: search state ────────────────────────────────────────────────── // ── M3: search state ──────────────────────────────────────────────────
@@ -205,6 +301,7 @@ class IrisController(
} }
init { init {
chat.streamingEnabled = _streamingEnabled.value
scope.launch { scope.launch {
client.events.collect { frame -> client.events.collect { frame ->
when (frame.type) { when (frame.type) {
@@ -245,7 +342,38 @@ class IrisController(
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_CREATED,
TYPE_CHANNEL_RENAMED, TYPE_CHANNEL_RENAMED,
TYPE_CHANNEL_DELETED, TYPE_CHANNEL_DELETED,
TYPE_CHANNEL_LIST -> channels.onFrame(frame) TYPE_CHANNEL_LIST -> {
channels.onFrame(frame)
// Auto-created thread (Settings → "Threads"): the
// gateway minted it for a message in the flat lane we
// are viewing — jump into it; the user's message and
// the reply land there.
if (frame.type == TYPE_CHANNEL_CREATED) {
frame.payloadAs<ChannelInfo>()?.let { info ->
if (info.kind == "thread" && info.auto && info.parentChatId != null) {
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
if (curThread == null && curChat == info.parentChatId) {
val lane = chat.laneKey(info.parentChatId, info.chatId)
chat.setLane(lane)
historyLoaded.add(lane)
}
}
}
}
// A thread/channel we were viewing got deleted — fall back
// to a valid lane (the thread's parent, or the home channel).
if (frame.type == TYPE_CHANNEL_DELETED) {
frame.payloadAs<ChannelDeletedPayload>()?.let { p ->
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
when {
curThread == p.chatId -> openChannel(curChat)
curChat == p.chatId ->
channels.defaultChannel()?.let { openChannel(it.chatId) }
else -> Unit
}
}
}
}
TYPE_SEARCH_RESULTS -> { TYPE_SEARCH_RESULTS -> {
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits } frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
_searching.value = false _searching.value = false
@@ -255,6 +383,22 @@ class IrisController(
// cursor is authoritative server-side (outbox). // cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor } frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
} }
TYPE_HISTORY -> {
// Full message history for a chat/thread (initial open /
// restart restore). `sync` only replays the outbox delta
// since the saved cursor, so after a process death the
// in-memory store is empty and older messages are not in
// the delta — history loads the full list.
frame.payloadAs<HistoryPayload>()?.let { p ->
val (chatId, threadId) =
(frame.chatId ?: return@let) to frame.threadId
val lane = chat.laneKey(chatId, threadId)
chat.loadHistory(
lane,
p.messages.map { it.toMessageItem() },
)
}
}
TYPE_NOTIFICATION -> { TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p -> frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
@@ -273,6 +417,13 @@ class IrisController(
TYPE_READ_RECEIPT -> { TYPE_READ_RECEIPT -> {
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) } frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
} }
TYPE_MESSAGE_DELETED -> {
// A message was deleted (by this or another device).
// Idempotent: dropping an unknown id is a no-op.
frame.payloadAs<MessageDeletedPayload>()?.let {
chat.removeMessages(it.messageIds.toSet())
}
}
TYPE_STATUS -> { TYPE_STATUS -> {
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state } frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
} }
@@ -287,13 +438,22 @@ class IrisController(
} }
} }
scope.launch { scope.launch {
var prevState: GatewayClient.State? = null
client.state.collect { s -> client.state.collect { s ->
val prev = prevState
prevState = s
if (s is GatewayClient.State.Connected) { if (s is GatewayClient.State.Connected) {
channels.setAll(s.channels) channels.setAll(s.channels)
val home = s.channels.firstOrNull { it.isDefault }?.chatId val home = s.channels.firstOrNull { it.isDefault }?.chatId
if (home != null) { if (home != null) {
_homeChannel.value = home _homeChannel.value = home
chat.setLane(home) chat.setLane(home)
// Restore the home lane after a process death: the
// in-memory store is empty and the `sync` delta does not
// cover messages older than the saved cursor, so load
// the full history. Skipped on a plain reconnect (the
// lane's history was already loaded this session).
loadHistory(home)
} }
// M5: remember the ntfy server for the listener service. // M5: remember the ntfy server for the listener service.
if (s.caps.pushNtfyServer.isNotBlank()) { if (s.caps.pushNtfyServer.isNotBlank()) {
@@ -301,6 +461,23 @@ class IrisController(
} }
// M5: a deep link tapped before we were connected. // M5: a deep link tapped before we were connected.
applyDeepLink() applyDeepLink()
// Gateway came back after a restart -> announce it (hermes
// routine, same icon + wording on all platforms). The core
// does not send a startup/online notice to this platform, so
// the app adds it.
if (prev is GatewayClient.State.Reconnecting) {
chat.addSystemMessage(homeChannel.value, GATEWAY_ONLINE_MSG)
}
} else if (s is GatewayClient.State.Reconnecting && prev is GatewayClient.State.Connected) {
// Gateway went away (restart / network drop): announce it
// (hermes routine, same icon + wording on all platforms) and
// close the in-flight turn's dangling tool cards / streaming
// bubble (nothing spins forever). The app is the source of
// truth for the "restarting" notice (the server's commentary
// frame is dropped in ChatStore), so the order is guaranteed:
// restarting (here) before online (on reconnect).
chat.addSystemMessage(homeChannel.value, GATEWAY_RESTARTING_MSG)
chat.finalizeInterrupted()
} }
} }
} }
@@ -314,14 +491,19 @@ class IrisController(
// ── M3: navigation (lane switching) ─────────────────────────────────── // ── M3: navigation (lane switching) ───────────────────────────────────
/** Switch to a channel's flat / "General" lane. */ /** Switch to a channel's flat / "General" lane. Loads the full history on
* first open this session so the view is populated, not just the `sync`
* delta. */
fun openChannel(chatId: String) { fun openChannel(chatId: String) {
chat.setLane(chatId) chat.setLane(chatId)
loadHistory(chatId)
} }
/** Switch to a specific thread lane under [chatId]. */ /** Switch to a specific thread lane under [chatId]. Loads the full history
* on first open this session. */
fun openThread(chatId: String, threadId: String) { fun openThread(chatId: String, threadId: String) {
chat.setLane(chat.laneKey(chatId, threadId)) chat.setLane(chat.laneKey(chatId, threadId))
loadHistory(chatId, threadId)
} }
// ── M3: channel directory ops (server is authoritative) ─────────────── // ── M3: channel directory ops (server is authoritative) ───────────────
@@ -348,6 +530,17 @@ class IrisController(
client.sendFrame(channelSetDefaultFrame(0, chatId)) client.sendFrame(channelSetDefaultFrame(0, chatId))
} }
/** Toggle the cosmetic favorite flag (sorts the channel to the top). */
fun setChannelFavorite(chatId: String, on: Boolean) {
client.sendFrame(channelFavoriteFrame(0, chatId, on))
}
/** Set the channel's cosmetic icon (base64 image) and/or avatar color.
* Pass null to clear a field. */
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
client.sendFrame(channelIconFrame(0, chatId, icon, color))
}
fun deleteChannel(chatId: String) { fun deleteChannel(chatId: String) {
client.sendFrame(channelDeleteFrame(0, chatId)) client.sendFrame(channelDeleteFrame(0, chatId))
} }
@@ -378,6 +571,19 @@ class IrisController(
client.sendFrame(syncFrame(0, cursor)) client.sendFrame(syncFrame(0, cursor))
} }
/** Request the full message history for a chat/thread (newest page).
* Populates the lane via the `history` response; used on first open of a
* chat and after a process death (restart) to restore the view, since the
* `sync` delta does not cover messages older than the saved cursor. */
fun loadHistory(chatId: String, threadId: String? = null) {
val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return
historyLoaded.add(lane)
// Newest page, sized to restore a full working view on restart / first
// open (older pages are reachable via scroll-up pagination).
client.sendFrame(historyFrame(0, chatId, threadId, limit = 200))
}
/** Optimistic send: show immediately in the current lane, then hand to the gateway. /** Optimistic send: show immediately in the current lane, then hand to the gateway.
* M4: [attachments] (uploaded) are attached via media_refs. */ * M4: [attachments] (uploaded) are attached via media_refs. */
fun send(text: String, attachments: List<PendingAttachment> = emptyList()) { fun send(text: String, attachments: List<PendingAttachment> = emptyList()) {
@@ -394,10 +600,17 @@ class IrisController(
) )
} }
chat.addPending(trimmed, lane, media) chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs) client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId))
_attachments.value = emptyList() _attachments.value = emptyList()
} }
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in a
* channel's flat lane gets its own fresh thread, AI-named by the gateway
* (Telegram topic-mode workflow). Slash commands and media-only sends
* stay in the flat lane. */
private fun wantsAutoThread(text: String, threadId: String?): Boolean =
_threadsEnabled.value && threadId == null && text.isNotEmpty() && !text.startsWith("/")
/** M7: resend a failed user message (tap on the failed bubble). */ /** M7: resend a failed user message (tap on the failed bubble). */
fun retrySend(messageId: String) { fun retrySend(messageId: String) {
val lane = chat.currentLane.value val lane = chat.currentLane.value
@@ -405,7 +618,25 @@ class IrisController(
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
if (item.status != MsgStatus.Failed) return if (item.status != MsgStatus.Failed) return
chat.rearmForRetry(lane, messageId) chat.rearmForRetry(lane, messageId)
client.sendMessage(chatId, item.text, threadId, item.media.map { it.mediaId }) client.sendMessage(
chatId,
item.text,
threadId,
item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId),
)
}
/** Delete the given message(s) from the current lane (long-press select →
* delete). The server removes them from the outbox and broadcasts
* `message.deleted`; the local cache drops them on that frame (or
* immediately, below, for snappy UX). */
fun deleteMessages(messageIds: List<String>) {
if (messageIds.isEmpty()) return
val lane = chat.currentLane.value
val (chatId, threadId) = chat.parseLane(lane)
chat.removeMessages(messageIds.toSet())
client.sendFrame(messageDeleteFrame(0, chatId, messageIds, threadId))
} }
// ── M4: attachments (pick -> upload -> send) ────────────────────────── // ── M4: attachments (pick -> upload -> send) ──────────────────────────
@@ -482,9 +713,42 @@ class IrisController(
} }
} }
/** Convert a history message (server) into a chat [MessageItem]. */
private fun HistoryMessage.toMessageItem(): MessageItem =
MessageItem(
id = messageId,
role = role,
text = text,
ts = ts ?: 0,
reasoning = reasoning,
model = model,
tokens = tokens,
media = media.map {
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
},
)
// Gateway restart routine (hermes, same icon + wording on all platforms). The
// app generates both notices locally, on the down/up state transition, so the
// order is guaranteed (restarting before online) and there is no dependency on
// the server frame (which may be dropped on shutdown or replayed out of order
// by the sync catch-up). The core does not send a startup/online notice to this
// platform, and its "restarting" commentary frame is dropped in ChatStore.
private const val GATEWAY_RESTARTING_MSG =
"⚠️ Gateway restarting — Your current task will be interrupted. Send any message after restart and I'll try to resume where you left off."
private const val GATEWAY_ONLINE_MSG =
"♻️ Gateway online — Hermes is back and ready."
/** How much tool detail to show (Settings → "Tool detail"). */ /** How much tool detail to show (Settings → "Tool detail"). */
enum class ToolDetail { enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview EVERYTHING, // name + full args (collapsible) + output preview
TRUNCATED, // one-liner "emoji name: preview", collapsible (default) TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
NOTHING, // suppress tool cards entirely NOTHING, // suppress tool cards entirely
;
companion object {
/** Parse a persisted value; unknown/blank falls back to the default. */
fun fromName(name: String): ToolDetail =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED
}
} }
@@ -0,0 +1,10 @@
package iris.ui
import androidx.compose.ui.Modifier
/**
* Opens [onRightClick] on a right (secondary) mouse-button press. Desktop-only
* behavior; on touch platforms this is a no-op (the context menu is opened via
* long-press instead).
*/
expect fun Modifier.rightClick(onRightClick: () -> Unit): Modifier
@@ -0,0 +1,212 @@
package iris.ui
import androidx.compose.foundation.background
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CodeOff
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
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.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.snipme.highlights.Highlights
import iris.platform.PlatformWebView
import iris.platform.handleSystemBack
import iris.ui.theme.IrisColors
/**
* M9: HTML artifact support. When the agent emits a complete HTML document
* (HTML + CSS + JS) in a fenced code block, the bubble renders an artifact
* card instead of plain highlighted code. "Preview" opens the document in a
* full-screen in-app WebView with JavaScript enabled, so interactive scripts
* run inside the app.
*/
/**
* True when a fenced code block should be rendered as a runnable HTML
* artifact: an explicit ```html / ```htm language tag, or an untagged block
* that starts with a complete HTML document.
*/
fun isHtmlArtifact(language: String?, code: String): Boolean {
val lang = language?.trim()?.lowercase()
if (lang == "html" || lang == "htm") return true
if (lang != null) return false
val head = code.trimStart().take(64).lowercase()
return head.startsWith("<!doctype html") || head.startsWith("<html")
}
/** The document's <title> (card / overlay header), or null when absent. */
fun htmlTitle(code: String): String? =
TITLE_REGEX.find(code)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
private val TITLE_REGEX =
Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
/** The artifact currently open in the full-screen preview (null = closed). */
data class ArtifactPreviewData(val html: String, val title: String?)
/**
* Process-wide preview state: the artifact card (deep in the message list)
* writes here, the overlay at the app root reads it.
*/
object ArtifactPreview {
private val _current = mutableStateOf<ArtifactPreviewData?>(null)
val current: State<ArtifactPreviewData?> get() = _current
fun show(html: String, title: String?) {
_current.value = ArtifactPreviewData(html, title)
}
fun hide() {
_current.value = null
}
}
/**
* Artifact card shown in place of a highlighted HTML code block: title row
* with a Preview button (opens the full-screen overlay) and a code toggle
* (reveals the highlighted source with its copy button).
*/
@Composable
fun HtmlArtifactCard(
code: String,
style: TextStyle,
highlights: Highlights.Builder,
) {
val title = remember(code) { htmlTitle(code) }
var showCode by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = IrisColors.panel,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 6.dp, end = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title ?: "HTML artifact",
color = IrisColors.textBright,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Interactive HTML preview",
color = IrisColors.textMuted,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(
onClick = { ArtifactPreview.show(code, title) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 4.dp,
),
) {
Text("Preview", style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = { showCode = !showCode }) {
Icon(
imageVector = if (showCode) Icons.Filled.CodeOff else Icons.Filled.Code,
contentDescription = if (showCode) "Hide code" else "Show code",
tint = IrisColors.textSecondary,
)
}
}
}
if (showCode) {
Spacer(modifier = Modifier.height(6.dp))
CodeBlockWithCopy(code = code, language = "html", style = style, highlights = highlights)
}
}
}
/**
* Full-screen artifact preview overlay (rendered at the app root). Covers the
* whole window; the header close button and the system back button (Android)
* dismiss it.
*/
@Composable
fun ArtifactPreviewOverlay() {
val preview by ArtifactPreview.current
if (preview == null) return
val data = preview!!
Box(
modifier = Modifier
.fillMaxSize()
.background(IrisColors.background)
.handleSystemBack { ArtifactPreview.hide() },
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 2.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = data.title ?: "HTML preview",
modifier = Modifier.weight(1f),
color = IrisColors.textBright,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = { ArtifactPreview.hide() }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close preview",
tint = IrisColors.textSecondary,
)
}
}
HorizontalDivider(color = IrisColors.divider)
PlatformWebView(html = data.html, modifier = Modifier.fillMaxSize().weight(1f))
}
}
}
@@ -0,0 +1,229 @@
package iris.ui
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.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.TextUnit
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.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes
/**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
* tables, task-list checkboxes, and fenced code blocks with language-aware
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in.
*/
@Composable
fun MarkdownText(
text: String,
color: Color,
fontSize: TextUnit,
modifier: Modifier = Modifier,
// M9: while the message is still streaming, HTML code blocks render as
// plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete.
isStreaming: Boolean = false,
) {
val state = rememberMarkdownState(text)
// The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
}
val base = TextStyle(color = color, fontSize = fontSize)
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
// Inline-code span style (mirrors the library's codeSpanStyle): the
// monospace inlineCode typography plus the chip background.
val codeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = inlineCodeBackground).toSpanStyle()
// Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle()
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
// The text of the inline code most recently copied (null when idle). Read
// inside the annotator so a change re-renders the chip with the flash.
val copiedCode = remember { mutableStateOf<String?>(null) }
// The default CODE_SPAN handler pads inline code with a leading and a
// trailing space, which doubled the whitespace around `code` mid-line.
// Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator(
annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) {
val children = child.children
// Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
pushStyle(spanStyle)
withLink(
LinkAnnotation.Url(
url = "iris:copy-code",
// Keep every interaction state identical to the chip so
// hover/press never restyles the inline code.
styles = TextLinkStyles(
style = spanStyle,
focusedStyle = spanStyle,
hoveredStyle = spanStyle,
pressedStyle = spanStyle,
),
linkInteractionListener = LinkInteractionListener {
clipboard.setText(AnnotatedString(code))
copiedCode.value = code
scope.launch {
delay(600)
copiedCode.value = null
}
},
),
) {
append(code)
}
pop()
true
} else {
false
}
},
)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color,
codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f),
),
typography = markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
h4 = base.copy(fontWeight = FontWeight.Bold),
h5 = base.copy(fontWeight = FontWeight.Bold),
h6 = base.copy(fontWeight = FontWeight.Bold),
text = base,
code = base.copy(fontSize = fontSize * 0.9f, fontFamily = FontFamily.Monospace),
inlineCode = base.copy(fontFamily = FontFamily.Monospace),
quote = base.copy(fontStyle = FontStyle.Italic),
paragraph = base,
ordered = base,
bullet = base,
list = base,
link = base.copy(textDecoration = TextDecoration.Underline),
table = base.copy(fontSize = fontSize * 0.95f),
),
components = markdownComponents(
codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead.
checkbox = {
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
},
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
},
)
}
/**
* Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
*/
@Composable
internal fun CodeBlockWithCopy(
code: String,
language: String?,
style: TextStyle,
highlights: Highlights.Builder,
) {
val clipboard = LocalClipboardManager.current
Box {
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
if (code.isNotBlank()) {
IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier
.align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it.
.padding(top = 8.dp, end = 4.dp)
.size(28.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy code",
tint = Color.White.copy(alpha = 0.6f),
)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,6 @@
package iris.ui.screens package iris.ui.screens
import androidx.compose.foundation.background 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -14,7 +12,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -135,20 +132,3 @@ fun ConnectScreen(
} }
} }
/** M7: shown while the initial connection is in flight (state == Connecting). */
@Composable
fun ConnectingScreen(host: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
"Connecting to $host…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,480 @@
package iris.ui.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.platform.ImageFilePicker
import iris.platform.loadScaledImage
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
import kotlin.math.roundToInt
/**
* Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time.
*/
/** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 12.dp),
) {
HeaderIconButton(onClick = onBack) {
BackArrow()
}
Spacer(modifier = Modifier.width(8.dp))
Text("Settings", style = MaterialTheme.typography.titleMedium)
}
Text(
"Chat",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 4.dp),
)
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🧵 Threads", fontSize = 14.sp)
Text(
"Show topics; new messages in General start their own topic (AI-named)",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = threadsEnabled,
onCheckedChange = { controller.toggleThreads() },
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("⚡ Streaming", fontSize = 14.sp)
Text(
"Show replies as they are generated",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = streamingEnabled,
onCheckedChange = { controller.toggleStreaming() },
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🧠 Reasoning", fontSize = 14.sp)
Text(
"Auto-collapse long reasoning blocks",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = reasoningAutoCollapse,
onCheckedChange = { controller.toggleReasoningAutoCollapse() },
)
}
}
Text(
"Appearance",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("💬 Your bubble", fontSize = 14.sp)
Text(
"Color of your message bubbles",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ColorSwatch(theme.userBubble) { pickerTarget = PickerTarget.UserBubble }
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🤖 Agent bubble", fontSize = 14.sp)
Text(
"Color of Hermes' message bubbles",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ColorSwatch(theme.agentBubble) { pickerTarget = PickerTarget.AgentBubble }
}
}
SettingsCard {
Text("🖼 Background", fontSize = 14.sp)
Text(
"Solid color or image behind the chat",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
TopicChip(
label = "Color",
selected = theme.backgroundMode == BackgroundMode.Color,
onClick = { controller.setBackgroundMode(BackgroundMode.Color) },
)
TopicChip(
label = "Image",
selected = theme.backgroundMode == BackgroundMode.Image,
onClick = { controller.setBackgroundMode(BackgroundMode.Image) },
)
}
if (theme.backgroundMode == BackgroundMode.Color) {
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Background color", fontSize = 12.sp, color = IrisColors.textDim)
}
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
else fileNameOf(theme.backgroundImagePath!!),
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
ImageFilePicker { picked ->
picked?.let { controller.setBackgroundImagePath(it.path) }
}
}
if (!theme.backgroundImagePath.isNullOrBlank()) {
TextButton(onClick = { controller.removeBackgroundImage() }) {
Text("Remove image", fontSize = 12.sp)
}
}
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Reset appearance", fontSize = 14.sp)
Text(
"Restore the default colors and background",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
TextButton(onClick = { controller.resetTheme() }) {
Text("Reset", fontSize = 12.sp)
}
}
}
Text(
"Tools",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Text("🔧 Tool detail", fontSize = 14.sp)
Text(
"How much tool activity to show",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ToolDetail.entries.forEach { d ->
TopicChip(
label = d.label.replaceFirstChar { it.uppercaseChar() },
selected = toolDetail == d,
onClick = { controller.setToolDetail(d) },
)
}
}
}
}
when (pickerTarget) {
PickerTarget.UserBubble -> ColorPickerDialog(
title = "Your bubble",
initialArgb = theme.userBubbleColor,
onApply = { controller.setUserBubbleColor(it) },
onConfirm = {
controller.setUserBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.AgentBubble -> ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
onConfirm = {
controller.setAgentBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.Background -> ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
onConfirm = {
controller.setBackgroundColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
null -> Unit
}
}
}
/** Material-style back arrow (vector, matches the app's icon weight). */
@Composable
private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
Canvas(modifier = Modifier.size(size)) {
val s = this.size.width / 24f
val path = Path().apply {
moveTo(20f * s, 11f * s)
lineTo(7.83f * s, 11f * s)
lineTo(13.42f * s, 5.41f * s)
lineTo(12f * s, 4f * s)
lineTo(4f * s, 12f * s)
lineTo(12f * s, 20f * s)
lineTo(13.41f * s, 18.59f * s)
lineTo(7.83f * s, 13f * s)
lineTo(20f * s, 13f * s)
close()
}
drawPath(path, color)
}
}
/** Settings row container (panel card). */
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(IrisColors.panel)
.padding(12.dp),
) {
content()
}
}
/** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
)
}
/** Small thumbnail of the current background image (empty panel when none). */
@Composable
private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) {
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
}
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
val b = bmp
if (b != null) {
Image(
bitmap = b,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* HSV color picker dialog (hue / saturation / brightness sliders).
* [onApply] applies the color and keeps the dialog open for further tweaks;
* [onConfirm] applies and closes; [onDismiss] closes without applying.
*/
@Composable
private fun ColorPickerDialog(
title: String,
initialArgb: Int,
onApply: (Int) -> Unit,
onConfirm: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val initial = remember { argbToHsv(initialArgb) }
var hue by remember { mutableFloatStateOf(initial[0]) }
var sat by remember { mutableFloatStateOf(initial[1]) }
var bri by remember { mutableFloatStateOf(initial[2]) }
val color = Color.hsv(hue, sat, bri)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(color),
)
Spacer(modifier = Modifier.height(12.dp))
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
Slider(value = sat, onValueChange = { sat = it }, valueRange = 0f..1f)
Slider(value = bri, onValueChange = { bri = it }, valueRange = 0f..1f)
Text(
"#%06X".format(color.toArgbInt() and 0xFFFFFF),
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
},
confirmButton = {
TextButton(onClick = { onApply(color.toArgbInt()) }) { Text("Apply") }
TextButton(onClick = { onConfirm(color.toArgbInt()) }) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/** Compose Color → ARGB int (no Color.toArgb in common code). */
private fun Color.toArgbInt(): Int {
fun c(v: Float) = (v * 255f).roundToInt().coerceIn(0, 255)
return (c(alpha) shl 24) or (c(red) shl 16) or (c(green) shl 8) or c(blue)
}
/** ARGB int → [hue (deg), saturation, brightness] (alpha ignored). */
private fun argbToHsv(argb: Int): FloatArray {
val r = ((argb shr 16) and 0xFF) / 255f
val g = ((argb shr 8) and 0xFF) / 255f
val b = (argb and 0xFF) / 255f
val max = maxOf(r, g, b)
val min = minOf(r, g, b)
val delta = max - min
var hue = 0f
if (delta != 0f) {
hue = when (max) {
r -> ((g - b) / delta) % 6f
g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f
}
hue *= 60f
if (hue < 0f) hue += 360f
}
val sat = if (max == 0f) 0f else delta / max
return floatArrayOf(hue, sat, max)
}
/** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String =
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
@@ -3,7 +3,15 @@ package iris.ui.theme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable 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.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.loadScaledImage
/** /**
* Centralized palette (M7). Dark is the only theme; every screen reads colors * Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -34,7 +42,6 @@ object IrisColors {
val chip = Color(0xFF2A2E3B) val chip = Color(0xFF2A2E3B)
val chipSelected = Color(0xFF2A3550) val chipSelected = Color(0xFF2A3550)
val rowSelected = Color(0xFF232838) val rowSelected = Color(0xFF232838)
val reasoningPanel = Color(0xFF1E212B)
val divider = Color(0xFF2A2E3B) val divider = Color(0xFF2A2E3B)
// Status // Status
@@ -70,3 +77,53 @@ fun avatarColor(name: String): Color {
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat() val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
return Color.hsv(hue, 0.5f, 0.75f) return Color.hsv(hue, 0.5f, 0.75f)
} }
/**
* User-customizable appearance (Settings → Appearance). Colors are stored as
* ARGB ints so they can be persisted without Compose types.
*/
data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
val backgroundMode: BackgroundMode = BackgroundMode.Color,
val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = null,
) {
val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor)
val background: Color get() = Color(backgroundColor)
companion object {
val DEFAULT_USER_BUBBLE: Int = 0xFF4F7CFF.toInt()
val DEFAULT_AGENT_BUBBLE: Int = 0xFF2A2E3B.toInt()
val DEFAULT_BACKGROUND: Int = 0xFF1B1E28.toInt()
}
}
/** Background style: solid color or image. */
enum class BackgroundMode { Color, Image }
/** Current user theme, provided at the app root (IrisApp). */
val LocalUserTheme = staticCompositionLocalOf { UserTheme() }
/** Black or white, whichever reads better on [this]. */
fun Color.contrastText(): Color {
val luminance = 0.299f * red + 0.587f * green + 0.114f * blue
return if (luminance > 0.6f) Color(0xFF1B1E28) else Color.White
}
/**
* Decodes the theme's background image (image mode only), scaled to at most
* 2048 px on the longest edge. Null in color mode or on decode failure — the
* caller then falls back to the solid background color.
*/
@Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val path = theme.backgroundImagePath
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) {
image = if (wantImage) loadScaledImage(path!!, 2048) else null
}
return image
}
@@ -0,0 +1,39 @@
package iris.util
/**
* Drops the leading newlines / blank lines a model reply often starts with, so
* the rendered text hugs the top of the bubble instead of leaving a blank gap.
* Leading indentation on the first real line is preserved, so an indented code
* block at the very start of a message still parses as code.
*/
fun String.prepareForMarkdown(): String {
val lines = split("\n")
var i = 0
while (i < lines.size && lines[i].isBlank()) i++
return lines.drop(i).joinToString("\n")
}
/**
* Converts the single newlines a user typed into markdown hard breaks (two
* trailing spaces) so they render as line breaks instead of being collapsed
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a
* space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the
* code content is not altered; blank lines are kept as-is since they already
* produce paragraph breaks.
*/
fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n")
var inCodeBlock = false
val out = lines.map { line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> line
else -> line + " "
}
}
return out.joinToString("\n")
}
@@ -9,6 +9,9 @@ expect fun formatTime(epochMillis: Long): String
/** Local calendar-day key used to detect date changes between messages (M7). */ /** Local calendar-day key used to detect date changes between messages (M7). */
expect fun localDayKey(epochMillis: Long): String expect fun localDayKey(epochMillis: Long): String
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
expect fun nowMillis(): Long
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */ /** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
fun hostFromUrl(url: String): String { fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://") val noScheme = url.trim().substringAfter("://")
@@ -0,0 +1,65 @@
package iris.ui
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HtmlArtifactTest {
@Test
fun htmlLanguageTagIsArtifact() {
assertTrue(isHtmlArtifact("html", "<p>hi</p>"))
assertTrue(isHtmlArtifact("htm", "<p>hi</p>"))
assertTrue(isHtmlArtifact("HTML", "<p>hi</p>"))
assertTrue(isHtmlArtifact(" html ", "<p>hi</p>"))
}
@Test
fun otherLanguageTagsAreNotArtifacts() {
assertFalse(isHtmlArtifact("css", "<style>x</style>"))
assertFalse(isHtmlArtifact("js", "console.log(1)"))
assertFalse(isHtmlArtifact("kotlin", "fun main() {}"))
// Even a full document inside a non-html fence is not an artifact.
assertFalse(isHtmlArtifact("text", "<!DOCTYPE html><html></html>"))
}
@Test
fun untaggedCompleteDocumentIsArtifact() {
assertTrue(isHtmlArtifact(null, "<!DOCTYPE html>\n<html><body>hi</body></html>"))
assertTrue(isHtmlArtifact(null, " <html><body>hi</body></html>"))
assertTrue(isHtmlArtifact(null, "<HTML><BODY>hi</BODY></HTML>"))
}
@Test
fun untaggedFragmentIsNotArtifact() {
assertFalse(isHtmlArtifact(null, "<div>just a fragment</div>"))
assertFalse(isHtmlArtifact(null, "Some prose with <b>bold</b> inside."))
assertFalse(isHtmlArtifact(null, ""))
}
@Test
fun extractsTitle() {
assertEquals(
"My Page",
htmlTitle("<html><head><title>My Page</title></head><body></body></html>"),
)
assertEquals(
"Spaced",
htmlTitle("<title> Spaced </title>"),
)
// Case-insensitive, attributes allowed.
assertEquals(
"X",
htmlTitle("<TITLE lang=\"en\">X</TITLE>"),
)
}
@Test
fun missingOrEmptyTitleIsNull() {
assertNull(htmlTitle("<html><body>no title</body></html>"))
assertNull(htmlTitle("<title></title>"))
assertNull(htmlTitle("<title> </title>"))
}
}
@@ -0,0 +1,42 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
class MarkdownTest {
@Test
fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
assertEquals("Hello", "\nHello".prepareForMarkdown())
assertEquals("Hello", "\r\n\r\nHello".prepareForMarkdown())
}
@Test
fun stripsLeadingBlankLines() {
assertEquals("Hello", "\n \n\t\nHello".prepareForMarkdown())
assertEquals("a\nb", "\n\na\nb".prepareForMarkdown())
}
@Test
fun preservesLeadingIndentationOnFirstLine() {
// An indented code block at the very start must still parse as code.
assertEquals(" code", "\n\n code".prepareForMarkdown())
}
@Test
fun leavesInteriorWhitespaceUntouched() {
val md = "Line one\n\nLine two\n\n- item"
assertEquals(md, md.prepareForMarkdown())
}
@Test
fun noLeadingNewlinesIsUnchanged() {
assertEquals("Hello world", "Hello world".prepareForMarkdown())
}
@Test
fun allBlankBecomesEmpty() {
assertEquals("", "\n\n \n".prepareForMarkdown())
}
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.data.MediaItem import iris.data.MediaItem
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.net.StandardProtocolFamily import java.net.StandardProtocolFamily
@@ -40,6 +41,7 @@ import java.nio.channels.SocketChannel
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -93,6 +95,33 @@ private fun pickFile(): PickedFile? {
) )
} }
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = {
val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
}
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
onPicked(null)
return@Button
}
val f = chooser.selectedFile ?: return@Button
try {
// Copy into the persistent ~/.iris dir so the theme survives
// deletion of the original file.
val dir = File(File(System.getProperty("user.home"), ".iris"), "theme").apply { mkdirs() }
val ext = f.name.substringAfterLast('.', "img").lowercase()
val target = File(dir, "background.$ext")
f.copyTo(target, overwrite = true)
onPicked(PickedFile(target.absolutePath, f.name, guessMime(f.name), target.length()))
} catch (e: Exception) {
onPicked(null)
}
}) {
Text("Choose image…")
}
}
private fun guessMime(name: String): String { private fun guessMime(name: String): String {
val ext = name.substringAfterLast('.', "").lowercase() val ext = name.substringAfterLast('.', "").lowercase()
return when (ext) { return when (ext) {
@@ -131,6 +160,54 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
} }
} }
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
if (scale >= 1f) {
img.toComposeImageBitmap()
} else {
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
scaled.toComposeImageBitmap()
}
} catch (_: Exception) {
null
}
}
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
val baos = ByteArrayOutputStream()
ImageIO.write(scaled, "png", baos)
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
} catch (_: Exception) {
null
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val bytes = java.util.Base64.getDecoder().decode(base64)
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
img.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
/** /**
* M6: inline media playback backed by mpv (docs/11 §11.2). * M6: inline media playback backed by mpv (docs/11 §11.2).
* *
@@ -2,6 +2,7 @@ package iris.platform
import iris.data.SecureStore import iris.data.SecureStore
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.ui.theme.UserTheme
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
@@ -34,6 +35,15 @@ class DesktopSecureStore : SecureStore {
val fcmToken: String = "", val fcmToken: String = "",
val ntfyTopic: String = "", val ntfyTopic: String = "",
val ntfyServer: String = "", val ntfyServer: String = "",
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true,
val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = "color",
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = "",
) )
init { init {
@@ -133,6 +143,69 @@ class DesktopSecureStore : SecureStore {
save(d.copy(ntfyServer = value)) save(d.copy(ntfyServer = value))
} }
override var threadsEnabled: Boolean
get() = load().threadsEnabled
set(value) {
val d = load()
save(d.copy(threadsEnabled = value))
}
override var toolDetail: String
get() = load().toolDetail.ifBlank { "truncated" }
set(value) {
val d = load()
save(d.copy(toolDetail = value))
}
override var streamingEnabled: Boolean
get() = load().streamingEnabled
set(value) {
val d = load()
save(d.copy(streamingEnabled = value))
}
override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse
set(value) {
val d = load()
save(d.copy(reasoningAutoCollapse = value))
}
override var userBubbleColor: Int
get() = load().userBubbleColor
set(value) {
val d = load()
save(d.copy(userBubbleColor = value))
}
override var agentBubbleColor: Int
get() = load().agentBubbleColor
set(value) {
val d = load()
save(d.copy(agentBubbleColor = value))
}
override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" }
set(value) {
val d = load()
save(d.copy(backgroundMode = value))
}
override var backgroundColor: Int
get() = load().backgroundColor
set(value) {
val d = load()
save(d.copy(backgroundColor = value))
}
override var backgroundImagePath: String
get() = load().backgroundImagePath
set(value) {
val d = load()
save(d.copy(backgroundImagePath = value))
}
override fun savePairing(url: String, token: String) { override fun savePairing(url: String, token: String) {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim())) save(d.copy(serverUrl = url.trim()))
@@ -0,0 +1,94 @@
package iris.platform
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
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.ui.Alignment
import androidx.compose.ui.Modifier
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
import dev.datlag.kcef.KCEF
import iris.ui.theme.IrisColors
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
/**
* M9: HTML artifact preview on desktop — JCEF (Chromium) via KCEF.
*
* KCEF is initialized once per process, lazily on the first artifact
* preview. The JCEF/Chromium binary is downloaded from the pinned
* JetBrainsRuntime release on first use and cached in ~/.iris/kcef-bundle,
* so later starts (and the packaged app) reuse it.
*/
private val kcefReady = MutableStateFlow(false)
private val kcefProgress = MutableStateFlow(0f)
private val kcefError = MutableStateFlow<String?>(null)
private suspend fun ensureKcefInitialized() {
if (kcefReady.value) return
withContext(Dispatchers.IO) {
KCEF.init(
builder = {
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was
// built against; the default "latest" JBR may be JDK 21-based
// and is not guaranteed compatible.
download {
github { release("jbr-release-17.0.12b1207.37") }
}
settings {
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
}
progress {
onDownloading { kcefProgress.value = it }
onInitialized { kcefReady.value = true }
}
},
onError = { kcefError.value = it?.message ?: "Preview engine failed to start" },
onRestartRequired = { kcefError.value = "Restart the app to finish setting up previews" },
)
}
}
@Composable
actual fun PlatformWebView(html: String, modifier: Modifier) {
val ready by kcefReady.collectAsState()
val progress by kcefProgress.collectAsState()
val error by kcefError.collectAsState()
LaunchedEffect(Unit) { ensureKcefInitialized() }
Box(modifier = modifier.fillMaxSize()) {
when {
error != null -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error!!, color = IrisColors.errorText)
}
}
!ready -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(progress = { progress })
Text(
text = "Preparing preview… ${progress.toInt()}%",
color = IrisColors.textSecondary,
)
}
}
}
else -> {
val state = rememberWebViewStateWithHTMLData(data = html)
WebView(state, modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier = this
@@ -0,0 +1,20 @@
package iris.ui
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
@OptIn(ExperimentalComposeUiApi::class)
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier =
pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) {
onRightClick()
}
}
}
}
@@ -25,3 +25,5 @@ actual fun formatTime(epochMillis: Long): String {
actual fun localDayKey(epochMillis: Long): String = actual fun localDayKey(epochMillis: Long): String =
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString() Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
actual fun nowMillis(): Long = System.currentTimeMillis()
+37 -5
View File
@@ -66,6 +66,16 @@ Streaming a bubble. `update` carries the **full** current text (app replaces).
"reasoning":"…","model":"…","tokens":11}} "reasoning":"…","model":"…","tokens":11}}
``` ```
### `message.deleted`
The given message(s) were deleted from a chat/thread. Response to a
`message.delete` request (`id` set) **and** broadcast to every device so all of
them drop the message(s) from their cache. Also outboxed, so a device that was
offline learns of the deletion on its next `sync`.
```json
{"type":"message.deleted","id":30,"chat_id":"android:default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}}
```
### `commentary` ### `commentary`
Intermediate assistant beat (between tool iterations). Intermediate assistant beat (between tool iterations).
```json ```json
@@ -116,6 +126,10 @@ subscribe; the server pushes to every open WS).
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports", {"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
"kind":"channel","parent_chat_id":null}} "kind":"channel","parent_chat_id":null}}
``` ```
`channel.created` may carry `"auto":true` for a thread the gateway minted
itself for an incoming message (auto-threading): the name is an instant
derived title, and a follow-up `channel.renamed` upgrades it to the model's
title.
### `history` ### `history`
Response to a `history` request. Returns a page of messages for a chat/thread. Response to a `history` request. Returns a page of messages for a chat/thread.
@@ -225,9 +239,16 @@ First frame; auth + caps.
Send text (or a `/slash-command`). Send text (or a `/slash-command`).
```json ```json
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null, {"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"]}} "payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"],
"auto_thread":false}}
``` ```
`media_refs` reference completed `media.upload`s to attach. `media_refs` reference completed `media.upload`s to attach.
`auto_thread` (optional, default false) asks the gateway to mint a fresh
thread for the message (auto-threading, `06-channels-cron-search.md` §6.3):
honored only in a channel's flat lane (`thread_id` null) with non-empty text
that is not a slash command. The gateway then broadcasts
`channel.created {auto:true}` and the echo / agent turn carry the new
`thread_id`.
### `media.upload.start` / (binary) / `media.upload.end` ### `media.upload.start` / (binary) / `media.upload.end`
See `07-media.md`. See `07-media.md`.
@@ -289,6 +310,16 @@ Load a page of messages for a chat/thread (initial open, scroll-up pagination).
`before_message_id` — return messages older than this (omit for newest page). `before_message_id` — return messages older than this (omit for newest page).
`limit` — max messages (default 50, max 200). `limit` — max messages (default 50, max 200).
### `message.delete`
Delete the given message(s) from a chat/thread. The server removes them from the
outbox (so `history`/`sync` no longer return them) and broadcasts
`message.deleted` to every device. Idempotent: a message already gone (pruned by
retention) still yields a `message.deleted` broadcast so live caches drop it.
```json
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
"payload":{"message_ids":["m_9001","m_9002"]}}
```
### `commands.catalog` ### `commands.catalog`
Fetch the full slash-command catalog (for the `Menü` bottom sheet). Fetch the full slash-command catalog (for the `Menü` bottom sheet).
```json ```json
@@ -335,10 +366,11 @@ Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
- Frames are ordered per connection (TCP/WS). Streaming `message.update` frames - Frames are ordered per connection (TCP/WS). Streaming `message.update` frames
for a `message_id` are monotonic; the app may coalesce to the latest. for a `message_id` are monotonic; the app may coalesce to the latest.
- Terminal frames (`message`, `message.stop`, `tool.end`, `notification`, - Terminal frames (`message`, `message.stop`, `message.deleted`, `tool.end`,
`picker.*`, `channel.*`, `agent.busy`, `agent.idle`, `history`, `notification`, `picker.*`, `channel.*`, `agent.busy`, `agent.idle`,
`commands.catalog`, `commands.complete`) are **never dropped** under `history`, `commands.catalog`, `commands.complete`) are **never dropped**
backpressure; only intermediate `message.update`/`tool.progress` are coalesced. under backpressure; only intermediate `message.update`/`tool.progress` are
coalesced.
- Anything not delivered live goes to the **outbox** and is replayed by `sync`. - Anything not delivered live goes to the **outbox** and is replayed by `sync`.
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are - **Broadcast:** channel directory events (`channel.*`) and read-receipts are
pushed to **all** connected devices for that gateway (no subscribe step). pushed to **all** connected devices for that gateway (no subscribe step).
+9 -2
View File
@@ -27,8 +27,15 @@ replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom. while the user is at the bottom.
**Streaming on/off.** Controlled by hermes `display.platforms.android.streaming` **Streaming on/off.** Two levels:
(default follows global). When off, the app just gets one final `message` frame. - **Gateway side:** hermes `display.platforms.android.streaming` (default
follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When
off, the app ignores `message.start`/`message.update` frames and
materializes each reply as a single final message on `message.stop`
(`ChatStore.onMessageStop` already handles a stop without a live bubble).
The gateway keeps streaming — frames are broadcast to all devices, so this
is a display preference like tool verbosity, not a wire flag.
## 5.2 Reasoning (shown *before* the message) ## 5.2 Reasoning (shown *before* the message)
+31
View File
@@ -45,6 +45,34 @@ gateway identity concepts**.
- The gateway's `create_handoff_thread` is used where hermes wants to open a - The gateway's `create_handoff_thread` is used where hermes wants to open a
named thread (e.g. continuable cron). named thread (e.g. continuable cron).
### 6.3.1 Auto-threading (Telegram topic-mode workflow)
With **Threads ON**, a message sent in a channel's flat lane (no active
thread) gets its **own fresh thread**, the way Telegram topic mode mints a
topic per new conversation — and the **AI names it** instead of the user:
1. The app sends `message.send {…, auto_thread:true}` (only from a flat lane,
with non-empty text, never for slash commands).
2. The gateway mints a thread (`channel.create`-equivalent, `kind:thread`)
under the channel, **named instantly** from the user's opening message via
hermes' session-title derivation (`agent/title_generator.derive_title` —
a deterministic slice of the user's own words, no model call), and
broadcasts `channel.created {auto:true}`.
3. The user echo, the agent turn, and all streaming frames carry the new
`thread_id` — the whole conversation lives in the thread.
4. In the background, the gateway upgrades the name with the model's title
(`agent/title_generator.generate_title`, the `title_generation` auxiliary
task) and broadcasts `channel.renamed`. This is hermes' two-stage session
titling (derived < llm < user) applied to the thread name; failures leave
the derived name in place.
App side: on `channel.created {auto:true}` under the flat lane it is viewing,
the app **jumps into the new thread** and relocates the optimistic pending
bubble from the flat lane into it (the echo arrives in the thread lane).
Follow-ups sent inside the thread stay there; the next flat-lane message
starts another thread. Media-only sends and slash commands stay in the flat
lane (nothing to title / session-scoped, not conversation starters).
## 6.4 User-created channels (for cron delegation) ## 6.4 User-created channels (for cron delegation)
- **Requirement:** the user creates new channels so **cron job outputs can be - **Requirement:** the user creates new channels so **cron job outputs can be
@@ -55,6 +83,9 @@ gateway identity concepts**.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the - **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is soft — marks directory (rename broadcasts `channel.renamed`; delete is soft — marks
archived, keeps history for search). archived, keeps history for search).
- **App affordance for threads:** long-press (touch) / right-click (desktop) a
topic chip → "Rename" (`channel.rename`) or "Delete" (`channel.delete`).
Deleting the open thread falls the app back to the channel's flat lane.
- **Cron targeting** (the key payoff): because the plugin registers - **Cron targeting** (the key payoff): because the plugin registers
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the `parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread: `send_message` tool can target any channel/thread:
+46 -3
View File
@@ -93,22 +93,53 @@ app/shared/src/
clarifies) render as **native pickers** from `picker.*` frames (a dialog / clarifies) render as **native pickers** from `picker.*` frames (a dialog /
sheet with the options; answer via `picker.select`). sheet with the options; answer via `picker.select`).
### Streaming (app-controlled)
- **Settings → "Streaming"** toggle (default on). When off, the app ignores
`message.start`/`message.update` frames and shows each reply as a single
final message on `message.stop` (the typing indicator covers the wait).
Per-device display preference — the gateway keeps streaming for other
devices (see `05-streaming.md` §5.1).
### Tool output (app-controlled verbosity) ### Tool output (app-controlled verbosity)
- `ToolCard` renders `tool.start/progress/end` frames. - `ToolCard` renders `tool.start/progress/end` frames.
- The gateway always supplies the **full** tool data: it forces
`display.platforms.android.tool_progress: verbose` (so the progress line
carries the full args JSON → `tool.start.args`) and captures each completed
call via the `post_tool_call` hook (→ `tool.end` `output_preview` /
`duration` / `ok`). The app decides how much to show.
- **Settings → "Tool detail": Everything / Truncated / Nothing.** - **Settings → "Tool detail": Everything / Truncated / Nothing.**
- Everything: name + full args (collapsible) + output preview. - Everything: cards start expanded — name + full args + output.
- Truncated (default): `emoji name: "short preview"` one-liner, expandable. - Truncated (default): compact one-liner; tap a card to reveal the full
- Nothing: suppress tool frames. args + output (what was actually called).
- Nothing: suppress tool cards.
- Spinner while running; ✓/✗ + duration on `tool.end`. - Spinner while running; ✓/✗ + duration on `tool.end`.
### Reasoning before message ### Reasoning before message
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy** - `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
button) rendered **above** the message body from the `reasoning` field. button) rendered **above** the message body from the `reasoning` field.
Matches the reference screenshot. Matches the reference screenshot.
- **Settings → "Reasoning"** toggle (default on): auto-collapse — long
reasoning blocks start collapsed, short ones expanded. Off: all reasoning
blocks start expanded. Per-device display preference; the per-block
tap-to-toggle always works.
### Intermediate messages ### Intermediate messages
- `commentary` frames → dimmed/smaller bubble, distinct from final answers. - `commentary` frames → dimmed/smaller bubble, distinct from final answers.
### Message selection + delete
- **Long-press** a message bubble (touch) or **right-click** it (desktop mouse)
enters selection mode: the tapped message is selected (a circular check appears
beside each bubble) and the composer is replaced by a selection toolbar
(count + **Delete** + cancel ✕).
- While selecting, **tap** a bubble to toggle it; the ✕ (or deselecting the last
message) exits selection mode. Only finalized messages are selectable — a
streaming bubble has no final id yet and a pending echo isn't on the server.
- **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the
current lane. The server removes the message(s) from the outbox (so
`history`/`sync` no longer return them) and broadcasts `message.deleted` to
every device; each device drops them from its cache (the requesting device
also drops them locally for snappy UX). Deleting is idempotent.
### Agent busy / stop / steer ### Agent busy / stop / steer
- `agent.busy` → show "thinking…" indicator in chat header (animated dots). - `agent.busy` → show "thinking…" indicator in chat header (animated dots).
- `agent.idle` → clear indicator. - `agent.idle` → clear indicator.
@@ -122,8 +153,20 @@ app/shared/src/
highlight bar) — matches the reference left sidebar. highlight bar) — matches the reference left sidebar.
- **Thread toggle** in the default chat header: "Threads on/off". On → topic - **Thread toggle** in the default chat header: "Threads on/off". On → topic
switcher above the message list (each topic = a `thread_id`). switcher above the message list (each topic = a `thread_id`).
- **Auto-threading** (Threads on, Telegram topic-mode workflow,
`06-channels-cron-search.md` §6.3.1): a message sent in a channel's flat
lane goes out as `message.send {auto_thread:true}`; the gateway mints a
fresh thread (instant derived name, AI-named a moment later via
`channel.renamed`) and the app jumps into it, relocating the optimistic
bubble from the flat lane.
- **New channel** (FAB / channel-list menu) → `channel.create` → appears in list; - **New channel** (FAB / channel-list menu) → `channel.create` → appears in list;
menu offers "Set as cron target". menu offers "Set as cron target".
- **Topic context menu** — long-press a topic chip (touch) or right-click it
(desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled
dialog); Delete → confirm → `channel.delete` (soft-delete; the thread leaves
the switcher and, if it was the open lane, the app falls back to the channel's
flat lane). The right-click handler is a skiko `expect`/`actual`
(`iris/ui/ContextMenu.kt`); on touch it is a no-op (long-press covers it).
### Search ### Search
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" / - Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
+9
View File
@@ -32,6 +32,11 @@ without the app (critical for verifying frame shapes early).
- **Push backend selection:** fcm vs ntfy chosen by config; `configured()` - **Push backend selection:** fcm vs ntfy chosen by config; `configured()`
reflects missing creds. reflects missing creds.
- **Channel directory:** create/rename/set_default; cron target resolution. - **Channel directory:** create/rename/set_default; cron target resolution.
- **Auto-threading:** `message.send {auto_thread:true}` in a flat lane mints a
thread (derived name, `channel.created {auto:true}`) and the echo/event
carry the new `thread_id`; no-op with an existing `thread_id`, for slash
commands, or for media-only sends; the LLM upgrade renames the thread
(`channel.renamed`).
- **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture - **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture
pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`. pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`.
@@ -113,6 +118,10 @@ adb logcat -d > /tmp/logcat.txt
trigger a message → FCM notification appears → tap → syncs + deep-links. trigger a message → FCM notification appears → tap → syncs + deep-links.
12. **Reconnect/sync:** kill the WS (stop gateway briefly) → restart → app 12. **Reconnect/sync:** kill the WS (stop gateway briefly) → restart → app
reconnects → `sync` catches up (no lost/dup messages). reconnects → `sync` catches up (no lost/dup messages).
13. **Auto-threading:** Threads on → send a message in the Default channel's
flat lane → a new topic appears (derived name), the app jumps into it, the
reply streams there, and the topic is renamed to the AI's title a moment
later. Slash commands / media-only sends stay in the flat lane.
## 13.5 Debugging tips ## 13.5 Debugging tips
+11 -7
View File
@@ -42,6 +42,7 @@
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } }, "message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } }, "message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } }, "message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" }, "ts": { "type": "integer" } } },
"message.deleted": { "description": "The given message(s) were deleted from a chat/thread. Response to a message.delete request (id set) and broadcast to every device so all drop them from their cache; also outboxed so an offline device learns of the deletion on its next sync.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" } } } },
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } }, "commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } }, "tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } }, "tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
@@ -49,8 +50,8 @@
"typing": { "payload": { "on": { "type": "boolean" } } }, "typing": { "payload": { "on": { "type": "boolean" } } },
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } }, "notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "channel_deleted", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } }, "channel.list": { "description": "Full channel directory (response to a channel.list request).", "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
"channel.created": { "payload": { "$ref": "#/definitions/channel" } }, "channel.created": { "description": "May carry auto:true for a thread the gateway minted itself for an incoming message (auto-threading); the name is a derived title, upgraded by a follow-up channel.renamed.", "payload": { "$ref": "#/definitions/channel" } },
"channel.renamed": { "description": "Also the response to channel.set_default (carries the full entry incl. is_default).", "payload": { "$ref": "#/definitions/channel" } }, "channel.renamed": { "description": "Also the response to channel.set_default / channel.favorite / channel.icon (carries the full entry incl. is_default, favorite, icon, color).", "payload": { "$ref": "#/definitions/channel" } },
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } }, "channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } }, "search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } }, "media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
@@ -59,29 +60,34 @@
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } }, "error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } }, "pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } }, "sync.done": { "payload": { "cursor": { "type": "integer" } } },
"history": { "description": "Paged full message history for a chat/thread (response to a history request). Reconstructed from the outbox log; used to populate the view on first open / after a process death, since sync only replays the outbox delta.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string","enum":["user","assistant"]}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"}, "media": {"type":"array","items":{"$ref":"#/definitions/media_ref"}} } } }, "has_more": { "type": "boolean", "description": "True when older pages exist." }, "oldest_message_id": { "type": "string", "description": "before_message_id for the next (older) page." } } } },
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } }, "media.pull.end": { "payload": { "ok": { "type": "boolean" } } },
"media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } } "media.upload.ack": { "description": "Response to media.upload.end; ref is cached and usable in message.send media_refs.", "payload": { "ok": { "type": "boolean" }, "media_ref": { "type": "string" } } }
}, },
"app_to_server": { "app_to_server": {
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, "hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } } } }, "message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } }, "auto_thread": { "type": "boolean", "description": "Optional, default false. Ask the gateway to mint a fresh thread for this message (auto-threading). Honored only in a channel's flat lane (thread_id null) with non-empty non-slash text; the gateway broadcasts channel.created {auto:true} and the echo/turn carry the new thread_id." } } },
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } }, "media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } }, "media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
"media.pull": { "payload": { "media_id": { "type": "string" } } }, "media.pull": { "payload": { "media_id": { "type": "string" } } },
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } }, "channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
"channel.rename": { "payload": { "name": { "type": "string" } } }, "channel.rename": { "payload": { "name": { "type": "string" } } },
"channel.set_default": { "payload": {} }, "channel.set_default": { "payload": {} },
"channel.favorite": { "description": "Toggle the cosmetic favorite flag (sorts the channel to the top of the list). Answered by a channel.renamed carrying the full entry.", "payload": { "on": { "type": "boolean" } } },
"channel.icon": { "description": "Set the channel's cosmetic icon (base64 image) and/or avatar color. Omit / null a field to clear it. Answered by a channel.renamed carrying the full entry.", "payload": { "icon": { "type": ["string", "null"], "description": "base64-encoded image (PNG/JPEG), max ~512 KiB." }, "color": { "type": ["string", "null"], "description": "#RRGGBB avatar color override." } } },
"channel.delete": { "payload": {} }, "channel.delete": { "payload": {} },
"channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} }, "channel.list": { "description": "Request the full channel directory; answered by the server_to_app channel.list frame.", "payload": {} },
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } }, "search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } }, "sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
"message.delete": { "description": "Delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } }, "fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"ping": { "payload": { "ts": { "type": "integer" } } } "ping": { "payload": { "ts": { "type": "integer" } } }
} }
}, },
"definitions": { "definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, "kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"} } }, "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } } "media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }
}, },
"x-planned-frames": [ "x-planned-frames": [
@@ -91,8 +97,6 @@
{ "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." }, { "name": "picker.approval", "direction": "server_to_app", "note": "Approval picker prompt. Planned, not implemented (approvals arrive as notification)." },
{ "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." }, { "name": "picker.confirm", "direction": "server_to_app", "note": "Confirmation picker prompt. Planned, not implemented." },
{ "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." }, { "name": "picker.select", "direction": "app_to_server", "note": "Picker answer. Planned, not implemented." },
{ "name": "history", "direction": "server_to_app", "note": "Paged history response. Planned, not implemented (catch-up is sync/outbox replay)." },
{ "name": "history", "direction": "app_to_server", "note": "Paged history request. Planned, not implemented (catch-up is sync/outbox replay)." },
{ "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." }, { "name": "commands.catalog", "direction": "server_to_app", "note": "Slash-command catalog. Planned, not implemented." },
{ "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." }, { "name": "commands.catalog", "direction": "app_to_server", "note": "Slash-command catalog request. Planned, not implemented." },
{ "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." }, { "name": "commands.complete", "direction": "server_to_app", "note": "Slash-command autocomplete. Planned, not implemented." },
@@ -105,7 +109,7 @@
], ],
"reliability": { "reliability": {
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.", "ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
"never_dropped": ["message", "message.stop", "tool.end", "notification", "channel.*", "search.results", "error"], "never_dropped": ["message", "message.stop", "message.deleted", "tool.end", "notification", "channel.*", "search.results", "error"],
"coalescable_under_backpressure": ["message.update", "tool.progress"], "coalescable_under_backpressure": ["message.update", "tool.progress"],
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed." "offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
} }
+487 -13
View File
@@ -58,12 +58,14 @@ Or via environment variables (overrides config.yaml; secrets live in .env):
""" """
import asyncio import asyncio
import json
import logging import logging
import os import os
import re import re
import threading import threading
import time import time
import uuid import uuid
from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -185,6 +187,75 @@ def _reset_reasoning() -> None:
_reasoning_flushed.clear() _reasoning_flushed.clear()
# ---------------------------------------------------------------------------
# M2 — tool-result capture (post_tool_call hook)
#
# The gateway renders tool *progress* lines to the platform but never streams
# the tool *output* (it is the agent's concern, persisted to history, not
# presentation). To let the app show the full call + result on demand
# (Settings → Tool detail), we capture each completed tool call via the
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
#
# Global FIFO (like the reasoning buffer): a personal android gateway serves
# one active turn at a time, and records are matched to the open tool by name
# in completion order. Bounded so a runaway turn can't grow it without limit.
# ---------------------------------------------------------------------------
_tool_results: "deque[Dict[str, Any]]" = deque()
_tool_results_lock = threading.Lock()
_MAX_TOOL_RESULTS = 200
_MAX_OUTPUT_PREVIEW = 8000
def _on_post_tool_call(**kwargs: Any) -> None:
"""Plugin hook: capture a completed tool call's result + timing."""
result = kwargs.get("result")
record = {
"tool_name": kwargs.get("tool_name") or "",
"result": (str(result) if result is not None else "")[:_MAX_OUTPUT_PREVIEW],
"duration_ms": kwargs.get("duration_ms") or 0,
"status": kwargs.get("status") or "ok",
}
with _tool_results_lock:
_tool_results.append(record)
while len(_tool_results) > _MAX_TOOL_RESULTS:
_tool_results.popleft()
def _take_tool_result(tool_name: str) -> Optional[Dict[str, Any]]:
"""Pop the first completed record matching *tool_name* (FIFO), else None."""
if not tool_name:
return None
with _tool_results_lock:
for i, rec in enumerate(_tool_results):
if rec["tool_name"] == tool_name:
del _tool_results[i]
return rec
return None
def _reset_tool_results() -> None:
"""Clear captured records (turn boundary — drop anything unconsumed)."""
with _tool_results_lock:
_tool_results.clear()
def _tool_end_fields(tool_name: str) -> Dict[str, Any]:
"""Build the ``tool.end`` enrichment (ok/duration/output_preview) from the
captured hook record for *tool_name*; empty dict when none is available
(e.g. tool_progress off, or the call came from another session)."""
rec = _take_tool_result(tool_name)
if rec is None:
return {}
fields: Dict[str, Any] = {
"ok": rec["status"] == "ok",
"output_preview": rec["result"] or None,
}
if rec["duration_ms"]:
fields["duration"] = round(rec["duration_ms"] / 1000.0, 3)
return fields
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Defaults # Defaults
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -283,6 +354,25 @@ def _thread_id_from_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[str
return None return None
def _derive_thread_name(text: str) -> str:
"""Instant auto-thread name from the user's opening message (no model).
Reuses hermes' session-title derivation (``agent/title_generator.py``):
a deterministic slice of the user's own words, so the thread is named the
moment it is created. The LLM upgrade (``_schedule_thread_title_upgrade``)
replaces it moments later — the same two-stage titling hermes uses for
sessions (derived < llm < user).
"""
try:
from agent.title_generator import derive_title
title = derive_title(text)
except Exception:
logger.debug("Thread name derivation failed", exc_info=True)
title = None
return (title or "").strip() or "New thread"
def _strip_streaming_cursor(text: str) -> str: def _strip_streaming_cursor(text: str) -> str:
if text and text.endswith(_STREAMING_CURSOR): if text and text.endswith(_STREAMING_CURSOR):
return text[: -len(_STREAMING_CURSOR)] return text[: -len(_STREAMING_CURSOR)]
@@ -406,6 +496,50 @@ def _extract_code_block(content: str) -> Optional[str]:
return None return None
def _extract_verbose_args(line: str, content: str) -> Optional[Dict[str, Any]]:
"""Recover the full args dict from a verbose tool line, else ``None``.
In verbose mode the gateway renders ``<emoji> <name>(keys)`` on one line
and the full args JSON on the line that follows. When *line* is such a
header, return the parsed JSON object from the following line.
"""
parts = line.strip().split(None, 1)
if len(parts) < 2 or not _TOOL_NAME_ARGS_RE.match(parts[1]):
return None
lines = content.splitlines()
for i, ln in enumerate(lines):
if ln.strip() != line.strip():
continue
for follow in lines[i + 1:]:
follow = follow.strip()
if not follow:
continue
if follow.startswith("{"):
try:
obj = json.loads(follow)
return obj if isinstance(obj, dict) else None
except Exception:
return None
return None # next non-empty line is not the args JSON
return None
def _short_preview_from_args(args: Dict[str, Any], cap: int = 60) -> Optional[str]:
"""Derive a short one-line preview from a verbose args dict.
The verbose line carries no explicit preview, so the Truncated display
would otherwise lose its one-liner. Use the first non-empty string value
(whitespace-collapsed, capped) as a stand-in.
"""
if not isinstance(args, dict):
return None
for value in args.values():
if isinstance(value, str) and value.strip():
s = " ".join(value.split())
return s[: cap - 1] + "…" if len(s) > cap else s
return None
def _is_tool_progress(content: str) -> bool: def _is_tool_progress(content: str) -> bool:
"""Heuristic: does *content* look like gateway tool-progress line(s)? """Heuristic: does *content* look like gateway tool-progress line(s)?
@@ -426,6 +560,23 @@ def _is_tool_progress(content: str) -> bool:
return _parse_tool_line(first) is not None return _parse_tool_line(first) is not None
def _is_gateway_lifecycle_notice(content: str) -> bool:
"""True for hermes gateway lifecycle notices (restart / shutdown / online).
These are system notices, not tool progress. Their leading ⚠️/♻️ emoji
would otherwise trip the tool-line heuristic and render them as a
never-completing tool card (an endless spinner, since no ``tool.end``
ever arrives for a notice that is not a real tool).
"""
if not content:
return False
c = content.strip()
return any(
marker in c
for marker in ("Gateway restarting", "Gateway shutting down", "Gateway online")
)
@dataclass @dataclass
class _TurnState: class _TurnState:
"""Per-chat turn state for outbound frame classification (M2).""" """Per-chat turn state for outbound frame classification (M2)."""
@@ -439,6 +590,9 @@ class _TurnState:
tool_index: int = 0 tool_index: int = 0
# Index of the most recently started tool (awaiting tool.end). # Index of the most recently started tool (awaiting tool.end).
open_tool_index: Optional[int] = None open_tool_index: Optional[int] = None
# Name of the most recently started tool (matches the post_tool_call
# record when the tool completes, so tool.end can carry its output).
open_tool_name: Optional[str] = None
# Tool lines already emitted as tool.start (dedup across edits). # Tool lines already emitted as tool.start (dedup across edits).
seen_tool_lines: set = field(default_factory=set) seen_tool_lines: set = field(default_factory=set)
@@ -601,6 +755,46 @@ async def _standalone_send(
} }
# ---------------------------------------------------------------------------
# Verbose tool progress (full args on the progress line)
# ---------------------------------------------------------------------------
def _ensure_verbose_tool_progress() -> None:
"""Ensure the android platform renders tool progress in ``verbose`` mode.
Verbose mode makes the gateway's tool-progress line carry the FULL
argument JSON (not just a ~40-char preview), which the adapter parses
into the ``tool.start`` frame's ``args`` field; the app then decides how
much to show (Settings → Tool detail). The tool *output* is captured
separately via the ``post_tool_call`` hook (verbose mode does not stream
it).
Best-effort and idempotent: writes
``display.platforms.android.tool_progress: verbose`` to config.yaml only
when it isn't already set. The gateway's config cache is mtime-keyed, so
the write takes effect on the next turn without a restart. Never raises.
"""
try:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly() or {}
display = cfg.get("display") or {}
platforms = display.get("platforms") or {}
android = platforms.get("android") or {}
if android.get("tool_progress") == "verbose":
return # already set
from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update(
get_hermes_home() / "config.yaml",
"display.platforms.android.tool_progress",
"verbose",
)
logger.info("android: set display.platforms.android.tool_progress=verbose")
except Exception:
logger.debug("android: could not ensure verbose tool_progress", exc_info=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Interactive setup (hermes gateway setup flow) # Interactive setup (hermes gateway setup flow)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -654,6 +848,10 @@ def interactive_setup() -> None:
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}") print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
print_info(f"Server URL: {url}") print_info(f"Server URL: {url}")
# Always render tool progress verbosely so the app receives the full tool
# call args (it decides how much to show via Settings → Tool detail).
_ensure_verbose_tool_progress()
print_success("Android configuration saved to ~/.hermes/.env") print_success("Android configuration saved to ~/.hermes/.env")
print_info("Restart the gateway for changes to take effect: hermes gateway restart") print_info("Restart the gateway for changes to take effect: hermes gateway restart")
@@ -671,10 +869,21 @@ class AndroidAdapter(BasePlatformAdapter):
``MessageEvent``s for ``handle_message()``. ``MessageEvent``s for ``handle_message()``.
""" """
# WS has no message-size limit. The stream consumer resolves its per-chat
# chunking budget via ``max_message_length_for_chat`` -> this attribute
# (defaulting to 4096 when unset), which would split long replies — and
# complete HTML artifacts — across multiple fence-reopened messages. A
# large cap disables that chunking so a reply arrives as a single message.
MAX_MESSAGE_LENGTH = 1_000_000
def __init__(self, config, **kwargs): def __init__(self, config, **kwargs):
platform = Platform("android") platform = Platform("android")
super().__init__(config=config, platform=platform) super().__init__(config=config, platform=platform)
# Ensure verbose tool progress (full args on the progress line) so the
# app can show the full tool call on demand. Best-effort; idempotent.
_ensure_verbose_tool_progress()
extra = getattr(config, "extra", {}) or {} extra = getattr(config, "extra", {}) or {}
# Connection settings (env vars override config.yaml) # Connection settings (env vars override config.yaml)
@@ -973,7 +1182,10 @@ class AndroidAdapter(BasePlatformAdapter):
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
# 4. Tool progress (first tool bubble of an editable line buffer). # 4. Tool progress (first tool bubble of an editable line buffer).
if _is_tool_progress(content): # Gateway lifecycle notices (restart/shutdown/online) are system
# notices, not tool progress — skip the tool-line heuristic so they
# fall through to commentary instead of a never-completing card.
if not _is_gateway_lifecycle_notice(content) and _is_tool_progress(content):
return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False) return await self._emit_tool_lines(chat_id, content, state, thread_id, is_edit=False)
# 5. Commentary (interim assistant beat). # 5. Commentary (interim assistant beat).
@@ -1094,38 +1306,59 @@ class AndroidAdapter(BasePlatformAdapter):
parsed = self._parse_tool_line_or_block(line, content) parsed = self._parse_tool_line_or_block(line, content)
if parsed is None: if parsed is None:
continue continue
name, preview = parsed name, preview, args = parsed
# A new tool begins: close the previously-open one. # A new tool begins: close the previously-open one, attaching the
# output/duration/ok captured by the post_tool_call hook.
if state.open_tool_index is not None: if state.open_tool_index is not None:
extra = _tool_end_fields(state.open_tool_name or "")
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id), protocol.tool_end(
chat_id, state.open_tool_index, state.open_tool_name or "",
ok=extra.get("ok", True),
duration=extra.get("duration"),
output_preview=extra.get("output_preview"),
thread_id=thread_id,
),
) )
state.tool_index += 1 state.tool_index += 1
state.open_tool_index = state.tool_index state.open_tool_index = state.tool_index
state.open_tool_name = name
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
protocol.tool_start( protocol.tool_start(
chat_id, state.tool_index, name, chat_id, state.tool_index, name,
preview=preview, thread_id=thread_id, preview=preview, args=args, thread_id=thread_id,
), ),
) )
return SendResult(success=True, message_id=message_id) return SendResult(success=True, message_id=message_id)
@staticmethod @staticmethod
def _parse_tool_line_or_block(line: str, content: str) -> Optional[Tuple[str, Optional[str]]]: def _parse_tool_line_or_block(
"""Parse a tool line, expanding a terminal code block to its command.""" line: str, content: str
) -> Optional[Tuple[str, Optional[str], Optional[Dict[str, Any]]]]:
"""Parse a tool line into ``(name, preview, args)``.
Expands a terminal code block to its command, and a verbose header
(``<emoji> <name>(keys)``) to its full args JSON (the JSON sits on the
following line). ``args`` is ``None`` unless the line is a verbose
header with a parseable JSON body.
"""
parsed = _parse_tool_line(line) parsed = _parse_tool_line(line)
if parsed is not None: if parsed is None:
return None
name, preview = parsed name, preview = parsed
# Terminal code block: the command lives in the fenced lines that # Terminal code block: the command lives in the fenced lines that
# follow the "<emoji> terminal" head line. # follow the "<emoji> terminal" head line.
if name == "terminal" and preview is None and "```" in content: if name == "terminal" and preview is None and "```" in content:
cmd = _extract_code_block(content) cmd = _extract_code_block(content)
if cmd: if cmd:
return name, cmd return name, cmd, None
return parsed # Verbose mode: recover the full args from the following JSON line.
return None args = _extract_verbose_args(line, content)
if args is not None and preview is None:
preview = _short_preview_from_args(args)
return name, preview, args
async def _close_open_tool( async def _close_open_tool(
self, chat_id: str, state: _TurnState, thread_id: Optional[str] self, chat_id: str, state: _TurnState, thread_id: Optional[str]
@@ -1137,11 +1370,19 @@ class AndroidAdapter(BasePlatformAdapter):
tool it was waiting on has returned). tool it was waiting on has returned).
""" """
if state.open_tool_index is not None: if state.open_tool_index is not None:
extra = _tool_end_fields(state.open_tool_name or "")
await self._broadcast_or_log( await self._broadcast_or_log(
chat_id, chat_id,
protocol.tool_end(chat_id, state.open_tool_index, "", ok=True, thread_id=thread_id), protocol.tool_end(
chat_id, state.open_tool_index, state.open_tool_name or "",
ok=extra.get("ok", True),
duration=extra.get("duration"),
output_preview=extra.get("output_preview"),
thread_id=thread_id,
),
) )
state.open_tool_index = None state.open_tool_index = None
state.open_tool_name = None
def _reset_tool_state(self, state: _TurnState) -> None: def _reset_tool_state(self, state: _TurnState) -> None:
"""Clear per-turn tool bookkeeping (called at turn finalization).""" """Clear per-turn tool bookkeeping (called at turn finalization)."""
@@ -1149,6 +1390,10 @@ class AndroidAdapter(BasePlatformAdapter):
state.seen_tool_lines = set() state.seen_tool_lines = set()
state.tool_index = 0 state.tool_index = 0
state.open_tool_index = None state.open_tool_index = None
state.open_tool_name = None
# Drop any captured tool results not consumed by a tool.end this turn
# (e.g. tool_progress off) so they can't leak into the next turn.
_reset_tool_results()
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None: async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
delivered = await self._ws_server.broadcast(frame) delivered = await self._ws_server.broadcast(frame)
@@ -1465,6 +1710,33 @@ class AndroidAdapter(BasePlatformAdapter):
if not isinstance(reply_to, str) or not reply_to.strip(): if not isinstance(reply_to, str) or not reply_to.strip():
reply_to = None reply_to = None
# Auto-threading (the app's Threads setting, docs/06 §6.3): a message
# in a channel's flat lane gets its own fresh thread, the way Telegram
# topic mode mints a topic per new conversation. The thread is named
# instantly from the user's opening message (derived title) and the
# LLM upgrades the name in the background. The user echo, the agent
# turn, and all streaming frames then carry the new thread_id.
# Skipped for slash commands (session-scoped, not conversation
# starters) and replies (they continue where the user is).
auto_thread = payload.get("auto_thread") is True
if (
auto_thread
and thread_id is None
and text.strip()
and not text.lstrip().startswith("/")
and reply_to is None
):
entry = self._channels.create(
name=_derive_thread_name(text),
kind="thread",
parent_chat_id=chat_id,
)
thread_id = entry["chat_id"]
# Bare broadcast (like channel.create): the directory is
# re-served on hello.ack, so no outbox entry is needed.
await self._ws_server.broadcast(protocol.channel_created(entry, auto=True))
self._schedule_thread_title_upgrade(entry["chat_id"], text)
# M4: resolve media refs (single-use; unknown ref -> error). # M4: resolve media refs (single-use; unknown ref -> error).
media_urls: List[str] = [] media_urls: List[str] = []
media_types: List[str] = [] media_types: List[str] = []
@@ -1494,6 +1766,12 @@ class AndroidAdapter(BasePlatformAdapter):
# Echo to all devices: the sender confirms (server-assigned id), # Echo to all devices: the sender confirms (server-assigned id),
# other devices see the message too (single-user, multi-device). # other devices see the message too (single-user, multi-device).
# Routed through _broadcast_or_log (not a bare broadcast) so the echo
# is appended to the outbox: the app's ChatStore is in-memory only, so
# after a process death / activity recreation the only way the user's
# own message is restored is via the sync replay. Without this, user
# messages vanish on reconnect while bot messages (already parked)
# survive.
message_id = f"m_{uuid.uuid4().hex[:16]}" message_id = f"m_{uuid.uuid4().hex[:16]}"
echo = protocol.message( echo = protocol.message(
chat_id=chat_id, chat_id=chat_id,
@@ -1505,7 +1783,7 @@ class AndroidAdapter(BasePlatformAdapter):
reply_to=reply_to, reply_to=reply_to,
ts=int(time.time() * 1000), ts=int(time.time() * 1000),
) )
await self._ws_server.broadcast(echo) await self._broadcast_or_log(chat_id, echo)
# Refs are consumed by this message (no replay). # Refs are consumed by this message (no replay).
for ref in media_refs: for ref in media_refs:
self._media.pop_inbound(ref) self._media.pop_inbound(ref)
@@ -1554,6 +1832,48 @@ class AndroidAdapter(BasePlatformAdapter):
protocol.read_receipt(chat_id, message_id), protocol.read_receipt(chat_id, message_id),
) )
def _schedule_thread_title_upgrade(self, thread_id: str, text: str) -> None:
"""Upgrade an auto-created thread's name with the model's title.
Stage 2 of hermes' two-stage session titling (``agent/title_generator
.py``): the thread was created with an instant derived name; this
background call on the ``title_generation`` auxiliary task replaces it
with the model's title and broadcasts ``channel.renamed``. Best-effort
— any failure (config, model, network) leaves the derived name in
place, and a thread the user already renamed or archived is untouched.
"""
loop = asyncio.get_running_loop()
def _work() -> None:
try:
from agent.title_generator import generate_title
title = generate_title(text)
except Exception:
logger.debug("Thread title upgrade failed", exc_info=True)
return
if not title:
return
entry = self._channels.get(thread_id)
if entry is None or entry.get("archived"):
return
if (entry.get("name") or "") == title:
return
renamed = self._channels.rename(thread_id, title)
if renamed is None:
return
try:
asyncio.run_coroutine_threadsafe(
self._ws_server.broadcast(protocol.channel_renamed(renamed)),
loop,
)
except Exception:
logger.debug("Thread title rename broadcast failed", exc_info=True)
threading.Thread(
target=_work, daemon=True, name="android-thread-title"
).start()
# ── M4: inbound media (app -> agent) ────────────────────────────────── # ── M4: inbound media (app -> agent) ──────────────────────────────────
# #
# ``media.upload.start`` -> raw binary frames (one at a time per # ``media.upload.start`` -> raw binary frames (one at a time per
@@ -1787,6 +2107,59 @@ class AndroidAdapter(BasePlatformAdapter):
resp.id = frame.id resp.id = frame.id
await self._ws_server.broadcast(resp) await self._ws_server.broadcast(resp)
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id),
)
return
on = bool(frame.payload.get("on"))
entry = self._channels.set_favorite(chat_id, on)
if entry is None:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
# Reuse the renamed event shape: it carries the full entry (incl. the
# new favorite flag) so every device reconciles the change.
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id),
)
return
payload = frame.payload
icon = payload.get("icon")
icon = icon if isinstance(icon, str) and icon else None
color = payload.get("color")
color = color if isinstance(color, str) and color else None
# Guard against a runaway base64 blob (a channel icon is small).
if icon is not None and len(icon) > 512 * 1024:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
)
return
entry = self._channels.set_icon(chat_id, icon, color)
if entry is None:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
)
return
resp = protocol.channel_renamed(entry)
resp.id = frame.id
await self._ws_server.broadcast(resp)
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None: async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
chat_id = frame.chat_id or frame.payload.get("chat_id") chat_id = frame.chat_id or frame.payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip(): if not isinstance(chat_id, str) or not chat_id.strip():
@@ -1875,6 +2248,100 @@ class AndroidAdapter(BasePlatformAdapter):
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id) done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
await self._ws_server.send_to(device_id, done) await self._ws_server.send_to(device_id, done)
# ── Full message history (initial channel open / scroll-up) ───────────
async def on_history(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``history`` request.
``sync`` only replays the outbox delta since the device's cursor, so
after a process death the app's in-memory ChatStore is empty and the
delta does not cover older messages. ``history`` loads the full
message list for a chat/thread (reconstructed from the outbox log) so
the app can populate the view on first open / restart.
"""
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
)
return
chat_id = chat_id.strip()
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
before = payload.get("before_message_id")
if not isinstance(before, str) or not before.strip():
before = None
limit_raw = payload.get("limit")
try:
limit = int(limit_raw) if limit_raw is not None else 50
except (TypeError, ValueError):
limit = 50
page = self._outbox.history(
chat_id,
thread_id=thread_id,
before_message_id=before,
limit=limit,
)
resp = protocol.history(
chat_id,
page["messages"],
page["has_more"],
thread_id=thread_id,
oldest_message_id=page["oldest_message_id"],
id=frame.id,
)
await self._ws_server.send_to(device_id, resp)
# ── Message deletion (app -> agent) ───────────────────────────────────
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``message.delete`` request.
Removes the requested message(s) from the outbox (so ``history`` and
``sync`` no longer return them) and broadcasts ``message.deleted`` to
every device (outboxed too, so an offline device learns of the
deletion on its next ``sync``). Deleting is idempotent: a message that
is already gone (pruned by retention) simply yields 0 removed rows,
and the ``message.deleted`` broadcast is still emitted so live caches
drop it.
"""
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id),
)
return
chat_id = chat_id.strip()
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
message_ids = payload.get("message_ids")
if not isinstance(message_ids, list):
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
if not message_ids:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id),
)
return
removed = 0
for mid in message_ids:
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
logger.info(
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s",
device_id, chat_id, thread_id, message_ids, removed,
)
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
resp.id = frame.id
await self._broadcast_or_log(chat_id, resp)
# ── M5: push token registration ─────────────────────────────────────── # ── M5: push token registration ───────────────────────────────────────
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None: async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
@@ -2123,6 +2590,13 @@ def register(ctx):
ctx.register_hook("on_stream_delta", _on_stream_delta) ctx.register_hook("on_stream_delta", _on_stream_delta)
except Exception: except Exception:
logger.debug("android: on_stream_delta hook registration failed", exc_info=True) logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
# M2: capture each completed tool call's result + timing so the tool.end
# frame can carry the output (the gateway never streams tool output to
# platforms). The app shows it on demand (Settings → Tool detail).
try:
ctx.register_hook("post_tool_call", _on_post_tool_call)
except Exception:
logger.debug("android: post_tool_call hook registration failed", exc_info=True)
ctx.register_platform( ctx.register_platform(
name="android", name="android",
label="Android", label="Android",
+50 -3
View File
@@ -64,10 +64,25 @@ class ChannelDirectory:
parent_chat_id TEXT, parent_chat_id TEXT,
is_default INTEGER NOT NULL DEFAULT 0, is_default INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0,
created REAL NOT NULL DEFAULT 0 created REAL NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0,
icon TEXT,
color TEXT
) )
""" """
) )
# Migrate existing DBs: add the cosmetic columns if missing.
existing = {
row[1] for row in self._conn.execute("PRAGMA table_info(channels)")
}
if "favorite" not in existing:
self._conn.execute(
"ALTER TABLE channels ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0"
)
if "icon" not in existing:
self._conn.execute("ALTER TABLE channels ADD COLUMN icon TEXT")
if "color" not in existing:
self._conn.execute("ALTER TABLE channels ADD COLUMN color TEXT")
self._conn.execute( self._conn.execute(
""" """
CREATE TABLE IF NOT EXISTS counters ( CREATE TABLE IF NOT EXISTS counters (
@@ -206,6 +221,35 @@ class ChannelDirectory:
self._conn.commit() self._conn.commit()
return self.get(chat_id) return self.get(chat_id)
def set_favorite(self, chat_id: str, on: bool) -> Optional[Dict[str, Any]]:
"""Toggle the cosmetic favorite flag (sorts to the top of the list)."""
with self._lock:
cur = self._conn.execute(
"UPDATE channels SET favorite = ? WHERE chat_id = ? AND archived = 0",
(1 if on else 0, chat_id),
)
self._conn.commit()
if cur.rowcount == 0:
return None
return self.get(chat_id)
def set_icon(self, chat_id: str, icon: Optional[str], color: Optional[str]) -> Optional[Dict[str, Any]]:
"""Set the channel's cosmetic icon (base64 image) and/or avatar color.
``icon`` is a base64-encoded image (or ``None`` to clear it); ``color``
is a ``#RRGGBB`` hex string (or ``None`` to clear it). Both are purely
cosmetic and independent of the name / default flag.
"""
with self._lock:
cur = self._conn.execute(
"UPDATE channels SET icon = ?, color = ? WHERE chat_id = ? AND archived = 0",
(icon, color, chat_id),
)
self._conn.commit()
if cur.rowcount == 0:
return None
return self.get(chat_id)
def delete(self, chat_id: str) -> Optional[Dict[str, Any]]: def delete(self, chat_id: str) -> Optional[Dict[str, Any]]:
"""Soft-delete (archive) a channel. History stays for search. """Soft-delete (archive) a channel. History stays for search.
@@ -234,11 +278,11 @@ class ChannelDirectory:
return _row_to_entry(row) if row else None return _row_to_entry(row) if row else None
def list(self, include_archived: bool = False) -> List[Dict[str, Any]]: def list(self, include_archived: bool = False) -> List[Dict[str, Any]]:
"""Directory listing. Default channel first, then creation order.""" """Directory listing. Default first, then favorites, then creation order."""
sql = "SELECT * FROM channels" sql = "SELECT * FROM channels"
if not include_archived: if not include_archived:
sql += " WHERE archived = 0" sql += " WHERE archived = 0"
sql += " ORDER BY is_default DESC, created ASC" sql += " ORDER BY is_default DESC, favorite DESC, created ASC"
with self._lock: with self._lock:
rows = self._conn.execute(sql).fetchall() rows = self._conn.execute(sql).fetchall()
return [_row_to_entry(r) for r in rows] return [_row_to_entry(r) for r in rows]
@@ -318,6 +362,9 @@ def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
"is_default": bool(row["is_default"]), "is_default": bool(row["is_default"]),
"archived": bool(row["archived"]), "archived": bool(row["archived"]),
"created": row["created"], "created": row["created"],
"favorite": bool(row["favorite"]),
"icon": row["icon"],
"color": row["color"],
} }
+157
View File
@@ -169,6 +169,163 @@ class Outbox:
) )
return out return out
# ── history (full message history for a chat/thread) ──────────────────
def history(
self,
chat_id: str,
thread_id: Optional[str] = None,
before_message_id: Optional[str] = None,
limit: int = 50,
) -> Dict[str, Any]:
"""Final messages for a chat/thread, for the ``history`` frame.
Reconstructs the message list from the outbox log: a final message is
either a standalone ``message`` frame (user echo / non-streaming
assistant) or a ``message.stop`` frame (streaming assistant final).
Intermediate frames (``message.start``/``message.update``, tool,
commentary, notification, …) are skipped. Messages are deduplicated by
``message_id`` and returned oldest → newest.
Pagination: ``before_message_id`` returns the page of messages older
than that id (the newest page when omitted). Returns
``{messages, has_more, oldest_message_id}``.
"""
limit = max(1, min(int(limit or 50), 200))
with self._lock:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ? "
"ORDER BY cursor ASC",
(chat_id,),
).fetchall()
final: List[Dict[str, Any]] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if thread_id is not None and frame.get("thread_id") != thread_id:
continue
ftype = frame.get("type")
payload = frame.get("payload")
if not isinstance(payload, dict):
continue
if ftype == "message":
role = payload.get("role")
if role not in ("user", "assistant"):
continue
final.append(
{
"cursor": int(r["cursor"]),
"message_id": payload.get("message_id"),
"role": role,
"text": payload.get("text", ""),
"reasoning": payload.get("reasoning"),
"model": payload.get("model"),
"tokens": payload.get("tokens"),
"ts": payload.get("ts"),
"media": payload.get("media"),
}
)
elif ftype == "message.stop":
final.append(
{
"cursor": int(r["cursor"]),
"message_id": payload.get("message_id"),
"role": "assistant",
"text": payload.get("final_text", ""),
"reasoning": payload.get("reasoning"),
"model": payload.get("model"),
"tokens": payload.get("tokens"),
"ts": payload.get("ts"),
"media": None,
}
)
# Deduplicate by message_id (keep the latest occurrence), keep order.
by_id: Dict[str, Dict[str, Any]] = {}
for m in final:
mid = m.get("message_id")
if mid:
by_id[mid] = m
ordered = sorted(by_id.values(), key=lambda m: m["cursor"])
# Paginate: messages older than ``before_message_id`` (newest page when
# omitted / not found — a pruned anchor falls back to the newest page).
if before_message_id:
idx = next(
(i for i, m in enumerate(ordered) if m["message_id"] == before_message_id),
None,
)
pool = ordered[:idx] if idx is not None else ordered
else:
pool = ordered
has_more = len(pool) > limit
page = pool[-limit:] if has_more else list(pool)
oldest_message_id = page[0]["message_id"] if page else None
for m in page:
m.pop("cursor", None)
# Omit absent optional fields (the app's serializer treats a
# missing key as its default, but a JSON ``null`` for a
# non-nullable field like ``media`` would fail to parse).
for key in ("reasoning", "model", "tokens", "ts", "media"):
if m.get(key) is None:
m.pop(key, None)
return {
"messages": page,
"has_more": has_more,
"oldest_message_id": oldest_message_id,
}
# ── message deletion ──────────────────────────────────────────────────
def delete_message(
self,
chat_id: str,
message_id: str,
thread_id: Optional[str] = None,
) -> int:
"""Remove every outbox frame belonging to *message_id* in *chat_id*.
A message can span several frames (``message`` / ``message.start`` /
``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames
with no ``thread_id``, and a thread delete matches only that thread's
frames (a ``message_id`` is unique to one lane, so this is a safety
net, not a filter that drops real frames). Returns the number of rows
removed (0 when the message is not in the outbox — e.g. already pruned
by retention).
"""
if not message_id:
return 0
with self._lock:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
cursors: List[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"]))
if not cursors:
return 0
placeholders = ",".join("?" * len(cursors))
self._conn.execute(
f"DELETE FROM outbox WHERE cursor IN ({placeholders})", cursors
)
self._conn.commit()
return len(cursors)
# ── retention ───────────────────────────────────────────────────────── # ── retention ─────────────────────────────────────────────────────────
def _maybe_prune(self) -> None: def _maybe_prune(self) -> None:
+86 -3
View File
@@ -42,6 +42,10 @@ TYPE_MESSAGE_START = "message.start"
TYPE_MESSAGE_UPDATE = "message.update" TYPE_MESSAGE_UPDATE = "message.update"
TYPE_MESSAGE_STOP = "message.stop" TYPE_MESSAGE_STOP = "message.stop"
# Message deletion (app requests; broadcast to all devices)
TYPE_MESSAGE_DELETE = "message.delete"
TYPE_MESSAGE_DELETED = "message.deleted"
# Tool activity (M2) # Tool activity (M2)
TYPE_TOOL_START = "tool.start" TYPE_TOOL_START = "tool.start"
TYPE_TOOL_PROGRESS = "tool.progress" TYPE_TOOL_PROGRESS = "tool.progress"
@@ -54,6 +58,8 @@ TYPE_COMMENTARY = "commentary"
TYPE_CHANNEL_CREATE = "channel.create" TYPE_CHANNEL_CREATE = "channel.create"
TYPE_CHANNEL_RENAME = "channel.rename" TYPE_CHANNEL_RENAME = "channel.rename"
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
TYPE_CHANNEL_FAVORITE = "channel.favorite"
TYPE_CHANNEL_ICON = "channel.icon"
TYPE_CHANNEL_DELETE = "channel.delete" TYPE_CHANNEL_DELETE = "channel.delete"
TYPE_CHANNEL_CREATED = "channel.created" TYPE_CHANNEL_CREATED = "channel.created"
TYPE_CHANNEL_RENAMED = "channel.renamed" TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -68,6 +74,9 @@ TYPE_SEARCH_RESULTS = "search.results"
TYPE_SYNC = "sync" TYPE_SYNC = "sync"
TYPE_SYNC_DONE = "sync.done" TYPE_SYNC_DONE = "sync.done"
# Full message history (initial channel open / scroll-up pagination)
TYPE_HISTORY = "history"
# Media (M4) # Media (M4)
TYPE_MEDIA_UPLOAD_START = "media.upload.start" TYPE_MEDIA_UPLOAD_START = "media.upload.start"
TYPE_MEDIA_UPLOAD_END = "media.upload.end" TYPE_MEDIA_UPLOAD_END = "media.upload.end"
@@ -427,12 +436,27 @@ def _channel_payload(entry: Dict[str, Any]) -> Dict[str, Any]:
payload["is_default"] = True payload["is_default"] = True
if entry.get("archived"): if entry.get("archived"):
payload["archived"] = True payload["archived"] = True
if entry.get("favorite"):
payload["favorite"] = True
if entry.get("icon"):
payload["icon"] = entry["icon"]
if entry.get("color"):
payload["color"] = entry["color"]
return payload return payload
def channel_created(entry: Dict[str, Any]) -> Frame: def channel_created(entry: Dict[str, Any], auto: bool = False) -> Frame:
"""Broadcast: a channel/thread was created.""" """Broadcast: a channel/thread was created.
return Frame(type=TYPE_CHANNEL_CREATED, payload=_channel_payload(entry))
``auto=True`` marks a thread the gateway minted itself for an incoming
message (auto-threading, docs/06 §6.3): the app jumps into it and the
name is an instant derived title, upgraded by the LLM via a follow-up
``channel.renamed``.
"""
payload = _channel_payload(entry)
if auto:
payload["auto"] = True
return Frame(type=TYPE_CHANNEL_CREATED, payload=payload)
def channel_renamed(entry: Dict[str, Any]) -> Frame: def channel_renamed(entry: Dict[str, Any]) -> Frame:
@@ -484,6 +508,65 @@ def sync_done(cursor: int, *, id: Optional[int] = None) -> Frame:
return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor}) return Frame(type=TYPE_SYNC_DONE, id=id, payload={"cursor": cursor})
# ---------------------------------------------------------------------------
# History frame (full message history for a chat/thread)
# ---------------------------------------------------------------------------
def history(
chat_id: str,
messages: List[Dict[str, Any]],
has_more: bool,
*,
thread_id: Optional[str] = None,
oldest_message_id: Optional[str] = None,
id: Optional[int] = None,
) -> Frame:
"""Response to a ``history`` request: a page of final messages for a
chat/thread, ordered oldest → newest. ``has_more`` signals older pages
exist; ``oldest_message_id`` is the ``before_message_id`` for the next
(older) page."""
payload: Dict[str, Any] = {
"messages": messages,
"has_more": has_more,
}
if oldest_message_id:
payload["oldest_message_id"] = oldest_message_id
return Frame(
type=TYPE_HISTORY,
id=id,
chat_id=chat_id,
thread_id=thread_id,
payload=payload,
)
# ---------------------------------------------------------------------------
# Message deletion frames
# ---------------------------------------------------------------------------
def message_deleted(
chat_id: str,
message_ids: List[str],
*,
thread_id: Optional[str] = None,
id: Optional[int] = None,
) -> Frame:
"""Broadcast: the given message(s) were deleted from a chat/thread.
Carried by the response to a ``message.delete`` request (``id`` set) and
broadcast to every device so all of them drop the message(s) from their
cache. Also outboxed, so a device that was offline learns of the deletion
on its next ``sync``.
"""
return Frame(
type=TYPE_MESSAGE_DELETED,
id=id,
chat_id=chat_id,
thread_id=thread_id,
payload={"message_ids": list(message_ids)},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Push / notification frames (M5) # Push / notification frames (M5)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8
View File
@@ -370,6 +370,10 @@ class WsServer:
await self._adapter.on_channel_rename(frame, device_id) await self._adapter.on_channel_rename(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT: elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
await self._adapter.on_channel_set_default(frame, device_id) await self._adapter.on_channel_set_default(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
await self._adapter.on_channel_favorite(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_ICON:
await self._adapter.on_channel_icon(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_DELETE: elif frame.type == protocol.TYPE_CHANNEL_DELETE:
await self._adapter.on_channel_delete(frame, device_id) await self._adapter.on_channel_delete(frame, device_id)
elif frame.type == protocol.TYPE_CHANNEL_LIST: elif frame.type == protocol.TYPE_CHANNEL_LIST:
@@ -378,6 +382,10 @@ class WsServer:
await self._adapter.on_search(frame, device_id) await self._adapter.on_search(frame, device_id)
elif frame.type == protocol.TYPE_SYNC: elif frame.type == protocol.TYPE_SYNC:
await self._adapter.on_sync(frame, device_id) await self._adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await self._adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await self._adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START: elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
await self._adapter.on_media_upload_start(frame, device_id) await self._adapter.on_media_upload_start(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END: elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
File renamed without changes.