Skip to content

AiLiveConversation.kt

src/jvmMain/kotlin/afield/ai/conversation/AiLiveConversation.kt · 98 lines

package afield.ai.conversation

import afield.ai.models.LlmProgressUpdate
import afield.ai.conversation.AiChatModelIdentity

The running transcript of one AI4 chat turn, assembled in the order things happened.

A turn interleaves three kinds of entry: text streamed from the model, the activity lines that say which tool is running, and anything the person typed while the turn was still going. Each is appended as it arrives, and the whole sequence renders into the <<ai>>, <<you>> and <<progress>> markers that a box's ai4_chat_history property stores and aiChatHistoryHtml turns into the chat panel.

Two rules keep that order readable while the turn is live. Consecutive text deltas merge into one block instead of one block per delta, and a new activity line replaces the previous one only where that one was still transient, so a finished tool's result or failure is never overwritten by the next thing to start. The newest activity line of a live turn is marked <<progress-open>> and renders expanded; the earlier ones stay collapsed.

At the end the bridge hands back the turn's final text, which usually repeats what was already streamed. Rendering drops the streamed copy when the final text starts with it and nothing but completion activity follows it, so the reader is left with one answer rather than two.

internal class AiLiveConversation

Ordered live transcript: tool waits cannot move earlier explanations below newer activity.

internal class AiLiveConversation {
    private sealed interface Entry
    private data class Text(val value: StringBuilder) : Entry
    private data class Activity(val value: LlmProgressUpdate) : Entry
    private data class User(val value: String) : Entry
    private val entries = mutableListOf<Entry>()

@Synchronized fun text(delta: String)

    @Synchronized fun text(delta: String) {
        if (delta.isEmpty()) return
        val tail = entries.lastOrNull() as? Text
        if (tail != null) tail.value.append(delta) else entries += Text(StringBuilder(delta))
    }

fun user(message: String)

A message the person sent while the turn ran, at the point the model received it (AiTurnInbox).

    @Synchronized
    fun user(message: String) {
        if (message.isNotBlank()) entries += User(message.trim())
    }

fun hasText(text: String): Boolean

Whether text was already streamed as one of this turn's text blocks.

    @Synchronized
    fun hasText(text: String): Boolean =
        text.isNotBlank() && entries.any { it is Text && it.value.toString().trim() == text.trim() }

@Synchronized fun progress(update: LlmProgressUpdate)

    @Synchronized fun progress(update: LlmProgressUpdate) {
        if (update.title.isBlank()) return
        val tail = entries.lastOrNull() as? Activity
        // Replace only transient activity, never an explanation, result or failure.
        if (update.phase != null && tail?.value?.phase in setOf("model", "tool", "build")) {
            entries.removeAt(entries.lastIndex)
        }
        entries += Activity(update)
    }

@Synchronized fun render(prefix: String, model: AiChatModelIdentity?, live: Boolean = true,

    @Synchronized fun render(prefix: String, model: AiChatModelIdentity?, live: Boolean = true,
        finalText: String? = null, additions: String = ""): String {
        val snapshot = entries.toMutableList()
        // The bridge's final text is the final response, not all earlier commentary.
        // Replace its streamed copy (possibly followed by Completed), preserving earlier turns.
        if (finalText != null) {
            val lastText = snapshot.indexOfLast { it is Text }
            val streamed = (snapshot.getOrNull(lastText) as? Text)?.value?.toString()?.trim().orEmpty()
            val suffix = snapshot.drop(lastText + 1)
            if (streamed.isNotEmpty() && finalText.trimStart().startsWith(streamed) &&
                suffix.all { it is Activity && (it.value.phase == "completed" || it.value.phase == null) }) {
                snapshot.removeAt(lastText)
            }
        }
        return buildString {
            append(prefix.trimEnd()); if (isNotEmpty()) append("\n")
            snapshot.forEachIndexed { index, entry ->
                when (entry) {
                    is Text -> {
                        append("<<ai>>\n"); model?.let { append(it.marker()) }
                        append(entry.value); append("\n")
                    }
                    is User -> { append("<<you>>\n"); append(entry.value); append("\n") }
                    is Activity -> {
                        append(if (live && index == snapshot.lastIndex) "<<progress-open>>\n" else "<<progress>>\n")
                        append(entry.value.title.trim()); append("\n")
                        if (entry.value.body.isNotBlank()) { append(entry.value.body.trim()); append("\n") }
                    }
                }
            }
            if (additions.isNotBlank()) { append(additions.trimEnd()); append("\n") }
            if (finalText != null) {
                append("<<ai>>\n"); model?.let { append(it.marker()) }; append(finalText)
            }
        }
    }
}