Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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`.
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
<application
|
||||
android:label="Iris"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
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>
|
||||
@@ -75,6 +75,7 @@ tasks.register<Exec>("jpackage") {
|
||||
"--input", inputDir.absolutePath,
|
||||
"--main-jar", "desktopApp-all.jar",
|
||||
"--main-class", "iris.desktop.MainKt",
|
||||
"--icon", file("src/main/resources/icon.png").absolutePath,
|
||||
"--dest", destDir.absolutePath,
|
||||
"--java-options", "-Xmx1g",
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import iris.platform.DesktopBridge
|
||||
import iris.platform.DesktopSecureStore
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics2D
|
||||
import javax.imageio.ImageIO
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.SystemTray
|
||||
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")
|
||||
|
||||
// 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.
|
||||
private const val TRAY_OFFLINE = 0xFF9E9E9E.toInt()
|
||||
private const val TRAY_CONNECTING = 0xFFFFC107.toInt()
|
||||
@@ -107,6 +112,7 @@ fun main() {
|
||||
}
|
||||
|
||||
Window(
|
||||
icon = BitmapPainter(windowIcon),
|
||||
onCloseRequest = {
|
||||
if (closeToTray.get()) {
|
||||
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,10 @@ val composeVersion = "1.7.3"
|
||||
val coroutinesVersion = "1.9.0"
|
||||
val serializationVersion = "1.7.3"
|
||||
val okhttpVersion = "4.12.0"
|
||||
// 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 {
|
||||
androidTarget()
|
||||
@@ -27,11 +31,23 @@ kotlin {
|
||||
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
||||
implementation("org.jetbrains.compose.material3:material3:$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-serialization-json:$serializationVersion")
|
||||
// OkHttp is a JVM library; both targets here are JVM-based
|
||||
// (androidTarget + jvm("desktop")), so it resolves on both.
|
||||
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")
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||
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? {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||
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
|
||||
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||
val path = media.localPath
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.Build
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import iris.data.SecureStore
|
||||
import iris.ui.theme.UserTheme
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -97,6 +98,42 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
||||
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) {
|
||||
serverUrl = url
|
||||
this.token = token
|
||||
@@ -116,5 +153,14 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
const val KEY_FCM_TOKEN = "fcm_token"
|
||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||
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,5 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier = this
|
||||
@@ -1,24 +1,28 @@
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.setActiveController
|
||||
import iris.state.IrisController
|
||||
import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
import iris.ui.screens.ConnectingScreen
|
||||
import iris.ui.theme.IrisColors
|
||||
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.
|
||||
@@ -49,24 +53,50 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
val theme by controller.theme.collectAsState()
|
||||
|
||||
IrisTheme {
|
||||
Surface(modifier = Modifier.background(IrisColors.background)) {
|
||||
val s = state
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// M7: initial connect in flight — dedicated screen, not an empty chat.
|
||||
GatewayClient.State.Connecting ->
|
||||
ConnectingScreen(hostFromUrl(store.serverUrl))
|
||||
else -> ChatScreen(controller)
|
||||
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
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class ChannelStore {
|
||||
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
||||
list.sortedWith(
|
||||
compareByDescending<ChannelInfo> { it.isDefault }
|
||||
.thenByDescending { it.favorite }
|
||||
.thenBy { it.kind != "thread" }
|
||||
.thenBy { it.name.lowercase() },
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.ToolEndPayload
|
||||
import iris.protocol.ToolProgressPayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
import iris.util.nowMillis
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -50,7 +51,9 @@ enum class MsgStatus {
|
||||
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(
|
||||
override val id: String,
|
||||
val role: String,
|
||||
@@ -64,6 +67,7 @@ data class MessageItem(
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val media: List<MediaItem> = emptyList(),
|
||||
val isSystem: Boolean = false,
|
||||
) : ChatItem
|
||||
|
||||
/**
|
||||
@@ -106,6 +110,12 @@ class ChatStore {
|
||||
private var localSeq = 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 {
|
||||
const val DEFAULT_LANE = "android:default"
|
||||
|
||||
@@ -153,6 +163,17 @@ class ChatStore {
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
/** 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) {
|
||||
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 ->
|
||||
val byId = list.indexOfFirst { it.id == p.messageId }
|
||||
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. */
|
||||
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
|
||||
if (incoming.isEmpty()) return existing
|
||||
@@ -239,6 +285,7 @@ class ChatStore {
|
||||
// ── message.start (open a live streaming bubble) ──────────────────────
|
||||
|
||||
private fun onMessageStart(lane: String, frame: Frame) {
|
||||
if (!streamingEnabled) return
|
||||
val p = frame.payloadAs<MessageStartPayload>() ?: return
|
||||
updateLane(lane) { list ->
|
||||
if (list.any { it.id == p.messageId }) list
|
||||
@@ -336,6 +383,14 @@ class ChatStore {
|
||||
|
||||
private fun onCommentary(lane: String, frame: Frame) {
|
||||
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 ->
|
||||
if (list.any { it.id == p.messageId }) list
|
||||
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) ─────────────────────
|
||||
|
||||
/**
|
||||
@@ -412,6 +475,51 @@ class ChatStore {
|
||||
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). */
|
||||
fun failPending() {
|
||||
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() {
|
||||
_lanes.value = emptyMap()
|
||||
}
|
||||
|
||||
@@ -30,6 +30,33 @@ interface SecureStore {
|
||||
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
||||
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 clear()
|
||||
}
|
||||
@@ -92,6 +92,10 @@ class GatewayClient(
|
||||
private var socket: WebSocket? = null
|
||||
private var nextRequestId = 1
|
||||
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 val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||
|
||||
@@ -118,6 +122,7 @@ class GatewayClient(
|
||||
fun start() {
|
||||
if (connectJob?.isActive == true) return
|
||||
attempt = 0
|
||||
hasConnected = false
|
||||
connectJob = scope.launch { connectLoop() }
|
||||
}
|
||||
|
||||
@@ -144,7 +149,7 @@ class GatewayClient(
|
||||
_state.value = State.Disconnected
|
||||
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)
|
||||
when (val result = dial.result) {
|
||||
is DialResult.AuthFailed -> {
|
||||
@@ -153,6 +158,7 @@ class GatewayClient(
|
||||
return
|
||||
}
|
||||
DialResult.Connected -> {
|
||||
hasConnected = true
|
||||
attempt = 0
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
dial.closed.await()
|
||||
@@ -289,16 +295,19 @@ class GatewayClient(
|
||||
// ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
/** 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(
|
||||
chatId: String,
|
||||
text: String,
|
||||
threadId: String? = null,
|
||||
mediaRefs: List<String> = emptyList(),
|
||||
autoThread: Boolean = false,
|
||||
) {
|
||||
val ws = socket ?: return
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
@@ -20,12 +20,33 @@ data class PickedFile(
|
||||
@Composable
|
||||
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). */
|
||||
expect fun mediaCacheBaseDir(): String
|
||||
|
||||
/** Load an image from a local path for display. Null on failure. */
|
||||
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). */
|
||||
@Composable
|
||||
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
|
||||
|
||||
@@ -3,6 +3,7 @@ package iris.protocol
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
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_UPDATE = "message.update"
|
||||
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_PROGRESS = "tool.progress"
|
||||
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_RENAME = "channel.rename"
|
||||
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_CREATED = "channel.created"
|
||||
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_SYNC = "sync"
|
||||
const val TYPE_SYNC_DONE = "sync.done"
|
||||
const val TYPE_HISTORY = "history"
|
||||
|
||||
// ── Error codes ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -149,6 +157,17 @@ data class ChannelInfo(
|
||||
@SerialName("is_default") val isDefault: Boolean = false,
|
||||
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
||||
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
|
||||
@@ -289,6 +308,9 @@ data class MessageSendPayload(
|
||||
val text: String,
|
||||
@SerialName("reply_to") val replyTo: String? = null,
|
||||
@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 ───────────────────────────────────────────────
|
||||
@@ -314,6 +336,17 @@ data class ChannelCreatePayload(
|
||||
@Serializable
|
||||
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
|
||||
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
|
||||
|
||||
@@ -356,6 +389,36 @@ data class SyncPayload(val cursor: Long)
|
||||
@Serializable
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||
@@ -424,6 +487,7 @@ fun messageSendFrame(
|
||||
text: String,
|
||||
threadId: String? = null,
|
||||
mediaRefs: List<String> = emptyList(),
|
||||
autoThread: Boolean = false,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
@@ -432,7 +496,7 @@ fun messageSendFrame(
|
||||
threadId = threadId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
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 =
|
||||
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 =
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
fun mediaUploadStartFrame(
|
||||
|
||||
@@ -13,8 +13,13 @@ import iris.platform.PickedFile
|
||||
import iris.platform.isAppForeground
|
||||
import iris.platform.mediaCacheBaseDir
|
||||
import iris.platform.postSystemNotification
|
||||
import iris.protocol.ChannelDeletedPayload
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
|
||||
import iris.protocol.HistoryMessage
|
||||
import iris.protocol.HistoryPayload
|
||||
import iris.protocol.MediaOfferPayload
|
||||
import iris.protocol.MessageDeletedPayload
|
||||
import iris.protocol.MessagePayload
|
||||
import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.NotificationPayload
|
||||
@@ -25,6 +30,7 @@ import iris.protocol.SearchResultsPayload
|
||||
import iris.protocol.StatusPayload
|
||||
import iris.protocol.SyncDonePayload
|
||||
import iris.protocol.TYPE_ERROR
|
||||
import iris.protocol.TYPE_HISTORY
|
||||
import iris.protocol.TYPE_NOTIFICATION
|
||||
import iris.protocol.TYPE_CHANNEL_CREATED
|
||||
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_MEDIA_OFFER
|
||||
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_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
@@ -47,11 +55,17 @@ import iris.protocol.TYPE_TYPING
|
||||
import iris.protocol.TypingPayload
|
||||
import iris.protocol.channelCreateFrame
|
||||
import iris.protocol.channelDeleteFrame
|
||||
import iris.protocol.channelFavoriteFrame
|
||||
import iris.protocol.channelIconFrame
|
||||
import iris.protocol.channelListFrame
|
||||
import iris.protocol.channelRenameFrame
|
||||
import iris.protocol.channelSetDefaultFrame
|
||||
import iris.protocol.historyFrame
|
||||
import iris.protocol.messageDeleteFrame
|
||||
import iris.protocol.searchFrame
|
||||
import iris.protocol.syncFrame
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -80,8 +94,9 @@ class IrisController(
|
||||
private val _typing = MutableStateFlow(false)
|
||||
val typing: StateFlow<Boolean> = _typing.asStateFlow()
|
||||
|
||||
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */
|
||||
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED)
|
||||
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely.
|
||||
* Persisted (Settings → "Tool detail"). */
|
||||
private val _toolDetail = MutableStateFlow(ToolDetail.fromName(store.toolDetail))
|
||||
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
|
||||
|
||||
fun cycleToolDetail() {
|
||||
@@ -90,22 +105,103 @@ class IrisController(
|
||||
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
|
||||
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). */
|
||||
private val _homeChannel = MutableStateFlow("android:default")
|
||||
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). */
|
||||
private val _gatewayStatus = MutableStateFlow<String?>(null)
|
||||
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
|
||||
|
||||
// ── 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()
|
||||
|
||||
fun toggleThreads() {
|
||||
_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 ──────────────────────────────────────────────────
|
||||
@@ -205,6 +301,7 @@ class IrisController(
|
||||
}
|
||||
|
||||
init {
|
||||
chat.streamingEnabled = _streamingEnabled.value
|
||||
scope.launch {
|
||||
client.events.collect { frame ->
|
||||
when (frame.type) {
|
||||
@@ -245,7 +342,38 @@ class IrisController(
|
||||
TYPE_CHANNEL_CREATED,
|
||||
TYPE_CHANNEL_RENAMED,
|
||||
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 -> {
|
||||
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
|
||||
_searching.value = false
|
||||
@@ -255,6 +383,22 @@ class IrisController(
|
||||
// cursor is authoritative server-side (outbox).
|
||||
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 -> {
|
||||
frame.payloadAs<NotificationPayload>()?.let { p ->
|
||||
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
|
||||
@@ -273,6 +417,13 @@ class IrisController(
|
||||
TYPE_READ_RECEIPT -> {
|
||||
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 -> {
|
||||
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
|
||||
}
|
||||
@@ -287,13 +438,22 @@ class IrisController(
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
var prevState: GatewayClient.State? = null
|
||||
client.state.collect { s ->
|
||||
val prev = prevState
|
||||
prevState = s
|
||||
if (s is GatewayClient.State.Connected) {
|
||||
channels.setAll(s.channels)
|
||||
val home = s.channels.firstOrNull { it.isDefault }?.chatId
|
||||
if (home != null) {
|
||||
_homeChannel.value = 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.
|
||||
if (s.caps.pushNtfyServer.isNotBlank()) {
|
||||
@@ -301,6 +461,23 @@ class IrisController(
|
||||
}
|
||||
// M5: a deep link tapped before we were connected.
|
||||
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) ───────────────────────────────────
|
||||
|
||||
/** 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) {
|
||||
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) {
|
||||
chat.setLane(chat.laneKey(chatId, threadId))
|
||||
loadHistory(chatId, threadId)
|
||||
}
|
||||
|
||||
// ── M3: channel directory ops (server is authoritative) ───────────────
|
||||
@@ -348,6 +530,17 @@ class IrisController(
|
||||
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) {
|
||||
client.sendFrame(channelDeleteFrame(0, chatId))
|
||||
}
|
||||
@@ -378,6 +571,19 @@ class IrisController(
|
||||
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.
|
||||
* M4: [attachments] (uploaded) are attached via media_refs. */
|
||||
fun send(text: String, attachments: List<PendingAttachment> = emptyList()) {
|
||||
@@ -394,10 +600,17 @@ class IrisController(
|
||||
)
|
||||
}
|
||||
chat.addPending(trimmed, lane, media)
|
||||
client.sendMessage(chatId, trimmed, threadId, refs)
|
||||
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId))
|
||||
_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). */
|
||||
fun retrySend(messageId: String) {
|
||||
val lane = chat.currentLane.value
|
||||
@@ -405,7 +618,25 @@ class IrisController(
|
||||
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
|
||||
if (item.status != MsgStatus.Failed) return
|
||||
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) ──────────────────────────
|
||||
@@ -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"). */
|
||||
enum class ToolDetail {
|
||||
EVERYTHING, // name + full args (collapsible) + output preview
|
||||
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
|
||||
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,216 @@
|
||||
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,
|
||||
) {
|
||||
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 ->
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
},
|
||||
codeBlock = { model ->
|
||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
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.
|
||||
*/
|
||||
@Composable
|
||||
private 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
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -14,7 +12,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.darkColorScheme
|
||||
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.ImageBitmap
|
||||
import iris.platform.loadScaledImage
|
||||
|
||||
/**
|
||||
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
||||
@@ -34,7 +42,6 @@ object IrisColors {
|
||||
val chip = Color(0xFF2A2E3B)
|
||||
val chipSelected = Color(0xFF2A3550)
|
||||
val rowSelected = Color(0xFF232838)
|
||||
val reasoningPanel = Color(0xFF1E212B)
|
||||
val divider = Color(0xFF2A2E3B)
|
||||
|
||||
// Status
|
||||
@@ -70,3 +77,53 @@ fun avatarColor(name: String): Color {
|
||||
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
|
||||
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). */
|
||||
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"). */
|
||||
fun hostFromUrl(url: String): String {
|
||||
val noScheme = url.trim().substringAfter("://")
|
||||
|
||||
@@ -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.sp
|
||||
import iris.data.MediaItem
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.net.StandardProtocolFamily
|
||||
@@ -40,6 +41,7 @@ import java.nio.channels.SocketChannel
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
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 {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
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).
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ package iris.platform
|
||||
|
||||
import iris.data.SecureStore
|
||||
import iris.protocol.IrisJson
|
||||
import iris.ui.theme.UserTheme
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
@@ -34,6 +35,15 @@ class DesktopSecureStore : SecureStore {
|
||||
val fcmToken: String = "",
|
||||
val ntfyTopic: 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 {
|
||||
@@ -133,6 +143,69 @@ class DesktopSecureStore : SecureStore {
|
||||
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) {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = url.trim()))
|
||||
|
||||
@@ -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 =
|
||||
Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString()
|
||||
|
||||
actual fun nowMillis(): Long = System.currentTimeMillis()
|
||||
@@ -63,7 +63,17 @@ Streaming a bubble. `update` carries the **full** current text (app replaces).
|
||||
{"type":"message.start","chat_id":"…","payload":{"message_id":"m_9002","role":"assistant"}}
|
||||
{"type":"message.update","chat_id":"…","payload":{"message_id":"m_9002","text":"partial…"}}
|
||||
{"type":"message.stop","chat_id":"…","payload":{"message_id":"m_9002","final_text":"full…",
|
||||
"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`
|
||||
@@ -116,6 +126,10 @@ subscribe; the server pushes to every open WS).
|
||||
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
|
||||
"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`
|
||||
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`).
|
||||
```json
|
||||
{"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.
|
||||
`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`
|
||||
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).
|
||||
`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`
|
||||
Fetch the full slash-command catalog (for the `Menü` bottom sheet).
|
||||
```json
|
||||
@@ -335,10 +366,11 @@ Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
|
||||
|
||||
- Frames are ordered per connection (TCP/WS). Streaming `message.update` frames
|
||||
for a `message_id` are monotonic; the app may coalesce to the latest.
|
||||
- Terminal frames (`message`, `message.stop`, `tool.end`, `notification`,
|
||||
`picker.*`, `channel.*`, `agent.busy`, `agent.idle`, `history`,
|
||||
`commands.catalog`, `commands.complete`) are **never dropped** under
|
||||
backpressure; only intermediate `message.update`/`tool.progress` are coalesced.
|
||||
- Terminal frames (`message`, `message.stop`, `message.deleted`, `tool.end`,
|
||||
`notification`, `picker.*`, `channel.*`, `agent.busy`, `agent.idle`,
|
||||
`history`, `commands.catalog`, `commands.complete`) are **never dropped**
|
||||
under backpressure; only intermediate `message.update`/`tool.progress` are
|
||||
coalesced.
|
||||
- Anything not delivered live goes to the **outbox** and is replayed by `sync`.
|
||||
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are
|
||||
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
|
||||
while the user is at the bottom.
|
||||
|
||||
**Streaming on/off.** Controlled by hermes `display.platforms.android.streaming`
|
||||
(default follows global). When off, the app just gets one final `message` frame.
|
||||
**Streaming on/off.** Two levels:
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -45,6 +45,34 @@ gateway identity concepts**.
|
||||
- The gateway's `create_handoff_thread` is used where hermes wants to open a
|
||||
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)
|
||||
|
||||
- **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
|
||||
directory (rename broadcasts `channel.renamed`; delete is soft — marks
|
||||
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
|
||||
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
|
||||
`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 /
|
||||
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)
|
||||
- `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.**
|
||||
- Everything: name + full args (collapsible) + output preview.
|
||||
- Truncated (default): `emoji name: "short preview"` one-liner, expandable.
|
||||
- Nothing: suppress tool frames.
|
||||
- Everything: cards start expanded — name + full args + output.
|
||||
- Truncated (default): compact one-liner; tap a card to reveal the full
|
||||
args + output (what was actually called).
|
||||
- Nothing: suppress tool cards.
|
||||
- Spinner while running; ✓/✗ + duration on `tool.end`.
|
||||
|
||||
### Reasoning before message
|
||||
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
|
||||
button) rendered **above** the message body from the `reasoning` field.
|
||||
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
|
||||
- `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` → show "thinking…" indicator in chat header (animated dots).
|
||||
- `agent.idle` → clear indicator.
|
||||
@@ -122,8 +153,20 @@ app/shared/src/
|
||||
highlight bar) — matches the reference left sidebar.
|
||||
- **Thread toggle** in the default chat header: "Threads on/off". On → topic
|
||||
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;
|
||||
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 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()`
|
||||
reflects missing creds.
|
||||
- **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
|
||||
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.
|
||||
12. **Reconnect/sync:** kill the WS (stop gateway briefly) → restart → app
|
||||
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
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"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.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" } } },
|
||||
"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" } } },
|
||||
@@ -49,8 +50,8 @@
|
||||
"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" } } },
|
||||
"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.renamed": { "description": "Also the response to channel.set_default (carries the full entry incl. is_default).", "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 / 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" } } },
|
||||
"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" } },
|
||||
@@ -59,29 +60,34 @@
|
||||
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
|
||||
"pong": { "payload": { "ts": { "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.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": {
|
||||
"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.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "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.rename": { "payload": { "name": { "type": "string" } } },
|
||||
"channel.set_default": { "payload": {} },
|
||||
"channel.delete": { "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.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." } } },
|
||||
"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" } } },
|
||||
"ping": { "payload": { "ts": { "type": "integer" } } }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"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."} } }
|
||||
},
|
||||
"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.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": "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": "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." },
|
||||
@@ -105,7 +109,7 @@
|
||||
],
|
||||
"reliability": {
|
||||
"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"],
|
||||
"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 json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -185,6 +187,75 @@ def _reset_reasoning() -> None:
|
||||
_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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,6 +354,25 @@ def _thread_id_from_metadata(metadata: Optional[Dict[str, Any]]) -> Optional[str
|
||||
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:
|
||||
if text and text.endswith(_STREAMING_CURSOR):
|
||||
return text[: -len(_STREAMING_CURSOR)]
|
||||
@@ -406,6 +496,50 @@ def _extract_code_block(content: str) -> Optional[str]:
|
||||
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:
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
class _TurnState:
|
||||
"""Per-chat turn state for outbound frame classification (M2)."""
|
||||
@@ -439,6 +590,9 @@ class _TurnState:
|
||||
tool_index: int = 0
|
||||
# Index of the most recently started tool (awaiting tool.end).
|
||||
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).
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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"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_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
@@ -675,6 +873,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
platform = Platform("android")
|
||||
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 {}
|
||||
|
||||
# Connection settings (env vars override config.yaml)
|
||||
@@ -973,7 +1175,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
# 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)
|
||||
|
||||
# 5. Commentary (interim assistant beat).
|
||||
@@ -1094,38 +1299,59 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
parsed = self._parse_tool_line_or_block(line, content)
|
||||
if parsed is None:
|
||||
continue
|
||||
name, preview = parsed
|
||||
# A new tool begins: close the previously-open one.
|
||||
name, preview, args = parsed
|
||||
# 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:
|
||||
extra = _tool_end_fields(state.open_tool_name or "")
|
||||
await self._broadcast_or_log(
|
||||
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.open_tool_index = state.tool_index
|
||||
state.open_tool_name = name
|
||||
await self._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.tool_start(
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_line_or_block(line: str, content: str) -> Optional[Tuple[str, Optional[str]]]:
|
||||
"""Parse a tool line, expanding a terminal code block to its command."""
|
||||
def _parse_tool_line_or_block(
|
||||
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)
|
||||
if parsed is not None:
|
||||
name, preview = parsed
|
||||
# Terminal code block: the command lives in the fenced lines that
|
||||
# follow the "<emoji> terminal" head line.
|
||||
if name == "terminal" and preview is None and "```" in content:
|
||||
cmd = _extract_code_block(content)
|
||||
if cmd:
|
||||
return name, cmd
|
||||
return parsed
|
||||
return None
|
||||
if parsed is None:
|
||||
return None
|
||||
name, preview = parsed
|
||||
# Terminal code block: the command lives in the fenced lines that
|
||||
# follow the "<emoji> terminal" head line.
|
||||
if name == "terminal" and preview is None and "```" in content:
|
||||
cmd = _extract_code_block(content)
|
||||
if cmd:
|
||||
return name, cmd, None
|
||||
# Verbose mode: recover the full args from the following JSON line.
|
||||
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(
|
||||
self, chat_id: str, state: _TurnState, thread_id: Optional[str]
|
||||
@@ -1137,11 +1363,19 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
tool it was waiting on has returned).
|
||||
"""
|
||||
if state.open_tool_index is not None:
|
||||
extra = _tool_end_fields(state.open_tool_name or "")
|
||||
await self._broadcast_or_log(
|
||||
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_name = None
|
||||
|
||||
def _reset_tool_state(self, state: _TurnState) -> None:
|
||||
"""Clear per-turn tool bookkeeping (called at turn finalization)."""
|
||||
@@ -1149,6 +1383,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
state.seen_tool_lines = set()
|
||||
state.tool_index = 0
|
||||
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:
|
||||
delivered = await self._ws_server.broadcast(frame)
|
||||
@@ -1465,6 +1703,33 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
if not isinstance(reply_to, str) or not reply_to.strip():
|
||||
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).
|
||||
media_urls: List[str] = []
|
||||
media_types: List[str] = []
|
||||
@@ -1494,6 +1759,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
|
||||
# Echo to all devices: the sender confirms (server-assigned id),
|
||||
# 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]}"
|
||||
echo = protocol.message(
|
||||
chat_id=chat_id,
|
||||
@@ -1505,7 +1776,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
reply_to=reply_to,
|
||||
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).
|
||||
for ref in media_refs:
|
||||
self._media.pop_inbound(ref)
|
||||
@@ -1554,6 +1825,48 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
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) ──────────────────────────────────
|
||||
#
|
||||
# ``media.upload.start`` -> raw binary frames (one at a time per
|
||||
@@ -1787,6 +2100,59 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
resp.id = frame.id
|
||||
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:
|
||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
@@ -1875,6 +2241,100 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
@@ -2123,6 +2583,13 @@ def register(ctx):
|
||||
ctx.register_hook("on_stream_delta", _on_stream_delta)
|
||||
except Exception:
|
||||
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(
|
||||
name="android",
|
||||
label="Android",
|
||||
|
||||
@@ -64,10 +64,25 @@ class ChannelDirectory:
|
||||
parent_chat_id TEXT,
|
||||
is_default 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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS counters (
|
||||
@@ -206,6 +221,35 @@ class ChannelDirectory:
|
||||
self._conn.commit()
|
||||
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]]:
|
||||
"""Soft-delete (archive) a channel. History stays for search.
|
||||
|
||||
@@ -234,11 +278,11 @@ class ChannelDirectory:
|
||||
return _row_to_entry(row) if row else None
|
||||
|
||||
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"
|
||||
if not include_archived:
|
||||
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:
|
||||
rows = self._conn.execute(sql).fetchall()
|
||||
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"]),
|
||||
"archived": bool(row["archived"]),
|
||||
"created": row["created"],
|
||||
"favorite": bool(row["favorite"]),
|
||||
"icon": row["icon"],
|
||||
"color": row["color"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -169,6 +169,163 @@ class Outbox:
|
||||
)
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_prune(self) -> None:
|
||||
|
||||
@@ -42,6 +42,10 @@ TYPE_MESSAGE_START = "message.start"
|
||||
TYPE_MESSAGE_UPDATE = "message.update"
|
||||
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)
|
||||
TYPE_TOOL_START = "tool.start"
|
||||
TYPE_TOOL_PROGRESS = "tool.progress"
|
||||
@@ -54,6 +58,8 @@ TYPE_COMMENTARY = "commentary"
|
||||
TYPE_CHANNEL_CREATE = "channel.create"
|
||||
TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||
TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||
TYPE_CHANNEL_ICON = "channel.icon"
|
||||
TYPE_CHANNEL_DELETE = "channel.delete"
|
||||
TYPE_CHANNEL_CREATED = "channel.created"
|
||||
TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||
@@ -68,6 +74,9 @@ TYPE_SEARCH_RESULTS = "search.results"
|
||||
TYPE_SYNC = "sync"
|
||||
TYPE_SYNC_DONE = "sync.done"
|
||||
|
||||
# Full message history (initial channel open / scroll-up pagination)
|
||||
TYPE_HISTORY = "history"
|
||||
|
||||
# Media (M4)
|
||||
TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
||||
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
|
||||
if entry.get("archived"):
|
||||
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
|
||||
|
||||
|
||||
def channel_created(entry: Dict[str, Any]) -> Frame:
|
||||
"""Broadcast: a channel/thread was created."""
|
||||
return Frame(type=TYPE_CHANNEL_CREATED, payload=_channel_payload(entry))
|
||||
def channel_created(entry: Dict[str, Any], auto: bool = False) -> Frame:
|
||||
"""Broadcast: a channel/thread was created.
|
||||
|
||||
``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:
|
||||
@@ -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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -370,6 +370,10 @@ class WsServer:
|
||||
await self._adapter.on_channel_rename(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
|
||||
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:
|
||||
await self._adapter.on_channel_delete(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||
@@ -378,6 +382,10 @@ class WsServer:
|
||||
await self._adapter.on_search(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_SYNC:
|
||||
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:
|
||||
await self._adapter.on_media_upload_start(frame, device_id)
|
||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
|
||||
|
||||