Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt
T
iris a9ea84c96c Stop choosing a model, and keep an imported session up to date
**Why the model became fable.** `spawn_session` fell back to the
provider's first listed model when none was given. That list is a shortcut
for the spawn screen, written in whatever order somebody typed it, and its
first entry is `fable` -- so every session spawned without a model, which
is every import, silently became a fable session. It looked like a default
and was an artefact of list order. Absent now means absent: no `--model`
flag, and the CLI uses whatever the person configured for themselves.

**Model and permission mode are now visible and changeable** from the
session, as buttons that read as their current value rather than labels
beside one. The mode was spawn-only; the CLI turns out to accept
`control_request{subtype:set_permission_mode}` and echo the mode back,
probed against 2.1.237 the same way the rest of the protocol record was.
Both default to `auto` -- on a phone every ask is a round trip to a
question card, which is how "allow Bash?" became the most-answered
question in the app.

The mode is reported by the API so the picker shows what the session is
actually set to, and it is kept in the live session beside the model for
the reason the model already was: `meta` is the shape a session was
*launched* with, so reporting from it shows the value a change replaced.

**And an imported session keeps itself level with its source file**, so
work done at a terminal arrives without a button. `--resume` appends to
the same transcript rather than forking -- measured, not assumed -- so the
only hard question is which new lines came from here.

Answered by counting the events this session has recorded. Status is the
obvious signal and is wrong, which cost a round trip to find: a turn that
starts and finishes between two polls reads as idle at both, so its output
is replayed on top of itself. It showed up on screen as `donedone`, and
only because the reply was one word -- with a longer answer it would have
looked like the model repeating itself.

Verified against both halves: text appended to the source file the way a
terminal writes it appears within one interval, and a message sent through
the app appears exactly once, before and after a turn.
2026-08-28 22:44:41 -04:00

568 lines
20 KiB
Kotlin

package com.example.aiapp
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import org.json.JSONArray
import org.json.JSONObject
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
// Shared with EventStream.kt, which connects the same way but then reads
// without a deadline.
const val CONNECT_TIMEOUT_MS = 5000
private const val READ_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the bearer token, and the
* failure translation every call needs. [readBody] gets the connected, already-status-checked
* connection to read from.
*
* @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through
* here -- an event stream has no bounded read time (see EventStream.kt).
*/
fun <T> requestFromServer(
settings: ServerSettings,
path: String,
method: String = "GET",
jsonBody: String? = null,
/** Raw request body as content-type to bytes -- the upload path. */
binaryBody: Pair<String, ByteArray>? = null,
readTimeoutMs: Int = READ_TIMEOUT_MS,
readBody: (HttpURLConnection) -> T,
): T {
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
try {
connection.applyPinnedTls()
connection.requestMethod = method
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = readTimeoutMs
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
if (jsonBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
} else if (binaryBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", binaryBody.first)
connection.outputStream.use { it.write(binaryBody.second) }
}
if (connection.responseCode !in 200..299) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(
when {
connection.responseCode == 401 ->
"The server rejected this device's token. Re-enroll by scanning " +
"the server's QR (or rotate with --rotate-token and scan the new one)."
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
}
)
}
return readBody(connection)
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
)
} finally {
connection.disconnect()
}
}
/** The response body as one JSON object. */
private fun HttpURLConnection.jsonObject(): JSONObject =
JSONObject(inputStream.bufferedReader().readText())
/** The response body as a JSON array of objects, each mapped through [parse]. */
private fun <T> HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List<T> =
JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse)
private fun <T> JSONArray.mapObjects(parse: (JSONObject) -> T): List<T> =
(0 until length()).map { parse(getJSONObject(it)) }
private fun JSONArray.strings(): List<String> = (0 until length()).map { getString(it) }
/** Percent-encodes a value going into a query string. */
private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name())
// One row of GET /sessions. A session names the machine it runs on and
// which of that machine's providers it runs.
data class SessionSummary(
val id: String,
/**
* The machine's current label. The id is deliberately not carried: nothing here addresses a
* setup, and holding both invites showing the wrong one, which is what happened.
*/
val setupName: String,
val provider: String,
val title: String,
val model: String?,
/** How much the session asks before acting; null when it was never set. */
val permissionMode: String?,
val status: String,
val lastActivity: Double,
)
private fun parseSession(session: JSONObject) =
SessionSummary(
id = session.getString("id"),
setupName = session.getString("setupName"),
provider = session.getString("provider"),
title = session.getString("title"),
model = session.optString("model").ifEmpty { null },
permissionMode = session.optString("permissionMode").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) }
// What the server offers, so the spawn screen has no hardcoded lists: a
// setup added to the server's config.ron appears here with no app rebuild.
//
// One list rather than two. A provider only exists on a machine that has
// it installed, so offering machines and providers as independent choices
// would offer pairs that cannot work.
data class Provider(val name: String, val kind: String, val models: List<String>)
/**
* A machine, and what it can run. [address] is absent for the backend itself.
*
* [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that
* refers to a setup uses the id and everything a person reads uses the name.
*/
data class Setup(
val id: String,
val name: String,
val address: String?,
val providers: List<Provider>,
)
private fun parseProvider(provider: JSONObject) =
Provider(
name = provider.getString("name"),
kind = provider.getString("kind"),
// Omitted entirely when the provider offers none.
models = provider.optJSONArray("models")?.strings().orEmpty(),
)
private fun parseSetup(setup: JSONObject) =
Setup(
id = setup.getString("id"),
name = setup.getString("name"),
address = setup.optString("address").ifEmpty { null },
providers = setup.getJSONArray("providers").mapObjects(::parseProvider),
)
fun fetchSetups(settings: ServerSettings): List<Setup> =
requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) }
/**
* A Claude Code session already on a machine, which can be continued here.
*
* Identified by [id] and never by a path. The server resolves which file that is, so this app has
* no way to ask it to read one -- the same rule that keeps a provider's command out of this client.
*/
data class Importable(
val id: String,
val cwd: String,
val title: String,
val modified: Double,
val lines: Int,
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
val named: Boolean,
)
fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
requestFromServer(settings, "/setups/$setup/importable") {
it.jsonObjects { session ->
Importable(
id = session.getString("id"),
cwd = session.optString("cwd"),
title = session.optString("title"),
modified = session.optDouble("modified", 0.0),
lines = session.optInt("lines", 0),
named = session.optBoolean("named", false),
)
}
}
/**
* How to reach a machine. Deliberately carries no command: the server discovers what a machine can
* run by asking it, so this app has no way to introduce something to run.
*
* [identityFile] is a path on the *backend*, not a key -- private keys do not travel.
*/
data class SshDetails(
val address: String,
val port: Int? = null,
val identityFile: String? = null,
)
private fun SshDetails.toJson() =
JSONObject().put("address", address).apply {
if (port != null) put("port", port)
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
}
/** What a machine turns out to have, without saving anything. */
fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List<Provider> =
requestFromServer(
settings,
"/setups/probe",
method = "POST",
jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(),
readTimeoutMs = 40000,
) {
it.jsonObjects(::parseProvider)
}
fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup =
requestFromServer(
settings,
"/setups",
method = "POST",
jsonBody =
JSONObject()
.put("name", name)
.apply { if (ssh != null) put("ssh", ssh.toJson()) }
.toString(),
readTimeoutMs = 40000,
) {
parseSetup(it.jsonObject())
}
/** Renames a machine, and optionally asks it again what it has. */
fun updateSetup(
settings: ServerSettings,
id: String,
name: String? = null,
rediscover: Boolean = false,
): Setup =
requestFromServer(
settings,
"/setups/${id.urlEncoded()}",
method = "PUT",
jsonBody =
JSONObject()
.apply {
if (name != null) put("name", name)
if (rediscover) put("rediscover", true)
}
.toString(),
readTimeoutMs = 40000,
) {
parseSetup(it.jsonObject())
}
fun deleteSetup(settings: ServerSettings, id: String) {
requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {}
}
/**
* Spawns a session and returns it as the list would show it. [setup] names the machine and
* [provider] one of the things that machine offers.
*/
fun spawnSession(
settings: ServerSettings,
setup: String,
provider: String,
title: String,
model: String? = null,
cwd: String? = null,
permissionMode: String? = null,
params: Map<String, String> = emptyMap(),
/** Continue this Claude Code session instead of starting an empty one. */
import: String? = null,
): SessionSummary =
requestFromServer(
settings,
"/sessions",
method = "POST",
jsonBody =
JSONObject()
.put("setup", setup)
.put("provider", provider)
.put("title", title)
.apply {
if (!model.isNullOrBlank()) put("model", model)
if (!cwd.isNullOrBlank()) put("cwd", cwd)
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
if (!import.isNullOrBlank()) put("import", import)
if (params.isNotEmpty()) {
put("params", JSONObject(params.toMap<String, Any>()))
}
}
.toString(),
readTimeoutMs = 30000,
) { connection ->
parseSession(connection.jsonObject())
}
fun sendMessage(
settings: ServerSettings,
sessionId: String,
text: String,
attachmentIds: List<String> = emptyList(),
) {
requestFromServer(
settings,
"/sessions/$sessionId/message",
method = "POST",
jsonBody =
JSONObject()
.put("text", text)
.put("attachmentIds", JSONArray(attachmentIds))
.toString(),
) {}
}
/** Uploads one picked image; the returned id goes into [sendMessage]. */
fun uploadAttachment(
settings: ServerSettings,
sessionId: String,
bytes: ByteArray,
mime: String,
): String {
val boundary = "----aiapp-${System.currentTimeMillis()}"
val head =
("--$boundary\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"image\"\r\n" +
"Content-Type: $mime\r\n\r\n")
.encodeToByteArray()
val tail = "\r\n--$boundary--\r\n".encodeToByteArray()
return requestFromServer(
settings,
"/sessions/$sessionId/attachments",
method = "POST",
binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail),
readTimeoutMs = 60000,
) { connection ->
connection.jsonObject().getString("id")
}
}
/** Fetches an image the transcript references (produced or uploaded). */
fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray =
requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) {
it.inputStream.readBytes()
}
// One rate-limit window, rendered as a labeled bar on the usage screen.
data class UsageWindow(
val label: String,
val percent: Double,
val resetsAt: String?,
val active: Boolean,
)
data class UsageSnapshot(
val provider: String,
val available: Boolean,
val windows: List<UsageWindow>,
val error: String?,
)
/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */
fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection ->
connection.jsonObjects { snapshot ->
UsageSnapshot(
provider = snapshot.getString("provider"),
available = snapshot.getBoolean("available"),
error = snapshot.optString("error").ifEmpty { null },
windows =
snapshot.getJSONArray("windows").mapObjects { window ->
UsageWindow(
label = window.getString("label"),
percent = window.getDouble("percent"),
resetsAt = window.optString("resetsAt").ifEmpty { null },
active = window.getBoolean("active"),
)
},
)
}
}
fun answerQuestion(
settings: ServerSettings,
sessionId: String,
questionId: String,
answer: String,
) {
requestFromServer(
settings,
"/sessions/$sessionId/answer",
method = "POST",
jsonBody = JSONObject().put("questionId", questionId).put("answer", answer).toString(),
) {}
}
fun interruptSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
}
/**
* Removes a Claude Code session from the machine.
*
* The transcript *is* the session, so this ends any chance of resuming that conversation. The
* caller confirms first; see ImportScreen.
*/
fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) {
requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {}
}
/** Switches a running session's model; the CLI changes it in place. */
fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) {
requestFromServer(
settings,
"/sessions/$sessionId/model",
method = "POST",
jsonBody = JSONObject().put("model", model).toString(),
) {}
}
/** Switches how much a running session asks before acting, also in place. */
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
requestFromServer(
settings,
"/sessions/$sessionId/permission-mode",
method = "POST",
jsonBody = JSONObject().put("mode", mode).toString(),
) {}
}
fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
}
// Models: what this backend has downloaded, what it is downloading, and
// what HuggingFace offers. Browsing is proxied by the server rather than
// done here, because this app trusts exactly one certificate and has no
// general internet trust to spend on huggingface.co.
data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long)
/**
* A download in flight or finished. [total] is null when the server never said how big the file is
* -- which must render as "not known", never as a bar at some invented position.
*/
data class Download(
val key: String,
val run: Long,
val repo: String,
val file: String,
val state: String,
val done: Long,
val total: Long?,
val error: String?,
)
data class Models(val local: List<LocalModel>, val downloads: List<Download>)
data class RemoteRepo(val id: String, val downloads: Long, val likes: Long)
data class RemoteFile(val path: String, val bytes: Long, val have: Boolean)
private fun parseDownload(o: JSONObject) =
Download(
key = o.getString("key"),
run = o.getLong("run"),
repo = o.getString("repo"),
file = o.getString("file"),
state = o.getString("state"),
done = o.getLong("done"),
// Absent rather than zero when unknown; see the field's comment.
total = if (o.has("total")) o.getLong("total") else null,
error = if (o.has("error")) o.getString("error") else null,
)
fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText())
Models(
local =
body.getJSONArray("local").mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
)
},
downloads = body.getJSONArray("downloads").mapObjects(::parseDownload),
)
}
fun searchModels(settings: ServerSettings, query: String): List<RemoteRepo> =
requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection ->
connection.jsonObjects { r ->
RemoteRepo(
id = r.getString("id"),
downloads = r.getLong("downloads"),
likes = r.getLong("likes"),
)
}
}
fun fetchRepoFiles(settings: ServerSettings, repo: String): List<RemoteFile> =
requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection ->
connection.jsonObjects { f ->
RemoteFile(
path = f.getString("path"),
bytes = f.getLong("bytes"),
have = f.getBoolean("have"),
)
}
}
fun startDownload(settings: ServerSettings, repo: String, file: String): Download =
requestFromServer(
settings,
"/models/download",
method = "POST",
jsonBody = JSONObject().put("repo", repo).put("file", file).toString(),
) { connection ->
parseDownload(JSONObject(connection.inputStream.bufferedReader().readText()))
}
fun cancelDownload(settings: ServerSettings, key: String) {
requestFromServer(
settings,
"/models/cancel",
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
) {}
}
fun deleteModel(settings: ServerSettings, key: String) {
requestFromServer(
settings,
"/models/delete",
method = "POST",
jsonBody = JSONObject().put("key", key).toString(),
) {}
}