M1+M2: gateway core loop + agent transparency
M1 (gateway core loop / text round-trip): - WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry - pairing.py: token generation, pairing store, QR payload - adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message - app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop) - tests/ws_probe.py: probe harness driving a real turn M2 (streaming + reasoning + tools + commentary): - protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary) - adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing - reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop - app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing - docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
This commit is contained in:
1 parent
59acf66c89
commit
218c50d688
21 files changed
+3437
-129
No files matched your search
@@ -0,0 +1,50 @@
|
||||
package iris.platform
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import iris.data.SecureStore
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Android pairing storage. M1: SharedPreferences (dev). M5 moves the token
|
||||
* to EncryptedSharedPreferences per docs/10 §10.2.
|
||||
*/
|
||||
class AndroidSecureStore(context: Context) : SecureStore {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("iris", Context.MODE_PRIVATE)
|
||||
|
||||
override var serverUrl: String
|
||||
get() = prefs.getString(KEY_URL, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_URL, value.trim()).apply()
|
||||
|
||||
override var token: String
|
||||
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
|
||||
|
||||
override val deviceId: String
|
||||
get() {
|
||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||
if (id.isNullOrBlank()) {
|
||||
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
override val deviceName: String
|
||||
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
|
||||
|
||||
override fun savePairing(url: String, token: String) {
|
||||
serverUrl = url
|
||||
this.token = token
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_URL = "server_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,59 @@
|
||||
package iris
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.state.IrisController
|
||||
import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
|
||||
private val IrisDark = darkColorScheme(
|
||||
background = Color(0xFF1B1E28),
|
||||
surface = Color(0xFF222634),
|
||||
onBackground = Color(0xFFE8EAF0),
|
||||
onSurface = Color(0xFFE8EAF0),
|
||||
primary = Color(0xFF4F7CFF),
|
||||
)
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
*
|
||||
* M0: a visible placeholder to confirm the Compose stack renders. M1+
|
||||
* replaces this with the Connect screen, chat UI, and the rest of the app
|
||||
* (see docs/10-android-app.md).
|
||||
* M1: routes between the Connect screen (unpaired / auth failed) and the
|
||||
* Chat screen (paired). Later milestones add the channel list, search,
|
||||
* settings, and media (docs/10-android-app.md).
|
||||
*/
|
||||
@Composable
|
||||
fun IrisApp() {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF1B1E28)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(Color(0xFF4F7CFF), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = "I", color = Color.White, fontSize = 40.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(text = "Iris × Hermes", color = Color.White, fontSize = 22.sp)
|
||||
}
|
||||
fun IrisApp(store: SecureStore) {
|
||||
val controller = remember(store) { IrisController(store) }
|
||||
DisposableEffect(controller) {
|
||||
onDispose { controller.dispose() }
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
|
||||
MaterialTheme(colorScheme = IrisDark) {
|
||||
Surface(modifier = Modifier.background(IrisDark.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}",
|
||||
)
|
||||
else -> ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package iris.data
|
||||
|
||||
import iris.protocol.CommentaryPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.MessagePayload
|
||||
import iris.protocol.MessageStartPayload
|
||||
import iris.protocol.MessageStopPayload
|
||||
import iris.protocol.MessageUpdatePayload
|
||||
import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.protocol.TYPE_COMMENTARY
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
import iris.protocol.TYPE_MESSAGE_START
|
||||
import iris.protocol.TYPE_MESSAGE_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.ToolEndPayload
|
||||
import iris.protocol.ToolProgressPayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* In-memory chat cache (M2). The server is authoritative; this is a cache that
|
||||
* reconciles on server frames. M2 adds streaming bubbles (message.start/
|
||||
* update/stop), structured tool cards (tool.start/progress/end), and dimmed
|
||||
* commentary beats, all interleaved in chronological order as [ChatItem]s.
|
||||
* SQLDelight persistence lands with the channel/message schema later.
|
||||
*/
|
||||
sealed interface ChatItem {
|
||||
val id: String
|
||||
}
|
||||
|
||||
/** A chat message (user / assistant / commentary / streaming bubble). */
|
||||
data class MessageItem(
|
||||
override val id: String,
|
||||
val role: String,
|
||||
val text: String,
|
||||
val ts: Long,
|
||||
val pending: Boolean = false,
|
||||
val reasoning: String? = null,
|
||||
val isCommentary: Boolean = false,
|
||||
val streaming: Boolean = false,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
) : ChatItem
|
||||
|
||||
/** A structured tool-activity card (spinner until [done]). */
|
||||
data class ToolItem(
|
||||
override val id: String,
|
||||
val index: Int,
|
||||
val name: String,
|
||||
val preview: String? = null,
|
||||
val args: JsonElement? = null,
|
||||
val note: String? = null,
|
||||
val done: Boolean = false,
|
||||
val ok: Boolean = true,
|
||||
val duration: Double? = null,
|
||||
val outputPreview: String? = null,
|
||||
) : ChatItem
|
||||
|
||||
class ChatStore {
|
||||
private val _items = MutableStateFlow<List<ChatItem>>(emptyList())
|
||||
val items: StateFlow<List<ChatItem>> = _items.asStateFlow()
|
||||
|
||||
private var localSeq = 0
|
||||
private var toolSeq = 0
|
||||
|
||||
/** Optimistic add: show the user's message immediately (pending). */
|
||||
fun addPending(text: String): String {
|
||||
localSeq++
|
||||
val id = "local_$localSeq"
|
||||
_items.value = _items.value +
|
||||
MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Reconcile a server frame into the cache. */
|
||||
fun onFrame(frame: Frame) {
|
||||
when (frame.type) {
|
||||
TYPE_MESSAGE -> onMessage(frame)
|
||||
TYPE_MESSAGE_START -> onMessageStart(frame)
|
||||
TYPE_MESSAGE_UPDATE -> onMessageUpdate(frame)
|
||||
TYPE_MESSAGE_STOP -> onMessageStop(frame)
|
||||
TYPE_TOOL_START -> onToolStart(frame)
|
||||
TYPE_TOOL_PROGRESS -> onToolProgress(frame)
|
||||
TYPE_TOOL_END -> onToolEnd(frame)
|
||||
TYPE_COMMENTARY -> onCommentary(frame)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// ── message (final / standalone, incl. non-streaming + user echo) ─────
|
||||
|
||||
private fun onMessage(frame: Frame) {
|
||||
val p = frame.payloadAs<MessagePayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
val byId = list.indexOfFirst { it.id == p.messageId }
|
||||
if (byId >= 0) {
|
||||
list[byId] = (list[byId] as? MessageItem)?.copy(
|
||||
text = p.text,
|
||||
reasoning = p.reasoning,
|
||||
pending = false,
|
||||
streaming = false,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
ts = p.ts ?: 0,
|
||||
) ?: return
|
||||
_items.value = list
|
||||
return
|
||||
}
|
||||
if (p.role == ROLE_USER) {
|
||||
// Replace the matching optimistic pending bubble (server echo).
|
||||
val pendingIdx = list.indexOfLast {
|
||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||
}
|
||||
if (pendingIdx >= 0) {
|
||||
list[pendingIdx] = MessageItem(
|
||||
id = p.messageId, role = p.role, text = p.text,
|
||||
ts = p.ts ?: 0, reasoning = p.reasoning,
|
||||
)
|
||||
_items.value = list
|
||||
return
|
||||
}
|
||||
}
|
||||
list.add(
|
||||
MessageItem(
|
||||
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
|
||||
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
|
||||
),
|
||||
)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── message.start (open a live streaming bubble) ──────────────────────
|
||||
|
||||
private fun onMessageStart(frame: Frame) {
|
||||
val p = frame.payloadAs<MessageStartPayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
if (list.any { it.id == p.messageId }) return
|
||||
list.add(
|
||||
MessageItem(
|
||||
id = p.messageId, role = p.role, text = "",
|
||||
ts = 0, streaming = true,
|
||||
),
|
||||
)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── message.update (replace live bubble text; full snapshot) ──────────
|
||||
|
||||
private fun onMessageUpdate(frame: Frame) {
|
||||
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
val idx = list.indexOfFirst { it.id == p.messageId }
|
||||
if (idx < 0) return
|
||||
val cur = list[idx] as? MessageItem ?: return
|
||||
list[idx] = cur.copy(text = p.text)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── message.stop (finalize the live bubble) ───────────────────────────
|
||||
|
||||
private fun onMessageStop(frame: Frame) {
|
||||
val p = frame.payloadAs<MessageStopPayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
val idx = list.indexOfFirst { it.id == p.messageId }
|
||||
if (idx < 0) {
|
||||
// No live bubble (e.g. missed start) — materialize a final one.
|
||||
list.add(
|
||||
MessageItem(
|
||||
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
|
||||
ts = p.ts ?: 0, reasoning = p.reasoning,
|
||||
model = p.model, tokens = p.tokens,
|
||||
),
|
||||
)
|
||||
_items.value = list
|
||||
return
|
||||
}
|
||||
val cur = list[idx] as? MessageItem ?: return
|
||||
list[idx] = cur.copy(
|
||||
text = p.finalText,
|
||||
reasoning = p.reasoning,
|
||||
model = p.model,
|
||||
tokens = p.tokens,
|
||||
streaming = false,
|
||||
ts = p.ts ?: cur.ts,
|
||||
)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── tool.start (new tool card) ────────────────────────────────────────
|
||||
|
||||
private fun onToolStart(frame: Frame) {
|
||||
val p = frame.payloadAs<ToolStartPayload>() ?: return
|
||||
toolSeq++
|
||||
val id = "tool_$toolSeq"
|
||||
val list = _items.value.toMutableList()
|
||||
list.add(
|
||||
ToolItem(
|
||||
id = id, index = p.index, name = p.name,
|
||||
preview = p.preview, args = p.args,
|
||||
),
|
||||
)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── tool.progress (in-progress note) ──────────────────────────────────
|
||||
|
||||
private fun onToolProgress(frame: Frame) {
|
||||
val p = frame.payloadAs<ToolProgressPayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
||||
if (idx < 0) return
|
||||
val cur = list[idx] as ToolItem
|
||||
list[idx] = cur.copy(note = p.note)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── tool.end (mark the tool card complete) ────────────────────────────
|
||||
|
||||
private fun onToolEnd(frame: Frame) {
|
||||
val p = frame.payloadAs<ToolEndPayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
||||
if (idx < 0) return
|
||||
val cur = list[idx] as ToolItem
|
||||
list[idx] = cur.copy(
|
||||
done = true, ok = p.ok, duration = p.duration,
|
||||
outputPreview = p.outputPreview,
|
||||
)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
// ── commentary (dimmed interim beat) ──────────────────────────────────
|
||||
|
||||
private fun onCommentary(frame: Frame) {
|
||||
val p = frame.payloadAs<CommentaryPayload>() ?: return
|
||||
val list = _items.value.toMutableList()
|
||||
if (list.any { it.id == p.messageId }) return
|
||||
list.add(
|
||||
MessageItem(
|
||||
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
|
||||
ts = 0, isCommentary = true,
|
||||
),
|
||||
)
|
||||
_items.value = list
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_items.value = emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun randomId(prefix: String): String =
|
||||
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package iris.data
|
||||
|
||||
/**
|
||||
* Pairing settings storage. The token is a secret: platform actuals keep it
|
||||
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
|
||||
* SharedPreferences for M1 dev, file on desktop).
|
||||
*/
|
||||
interface SecureStore {
|
||||
/** ws(s)://host:port/ws */
|
||||
var serverUrl: String
|
||||
|
||||
/** ANDROID_TOKEN presented in the hello frame. */
|
||||
var token: String
|
||||
|
||||
/** Stable app-generated device id (persisted). */
|
||||
val deviceId: String
|
||||
|
||||
/** Human-readable device name (e.g. "MIX 2S"). */
|
||||
val deviceName: String
|
||||
|
||||
fun savePairing(url: String, token: String)
|
||||
fun clear()
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package iris.net
|
||||
|
||||
import iris.data.SecureStore
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.ErrorPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.HelloAckPayload
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.ServerCaps
|
||||
import iris.protocol.TYPE_ERROR
|
||||
import iris.protocol.TYPE_HELLO_ACK
|
||||
import iris.protocol.TYPE_PONG
|
||||
import iris.protocol.helloFrame
|
||||
import iris.protocol.messageSendFrame
|
||||
import iris.protocol.pingFrame
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
|
||||
*
|
||||
* - connect + hello (real auth leg), hello.ack
|
||||
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
||||
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
|
||||
* - events: server frames (minus hello.ack) on [events]
|
||||
* - request/response correlation by id (M2+ consumers)
|
||||
*/
|
||||
class GatewayClient(
|
||||
private val scope: CoroutineScope,
|
||||
private val store: SecureStore,
|
||||
) {
|
||||
sealed interface State {
|
||||
data object Disconnected : State
|
||||
data object Connecting : State
|
||||
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
|
||||
data object Reconnecting : State
|
||||
data class AuthFailed(val message: String) : State
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private var connectJob: Job? = null
|
||||
private var socket: WebSocket? = null
|
||||
private var nextRequestId = 1
|
||||
private var attempt = 0
|
||||
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
||||
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Start (or resume) the connect loop. Idempotent. */
|
||||
fun start() {
|
||||
if (connectJob?.isActive == true) return
|
||||
attempt = 0
|
||||
connectJob = scope.launch { connectLoop() }
|
||||
}
|
||||
|
||||
/** Stop the connect loop and close the socket. */
|
||||
fun stop() {
|
||||
connectJob?.cancel()
|
||||
connectJob = null
|
||||
socket?.close(1000, "client shutdown")
|
||||
socket = null
|
||||
_state.value = State.Disconnected
|
||||
}
|
||||
|
||||
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
||||
fun restart() {
|
||||
stop()
|
||||
start()
|
||||
}
|
||||
|
||||
private suspend fun connectLoop() {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val url = store.serverUrl.trim()
|
||||
val token = store.token
|
||||
if (url.isBlank() || token.isBlank()) {
|
||||
_state.value = State.Disconnected
|
||||
return
|
||||
}
|
||||
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting
|
||||
val dial = dial(url, token)
|
||||
when (val result = dial.result) {
|
||||
is DialResult.AuthFailed -> {
|
||||
_state.value = State.AuthFailed(result.message)
|
||||
dial.socket.close(1000, "auth failed")
|
||||
return
|
||||
}
|
||||
DialResult.Connected -> {
|
||||
attempt = 0
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
dial.closed.await()
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
// socket dropped -> loop again (Reconnecting)
|
||||
}
|
||||
is DialResult.Failed -> {
|
||||
attempt++
|
||||
delay(backoffMs(attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dial (one connect + hello) ────────────────────────────────────────
|
||||
|
||||
private sealed interface DialResult {
|
||||
data object Connected : DialResult
|
||||
data class AuthFailed(val message: String) : DialResult
|
||||
data class Failed(val message: String) : DialResult
|
||||
}
|
||||
|
||||
private data class Dial(
|
||||
val result: DialResult,
|
||||
val socket: WebSocket,
|
||||
val closed: CompletableDeferred<Unit>,
|
||||
)
|
||||
|
||||
private suspend fun dial(url: String, token: String): Dial {
|
||||
val closed = CompletableDeferred<Unit>()
|
||||
val helloAck = CompletableDeferred<HelloAckPayload>()
|
||||
val authError = CompletableDeferred<String>()
|
||||
val fail = CompletableDeferred<String>()
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val ws = client.newWebSocket(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(helloFrame(token, store.deviceId, store.deviceName).toWire())
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
val frame = try {
|
||||
IrisJson.instance.decodeFromString(Frame.serializer(), text)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
when (frame.type) {
|
||||
TYPE_HELLO_ACK -> {
|
||||
val ack = frame.payloadAs<HelloAckPayload>()
|
||||
if (ack != null) helloAck.complete(ack)
|
||||
}
|
||||
TYPE_ERROR -> {
|
||||
val err = frame.payloadAs<ErrorPayload>()
|
||||
authError.complete(err?.message ?: "auth failed")
|
||||
}
|
||||
TYPE_PONG -> Unit
|
||||
else -> {
|
||||
_events.tryEmit(frame)
|
||||
frame.id?.let { pending[it]?.complete(frame) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
closed.complete(Unit)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
fail.complete(t.message ?: "connection failed")
|
||||
closed.complete(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
socket = ws
|
||||
|
||||
val winner = CompletableDeferred<DialResult>()
|
||||
helloAck.invokeOnCompletion { e ->
|
||||
if (e == null) {
|
||||
val ack = helloAck.getCompleted()
|
||||
_state.value = State.Connected(ack.serverCaps, ack.channels)
|
||||
winner.complete(DialResult.Connected)
|
||||
}
|
||||
}
|
||||
authError.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
|
||||
}
|
||||
fail.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
|
||||
}
|
||||
val result = withTimeoutOrNull(15_000) { winner.await() }
|
||||
?: DialResult.Failed("timeout waiting for hello.ack")
|
||||
return Dial(result, ws, closed)
|
||||
}
|
||||
|
||||
// ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Send a text message (fire-and-forget; the server echoes it back). */
|
||||
fun sendMessage(chatId: String, text: String, threadId: String? = null) {
|
||||
val ws = socket ?: return
|
||||
val id = nextRequestId++
|
||||
ws.send(messageSendFrame(id, chatId, text, threadId).toWire())
|
||||
}
|
||||
|
||||
/** Send a ping (heartbeat). */
|
||||
fun ping() {
|
||||
socket?.send(pingFrame().toWire())
|
||||
}
|
||||
|
||||
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
|
||||
fun isStale(timeoutMs: Long = 60_000): Boolean =
|
||||
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
|
||||
|
||||
fun reapStale() {
|
||||
if (isStale()) {
|
||||
socket?.close(1000, "heartbeat timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
|
||||
* Exercises the auth leg, not just TCP (docs/10 §10.8).
|
||||
*/
|
||||
suspend fun testHello(url: String, token: String): Result<Unit> {
|
||||
val dial = dial(url, token)
|
||||
return when (val result = dial.result) {
|
||||
DialResult.Connected -> {
|
||||
dial.socket.close(1000, "test complete")
|
||||
Result.success(Unit)
|
||||
}
|
||||
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
|
||||
is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat job ─────────────────────────────────────────────────────
|
||||
|
||||
fun startHeartbeat() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(20_000)
|
||||
if (_state.value is State.Connected) {
|
||||
ping()
|
||||
reapStale()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun backoffMs(attempt: Int): Long {
|
||||
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
||||
val capped = minOf(base, 30_000L)
|
||||
return capped + Random.nextLong(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
|
||||
@@ -0,0 +1,238 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
|
||||
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
|
||||
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
|
||||
* progress/end, commentary, reasoning (on message / message.stop).
|
||||
*/
|
||||
|
||||
const val PROTOCOL_VERSION = 1
|
||||
|
||||
object IrisJson {
|
||||
val instance: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame type constants ────────────────────────────────────────────────
|
||||
|
||||
const val TYPE_HELLO = "hello"
|
||||
const val TYPE_HELLO_ACK = "hello.ack"
|
||||
const val TYPE_MESSAGE = "message"
|
||||
const val TYPE_MESSAGE_SEND = "message.send"
|
||||
const val TYPE_ERROR = "error"
|
||||
const val TYPE_PING = "ping"
|
||||
const val TYPE_PONG = "pong"
|
||||
const val TYPE_TYPING = "typing"
|
||||
|
||||
// M2 — streaming / tools / commentary
|
||||
const val TYPE_MESSAGE_START = "message.start"
|
||||
const val TYPE_MESSAGE_UPDATE = "message.update"
|
||||
const val TYPE_MESSAGE_STOP = "message.stop"
|
||||
const val TYPE_TOOL_START = "tool.start"
|
||||
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
||||
const val TYPE_TOOL_END = "tool.end"
|
||||
const val TYPE_COMMENTARY = "commentary"
|
||||
|
||||
// ── Error codes ─────────────────────────────────────────────────────────
|
||||
|
||||
const val ERR_AUTH = "auth"
|
||||
const val ERR_NOT_FOUND = "not_found"
|
||||
const val ERR_UNSUPPORTED = "unsupported"
|
||||
const val ERR_INTERNAL = "internal"
|
||||
|
||||
// ── Roles ───────────────────────────────────────────────────────────────
|
||||
|
||||
const val ROLE_USER = "user"
|
||||
const val ROLE_ASSISTANT = "assistant"
|
||||
|
||||
// ── Envelope ────────────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class Frame(
|
||||
val v: Int = PROTOCOL_VERSION,
|
||||
val id: Int? = null,
|
||||
val type: String,
|
||||
@SerialName("chat_id") val chatId: String? = null,
|
||||
@SerialName("thread_id") val threadId: String? = null,
|
||||
val payload: JsonElement = JsonObject(emptyMap()),
|
||||
) {
|
||||
/** Payload as a JSON object (the wire format); parse per-type with
|
||||
* [IrisJson]. Unknown fields are ignored (forward-compat). */
|
||||
inline fun <reified T : Any> payloadAs(): T? =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString<T>(payload.toString())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ── hello (app -> server) ───────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class HelloPayload(
|
||||
val token: String,
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
@SerialName("device_name") val deviceName: String,
|
||||
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
|
||||
@SerialName("fcm_token") val fcmToken: String? = null,
|
||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||
)
|
||||
|
||||
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class ServerCaps(
|
||||
val streaming: Boolean = false,
|
||||
val reasoning: Boolean = false,
|
||||
val tools: Boolean = false,
|
||||
val media: Boolean = false,
|
||||
val search: Boolean = false,
|
||||
val push: String = "fcm",
|
||||
val pickers: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelInfo(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
val name: String,
|
||||
val kind: String = "channel",
|
||||
@SerialName("is_default") val isDefault: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HelloAckPayload(
|
||||
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
|
||||
@SerialName("sync_cursor") val syncCursor: Long = 0,
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
)
|
||||
|
||||
// ── message (server -> app) ─────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class MessagePayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
val role: String,
|
||||
val text: String,
|
||||
val reasoning: String? = null,
|
||||
@SerialName("reply_to") val replyTo: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class MessageStartPayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
val role: String = ROLE_ASSISTANT,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageUpdatePayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessageStopPayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
@SerialName("final_text") val finalText: String,
|
||||
val reasoning: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
// ── M2: tool activity frames (server -> app) ────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class ToolStartPayload(
|
||||
val index: Int,
|
||||
val name: String,
|
||||
val preview: String? = null,
|
||||
val args: JsonElement? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToolProgressPayload(
|
||||
val index: Int,
|
||||
val name: String,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToolEndPayload(
|
||||
val index: Int,
|
||||
val name: String,
|
||||
val ok: Boolean = true,
|
||||
val duration: Double? = null,
|
||||
@SerialName("output_preview") val outputPreview: String? = null,
|
||||
)
|
||||
|
||||
// ── M2: commentary frame (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class CommentaryPayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
// ── message.send (app -> server) ────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class MessageSendPayload(
|
||||
val text: String,
|
||||
@SerialName("reply_to") val replyTo: String? = null,
|
||||
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
// ── typing / error / ping ───────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class TypingPayload(val on: Boolean)
|
||||
|
||||
@Serializable
|
||||
data class ErrorPayload(val code: String, val message: String)
|
||||
|
||||
@Serializable
|
||||
data class PingPayload(val ts: Long? = null)
|
||||
|
||||
// ── Frame builders ──────────────────────────────────────────────────────
|
||||
|
||||
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
|
||||
Frame(
|
||||
type = TYPE_HELLO,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
HelloPayload.serializer(),
|
||||
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName),
|
||||
),
|
||||
)
|
||||
|
||||
fun messageSendFrame(id: Int, chatId: String, text: String, threadId: String? = null): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MESSAGE_SEND,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MessageSendPayload.serializer(),
|
||||
MessageSendPayload(text = text),
|
||||
),
|
||||
)
|
||||
|
||||
fun pingFrame(): Frame =
|
||||
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
||||
@@ -0,0 +1,122 @@
|
||||
package iris.state
|
||||
|
||||
import iris.data.ChatStore
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.protocol.TYPE_COMMENTARY
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
import iris.protocol.TYPE_MESSAGE_START
|
||||
import iris.protocol.TYPE_MESSAGE_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.TYPE_TYPING
|
||||
import iris.protocol.TypingPayload
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-level controller (M2): owns the GatewayClient + ChatStore, routes
|
||||
* server frames (message / streaming / tool / commentary / typing) into
|
||||
* state, and exposes the actions the UI needs.
|
||||
*/
|
||||
class IrisController(
|
||||
private val store: SecureStore,
|
||||
) {
|
||||
private val job = SupervisorJob()
|
||||
private val scope = CoroutineScope(job + Dispatchers.Default)
|
||||
|
||||
val client = GatewayClient(scope, store)
|
||||
val chat = ChatStore()
|
||||
|
||||
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)
|
||||
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
|
||||
|
||||
fun cycleToolDetail() {
|
||||
_toolDetail.value = when (_toolDetail.value) {
|
||||
ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED
|
||||
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
|
||||
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
|
||||
}
|
||||
}
|
||||
|
||||
/** Home channel id (from hello.ack; default until then). */
|
||||
private val _homeChannel = MutableStateFlow("android:default")
|
||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
client.events.collect { frame ->
|
||||
when (frame.type) {
|
||||
TYPE_MESSAGE,
|
||||
TYPE_MESSAGE_START,
|
||||
TYPE_MESSAGE_UPDATE,
|
||||
TYPE_MESSAGE_STOP,
|
||||
TYPE_TOOL_START,
|
||||
TYPE_TOOL_PROGRESS,
|
||||
TYPE_TOOL_END,
|
||||
TYPE_COMMENTARY -> chat.onFrame(frame)
|
||||
TYPE_TYPING -> {
|
||||
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
client.state.collect { s ->
|
||||
if (s is GatewayClient.State.Connected) {
|
||||
val home = s.channels.firstOrNull { it.isDefault }?.chatId
|
||||
if (home != null) _homeChannel.value = home
|
||||
}
|
||||
}
|
||||
}
|
||||
client.startHeartbeat()
|
||||
client.start()
|
||||
}
|
||||
|
||||
/** Optimistic send: show immediately, then hand to the gateway. */
|
||||
fun send(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
chat.addPending(trimmed)
|
||||
client.sendMessage(_homeChannel.value, trimmed)
|
||||
}
|
||||
|
||||
/** Connect-screen action: real hello test, then save + (re)connect. */
|
||||
suspend fun connect(url: String, token: String): Result<Unit> {
|
||||
val test = client.testHello(url, token)
|
||||
if (test.isFailure) return test
|
||||
store.savePairing(url, token)
|
||||
client.restart()
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
fun forget() {
|
||||
store.clear()
|
||||
chat.clear()
|
||||
client.restart()
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
client.stop()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package iris.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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
|
||||
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.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.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import iris.data.ChatItem
|
||||
import iris.data.MessageItem
|
||||
import iris.data.ToolItem
|
||||
import iris.net.GatewayClient
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
|
||||
/**
|
||||
* Chat screen (M2): message list + composer. User bubbles right (accent),
|
||||
* assistant bubbles left (surface). M2 adds live streaming bubbles, a
|
||||
* collapsible reasoning block above the answer, structured tool cards
|
||||
* (verbosity-controlled), and dimmed commentary beats.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatScreen(controller: IrisController) {
|
||||
val state by controller.client.state.collectAsState()
|
||||
val items by controller.chat.items.collectAsState()
|
||||
val typing by controller.typing.collectAsState()
|
||||
val toolDetail by controller.toolDetail.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
var input by remember { mutableStateOf("") }
|
||||
|
||||
fun doSend() {
|
||||
if (input.isBlank()) return
|
||||
val text = input
|
||||
input = ""
|
||||
controller.send(text)
|
||||
}
|
||||
|
||||
LaunchedEffect(items.size, typing) {
|
||||
val last = items.size - 1
|
||||
if (last >= 0) listState.animateScrollToItem(last)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Iris", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
StatusChip(state)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// M2: cycle tool-card verbosity (full Settings screen lands in M7).
|
||||
TextButton(onClick = { controller.cycleToolDetail() }) {
|
||||
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (items.isEmpty() && !typing) {
|
||||
item(key = "empty") {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Say hello to your agent.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(items, key = { it.id }) { item ->
|
||||
when (item) {
|
||||
is MessageItem -> MessageBubble(item)
|
||||
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
|
||||
}
|
||||
}
|
||||
if (typing) {
|
||||
item(key = "typing") {
|
||||
Text(
|
||||
"typing…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composer
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 48.dp, max = 160.dp),
|
||||
placeholder = { Text("Message") },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { doSend() }),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { doSend() },
|
||||
enabled = input.isNotBlank(),
|
||||
) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(state: GatewayClient.State) {
|
||||
val (label, color) = when (state) {
|
||||
GatewayClient.State.Disconnected -> "offline" to Color(0xFF9E9E9E)
|
||||
GatewayClient.State.Connecting -> "connecting…" to Color(0xFFFFC107)
|
||||
GatewayClient.State.Reconnecting -> "reconnecting…" to Color(0xFFFFC107)
|
||||
is GatewayClient.State.Connected -> "connected" to Color(0xFF4CAF50)
|
||||
is GatewayClient.State.AuthFailed -> "auth failed" to Color(0xFFF44336)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(color.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(label, fontSize = 11.sp, color = color)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(msg: MessageItem) {
|
||||
val isUser = msg.role == ROLE_USER
|
||||
val isCommentary = msg.isCommentary
|
||||
val bubbleColor = when {
|
||||
isUser -> Color(0xFF4F7CFF)
|
||||
isCommentary -> Color(0xFF23262F)
|
||||
else -> Color(0xFF2A2E3B)
|
||||
}
|
||||
val textColor = when {
|
||||
isUser -> Color.White
|
||||
isCommentary -> Color(0xFFE8EAF0).copy(alpha = 0.55f)
|
||||
else -> Color(0xFFE8EAF0)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(bubbleColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// Reasoning block above the answer (assistant, non-commentary).
|
||||
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
|
||||
ReasoningBlock(msg.reasoning!!)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
if (msg.text.isNotBlank() || msg.streaming) {
|
||||
Text(
|
||||
msg.text + if (msg.streaming) " ▉" else "",
|
||||
color = textColor,
|
||||
fontSize = if (isCommentary) 13.sp else 15.sp,
|
||||
)
|
||||
}
|
||||
if (msg.pending) {
|
||||
Text("sending…", color = textColor.copy(alpha = 0.6f), fontSize = 10.sp)
|
||||
}
|
||||
// Model / token footer (final assistant answers only).
|
||||
if (!isUser && !isCommentary && !msg.streaming &&
|
||||
(msg.model != null || msg.tokens != null)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
buildString {
|
||||
msg.model?.let { append(it) }
|
||||
if (msg.model != null && msg.tokens != null) append(" · ")
|
||||
msg.tokens?.let { append("${it} tok") }
|
||||
},
|
||||
color = textColor.copy(alpha = 0.4f),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible reasoning panel (M2): header "💭 Reasoning", monospace body,
|
||||
* copy button. Collapsed by default when long; tap to toggle.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReasoningBlock(reasoning: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var expanded by remember {
|
||||
mutableStateOf(reasoning.length <= 240)
|
||||
}
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF1E212B))
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"💭 Reasoning",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFFB9C0D0),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { expanded = !expanded },
|
||||
)
|
||||
TextButton(onClick = {
|
||||
clipboard.setText(AnnotatedString(reasoning))
|
||||
copied = true
|
||||
}) {
|
||||
Text(if (copied) "copied" else "copy", fontSize = 11.sp)
|
||||
}
|
||||
Text(
|
||||
if (expanded) "▾" else "▸",
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFFB9C0D0),
|
||||
modifier = Modifier.clickable { expanded = !expanded },
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Text(
|
||||
reasoning,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF9AA3B5),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured tool-activity card (M2). Spinner while [ToolItem.done] is false,
|
||||
* ✓/✗ + duration on completion. Verbosity controlled by [detail].
|
||||
*/
|
||||
@Composable
|
||||
private fun ToolCard(tool: ToolItem, detail: ToolDetail) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val status = when {
|
||||
!tool.done -> null
|
||||
tool.ok -> "✓"
|
||||
else -> "✗"
|
||||
}
|
||||
val statusColor = when {
|
||||
tool.ok -> Color(0xFF4CAF50)
|
||||
else -> Color(0xFFF44336)
|
||||
}
|
||||
val preview = tool.preview ?: tool.note
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!tool.done) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
} else {
|
||||
Text(status!!, color = statusColor, fontSize = 14.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
"🔧 ${tool.name}",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFFD7DBE5),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { expanded = !expanded },
|
||||
)
|
||||
if (tool.duration != null) {
|
||||
Text(
|
||||
"${String.format("%.1f", tool.duration)}s",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF8A93A6),
|
||||
)
|
||||
}
|
||||
}
|
||||
// One-liner preview (Truncated default / Everything).
|
||||
if (preview != null && detail != ToolDetail.NOTHING) {
|
||||
Text(
|
||||
preview,
|
||||
fontSize = 12.sp,
|
||||
color = Color(0xFF9AA3B5),
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
||||
)
|
||||
}
|
||||
// Full args + output preview (Everything, expanded).
|
||||
if (detail == ToolDetail.EVERYTHING && expanded) {
|
||||
tool.args?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
it.toString(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF8A93A6),
|
||||
)
|
||||
}
|
||||
tool.outputPreview?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
it,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF8A93A6),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ToolDetail.label: String
|
||||
get() = when (this) {
|
||||
ToolDetail.EVERYTHING -> "everything"
|
||||
ToolDetail.TRUNCATED -> "truncated"
|
||||
ToolDetail.NOTHING -> "nothing"
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package iris.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.state.IrisController
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Onboarding / pairing screen (docs/10 §10.8). "Test & Connect" performs a
|
||||
* real `hello` (the auth leg), not just a TCP probe.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
controller: IrisController,
|
||||
prefillUrl: String = "",
|
||||
prefillToken: String = "",
|
||||
initialError: String? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
|
||||
var token by remember { mutableStateOf(prefillToken) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(initialError) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
Text("Iris × Hermes", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Pair this device with your hermes gateway",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (busy) return@Button
|
||||
busy = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = controller.connect(url.trim(), token.trim())
|
||||
busy = false
|
||||
if (result.isFailure) {
|
||||
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (busy) "Testing connection…" else "Test & Connect")
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
||||
"hermes gateway setup on the gateway host.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package iris.platform
|
||||
|
||||
import iris.data.SecureStore
|
||||
import iris.protocol.IrisJson
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Desktop pairing storage: a small JSON file under the user home
|
||||
* (`~/.iris/pairing.json`). M1 dev storage; a keyring backend is a
|
||||
* later-milestone concern.
|
||||
*/
|
||||
@Serializable
|
||||
private data class PairingData(
|
||||
val serverUrl: String = "",
|
||||
val token: String = "",
|
||||
val deviceId: String = "",
|
||||
)
|
||||
|
||||
class DesktopSecureStore : SecureStore {
|
||||
private val file: File = File(System.getProperty("user.home"), ".iris/pairing.json")
|
||||
|
||||
private fun load(): PairingData =
|
||||
if (file.exists()) {
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(PairingData.serializer(), file.readText())
|
||||
} catch (_: Exception) {
|
||||
PairingData()
|
||||
}
|
||||
} else {
|
||||
PairingData()
|
||||
}
|
||||
|
||||
private fun save(data: PairingData) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(IrisJson.instance.encodeToString(PairingData.serializer(), data))
|
||||
}
|
||||
|
||||
override var serverUrl: String
|
||||
get() = load().serverUrl
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = value.trim()))
|
||||
}
|
||||
|
||||
override var token: String
|
||||
get() = load().token
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(token = value.trim()))
|
||||
}
|
||||
|
||||
override val deviceId: String
|
||||
get() {
|
||||
val d = load()
|
||||
if (d.deviceId.isNotBlank()) return d.deviceId
|
||||
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||
save(d.copy(deviceId = id))
|
||||
return id
|
||||
}
|
||||
|
||||
override val deviceName: String
|
||||
get() = "Desktop (${System.getProperty("os.name")})"
|
||||
|
||||
override fun savePairing(url: String, token: String) {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = url.trim(), token = token.trim()))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = "", token = ""))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user