ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,214 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.wgapplink.localNetworkAllowed
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
||||
* and the back button the only other way between them.
|
||||
*
|
||||
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
|
||||
* of the same backend, none of them a step down from another -- and what is left in this `when` is
|
||||
* only what genuinely is a step down: one session, spawning one, and settings. A session's own
|
||||
* settings are not among them: they are a dialog over the session, which is where the thing they
|
||||
* change is.
|
||||
*/
|
||||
private sealed class Screen {
|
||||
data object Main : Screen()
|
||||
|
||||
data class Session(val summary: SessionSummary) : Screen()
|
||||
|
||||
data object Spawn : Screen()
|
||||
|
||||
data object Settings : Screen()
|
||||
}
|
||||
|
||||
/**
|
||||
* A session a notification tap asked to open, before it is a screen.
|
||||
*
|
||||
* The notification names an id and nothing else, so opening it means fetching the session first.
|
||||
* [serial] tells two taps on the same session's notification apart, since they are two requests and
|
||||
* would otherwise compare equal -- see MainActivity, which counts them.
|
||||
*/
|
||||
data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
||||
|
||||
/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */
|
||||
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
|
||||
|
||||
/**
|
||||
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
|
||||
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
|
||||
*
|
||||
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
|
||||
*/
|
||||
@Composable
|
||||
fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
||||
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
||||
// A notification tap this could not follow, and why. Null both before one is asked for and
|
||||
// after one succeeds, since success is a screen rather than a message.
|
||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
||||
// Bumped whenever another screen changes something the list shows, so
|
||||
// returning to it refetches instead of showing a stale list.
|
||||
var reloadToken by remember { mutableIntStateOf(0) }
|
||||
|
||||
// A standing condition rather than a per-request failure, so it is
|
||||
// stated once here instead of appended to every error that might be
|
||||
// caused by it. Without this the app is simply unreachable and every
|
||||
// screen blames the server or the tunnel for it.
|
||||
if (!localNetworkAllowed(context)) {
|
||||
Text(
|
||||
"This app is not allowed to reach local network addresses, so it cannot " +
|
||||
"connect to the backend at all. Grant \"local network\" in Android's app " +
|
||||
"settings; until then every screen here will look like the server is down.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
val current = settings
|
||||
if (current == null) {
|
||||
// Not enrolled yet: settings is the only usable screen. The QR
|
||||
// path lands in MainActivity and recomposes from the top.
|
||||
Box(Modifier.imePadding()) {
|
||||
SettingsScreen(
|
||||
existing = null,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
screen = Screen.Main
|
||||
},
|
||||
onBack = null,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The one way back, whichever screen is showing and whether it was
|
||||
// reached by the system back gesture or a screen's own Back button.
|
||||
// Every leaf screen can have changed something the list shows, so it
|
||||
// always refetches.
|
||||
val goToMain = {
|
||||
reloadToken++
|
||||
screen = Screen.Main
|
||||
}
|
||||
if (screen !is Screen.Main) {
|
||||
BackHandler(onBack = goToMain)
|
||||
}
|
||||
|
||||
// Turning a notification into the screen it points at. The id has to be resolved to a session
|
||||
// first, because that is what SessionScreen is given -- and unlike a list row, which is a
|
||||
// snapshot the list already fetched, there is nothing here to seed it from.
|
||||
//
|
||||
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
|
||||
// an app that opens to the session list with no explanation looks like the tap missed.
|
||||
val open: suspend (SessionOpenRequest) -> Unit = { request ->
|
||||
failedOpen = null
|
||||
try {
|
||||
val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) }
|
||||
screen = Screen.Session(session)
|
||||
} catch (e: ApiException) {
|
||||
failedOpen = FailedOpen(request, e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
LaunchedEffect(openRequest) { openRequest?.let { open(it) } }
|
||||
|
||||
val failed = failedOpen
|
||||
if (failed != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { failedOpen = null },
|
||||
title = { Text("Couldn't open that session") },
|
||||
text = { Text(failed.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { scope.launch { open(failed.request) } }) {
|
||||
Text("Try again")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
// Every screen but the session takes the keyboard as bottom padding here. The session
|
||||
// screen deliberately does not: resizing a whole screen on every frame of the keyboard
|
||||
// animation is the cost that made it lag, so it moves only its composer and transcript --
|
||||
// see the layout note in SessionScreen.
|
||||
when (val here = screen) {
|
||||
is Screen.Main ->
|
||||
Box(Modifier.imePadding()) {
|
||||
MainScreen(
|
||||
settings = current,
|
||||
reloadToken = reloadToken,
|
||||
onOpen = { screen = Screen.Session(it) },
|
||||
onSpawn = { screen = Screen.Spawn },
|
||||
onImported = { imported ->
|
||||
reloadToken++
|
||||
screen = Screen.Session(imported)
|
||||
},
|
||||
onSettings = { screen = Screen.Settings },
|
||||
)
|
||||
}
|
||||
is Screen.Session ->
|
||||
// Keyed on the id, because a different session is a different screen rather than this
|
||||
// one showing other rows. SessionScreen remembers a transcript, an open event stream, a
|
||||
// draft and a scroll position, and without the key Compose keeps all of it across the
|
||||
// change and merges two conversations -- which crashes the list on the first duplicate
|
||||
// row key. Only reachable since a notification can move straight from one session to
|
||||
// another; every other way here passes through [Screen.Main], which disposes it anyway.
|
||||
key(here.summary.id) {
|
||||
SessionScreen(settings = current, summary = here.summary, onBack = goToMain)
|
||||
}
|
||||
is Screen.Spawn ->
|
||||
Box(Modifier.imePadding()) {
|
||||
SpawnScreen(
|
||||
settings = current,
|
||||
onSpawned = { spawned ->
|
||||
reloadToken++
|
||||
screen = Screen.Session(spawned)
|
||||
},
|
||||
onBack = goToMain,
|
||||
)
|
||||
}
|
||||
is Screen.Settings ->
|
||||
Box(Modifier.imePadding()) {
|
||||
SettingsScreen(
|
||||
existing = current,
|
||||
onSaved = { saved ->
|
||||
settings = saved
|
||||
goToMain()
|
||||
},
|
||||
onBack = goToMain,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
||||
// the activity puts around this, and that Box paints in the order it was given. A session
|
||||
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
|
||||
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
|
||||
// it goes through the same `open`, failure dialog included.
|
||||
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
||||
}
|
||||
Reference in new issue
Block a user