Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d717c10256 | ||
|
|
2f0cdbba17 | ||
|
|
0951ebbbce | ||
|
|
a8c4418aff | ||
|
|
8100730cba | ||
|
|
bff363d579 | ||
|
|
5b9427b10d | ||
|
|
f5e39c0752 | ||
|
|
b681e547e7 | ||
|
|
0d99ee5cf5 | ||
|
|
8b40eb6e4c | ||
|
|
ccc40213ca | ||
|
|
c668b209b2 | ||
|
|
de3b5fd956 | ||
|
|
6f4bf3cb7f | ||
|
|
60ec2b44a7 | ||
|
|
1d67de5f12 |
No files matched your search
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -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 |
|
After Width: | Height: | Size: 54 KiB |
@@ -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 |
|
After Width: | Height: | Size: 50 KiB |
@@ -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 |
|
After Width: | Height: | Size: 193 KiB |
@@ -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 |
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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" /
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||