superintelligence-android / android/app/src/main/java/net/kentonc/si/agent/Models.kt

raw file 5205 bytes
package net.kentonc.si.agent

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
 * OpenAI-compatible wire types, mirroring src/llm.rs in the Rust original:
 * messages carry optional content (plain text or a parts list) plus tool
 * calls, and tool results come back as separate `tool` role messages.
 */

@Serializable
data class WireMessage(
    val role: String,
    val content: JsonElement? = null,
    @SerialName("tool_calls") val toolCalls: List<ToolCall> = emptyList(),
    @SerialName("tool_call_id") val toolCallId: String? = null,
) {
    companion object {
        fun system(text: String) = WireMessage(role = "system", content = JsonPrimitive(text))
        fun user(text: String, images: List<AgentImage>): WireMessage {
            val content: JsonElement = if (images.isEmpty()) JsonPrimitive(text)
            else buildJsonArray {
                if (text.isNotEmpty()) add(buildJsonObject {
                    put("type", "text"); put("text", text)
                })
                images.forEach { img -> add(img.toPart()) }
            }
            return WireMessage(role = "user", content = content)
        }
        fun assistant(content: String?, toolCalls: List<ToolCall>) = WireMessage(
            role = "assistant",
            content = content?.takeIf { it.isNotEmpty() }?.let { JsonPrimitive(it) },
            toolCalls = toolCalls,
        )
        fun toolResult(id: String, text: String) = WireMessage(
            role = "tool",
            content = JsonPrimitive(text),
            toolCallId = id,
        )
    }
}

@Serializable
data class ToolCall(
    val id: String = "",
    @SerialName("type") val type: String = "function",
    val function: FunctionCall,
)

@Serializable
data class FunctionCall(
    val name: String,
    val arguments: String = "",
)

/** One-line rendering of a call, for showing the user what's happening (ToolCall::summary). */
fun ToolCall.summary(): String {
    val args: JsonObject = runCatching {
        json.parseToJsonElement(function.arguments.ifBlank { "{}" })
    }.getOrNull() as? JsonObject ?: JsonObject(emptyMap())
    val parts = args.entries.map { (key, value) ->
        val text = when (value) {
            is JsonPrimitive -> if (key == "command" || key == "content") value.content else truncateFor(key, value.content)
            else -> truncateFor(key, value.toString())
        }
        "$key=$text"
    }
    return "${function.name}(${parts.joinToString(", ")})"
}

private fun truncateFor(key: String, text: String) =
    if (text.length <= 80) text else text.take(80) + "…"

/** Streamed chunk shapes. `content` stays a raw JSON element because servers emit
 *  either a string or an array of typed parts — handle both in [flattenContent]. */
@Serializable
data class StreamChunk(val choices: List<StreamChoice> = emptyList())

@Serializable
data class StreamChoice(val delta: StreamDelta? = null)

@Serializable
data class StreamDelta(
    val content: JsonElement? = null,
    @SerialName("tool_calls") val toolCalls: List<ToolCallDelta> = emptyList(),
)

@Serializable
data class ToolCallDelta(
    val index: Int = 0,
    val id: String? = null,
    val function: DeltaFunction? = null,
)

@Serializable
data class DeltaFunction(val name: String? = null, val arguments: String? = null)

/** Flatten a delta `content` field, string or parts list, to the text inside. */
fun flattenContent(el: JsonElement?): String? = when (el) {
    null, JsonNull -> null
    is JsonPrimitive -> el.content
    is JsonArray -> el.joinToString("") { part ->
        ((part as? JsonObject)?.get("text") as? JsonPrimitive)?.content ?: ""
    }
    is JsonObject -> ((el["text"]) as? JsonPrimitive)?.content
}

/** Something surfaced to the UI mid-turn, one stream like the Rust Item enum. */
sealed interface Item {
    data class Text(val delta: String) : Item
    data class ToolStart(val name: String, val summary: String) : Item
    data class ToolDone(val outcome: String) : Item
}

/** One local image to attach to the prompt (mirrors Image in llm.rs). */
data class AgentImage(val mime: String, val data: ByteArray) {
    /** data:<mime>;base64,<data> for the image_url part. */
    fun dataUrl(): String =
        "data:$mime;base64," + android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)

    fun toPart() = buildJsonObject {
        put("type", "image_url")
        put("image_url", buildJsonObject { put("url", dataUrl()) })
    }

    override fun equals(other: Any?) = other is AgentImage && other.mime == mime && other.data.contentEquals(data)
    override fun hashCode() = mime.hashCode() * 31 + data.contentHashCode()
}

internal val json = kotlinx.serialization.json.Json {
    ignoreUnknownKeys = true
    encodeDefaults = false
}