diff --git a/README.md b/README.md index 306ad44..c976621 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ io.github.androidpoet:nebula:0.1.0 // JSON SDUI io.github.androidpoet:nebula-protocol:0.1.0 // Binary wire protocol (pure Kotlin, no Compose) io.github.androidpoet:nebula-protocol-creation:0.1.0 // Server-side authoring DSL io.github.androidpoet:nebula-protocol-player:0.1.0 // Compose renderer for binary documents +io.github.androidpoet:nebula-genui:0.1.0 // Generative UI — render an LLM's streamed component tree live ``` Pick only what you need: @@ -54,6 +55,51 @@ implementation("io.github.androidpoet:nebula-protocol:0.1.0") --- +## Generative UI (Streaming) + +`nebula-genui` renders a UI that an LLM streams back token by token. The screen +assembles itself as JSON arrives — no waiting for the full response. + +```kotlin +implementation("io.github.androidpoet:nebula-genui:0.1.0") +``` + +Nebula stays model-agnostic — bring your own client and adapt its token stream +into a `Flow`: + +```kotlin +val source = NebulaUiSource { system, user -> + myLlmClient.streamChat(system, user) // -> Flow of raw JSON fragments +} + +@Composable +fun Screen(ask: String) { + NebulaStream( + source = source, + userMessage = ask, + extraInstructions = "Use a scaffold with a top app bar. Brand color #6750A4.", + onAction = { action -> handle(action) }, + ) +} +``` + +`NebulaPrompt.system()` auto-generates the system prompt from the component +catalog, so the model only ever emits components the renderer understands — the +prompt and the renderer can never drift. + +Already have a token flow? Render it directly: + +```kotlin +NebulaStream(tokens = myTokenFlow, onAction = { /* ... */ }) +``` + +Under the hood, each token is buffered and repaired into the largest valid JSON +so far (`repairToValidJson`), decoded into a `NebulaComponent`, and rendered. +A new tree is emitted only when it changes, so recomposition stays cheap. For +testing and previews without a network, use `MockNebulaUiSource(json)`. + +--- + ## Binary Wire Protocol A compact binary format modeled after [AndroidX Compose Remote](https://android.googlesource.com/platform/frameworks/support/+/refs/heads/main/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/RemoteViewsTranslator.kt). Server creates a document as bytes, client renders it as native Compose UI. diff --git a/nebula-genui/build.gradle.kts b/nebula-genui/build.gradle.kts new file mode 100644 index 0000000..94602ee --- /dev/null +++ b/nebula-genui/build.gradle.kts @@ -0,0 +1,136 @@ +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.jetbrains.compose) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.nexus.plugin) +} + +mavenPublishing { + publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + coordinates("io.github.androidpoet", "nebula-genui", "0.1.0") + + pom { + name.set("Nebula GenUI") + description.set("Generative, streaming UI for Kotlin Multiplatform — render an LLM's component tree as it arrives") + url.set("https://github.com/AndroidPoet/nebula") + + licenses { + license { + name.set("Apache License 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("androidpoet") + name.set("Ranbir Singh") + url.set("https://github.com/AndroidPoet") + } + } + scm { + url.set("https://github.com/AndroidPoet/nebula") + connection.set("scm:git:git://github.com/AndroidPoet/nebula.git") + developerConnection.set("scm:git:ssh://git@github.com/AndroidPoet/nebula.git") + } + } +} + +kotlin { + androidTarget { publishLibraryVariants("release") } + jvm("desktop") + iosX64() + iosArm64() + iosSimulatorArm64() + macosX64() + macosArm64() + + @Suppress("OPT_IN_USAGE") + applyHierarchyTemplate { + common { + group("jvm") { + withAndroidTarget() + withJvm() + } + group("skia") { + withJvm() + group("darwin") { + group("apple") { + group("ios") { + withIosX64() + withIosArm64() + withIosSimulatorArm64() + } + group("macos") { + withMacosX64() + withMacosArm64() + } + } + } + } + } + } + + targets.configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":nebula-core")) + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.runtime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + } + + explicitApi() +} + +composeCompiler { + enableStrongSkippingMode = true +} + +android { + compileSdk = 34 + namespace = "io.github.androidpoet.nebula.genui" + + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + packaging { + resources { + excludes.add("/META-INF/{AL2.0,LGPL2.1}") + } + } +} + +tasks.withType { + kotlinOptions { + jvmTarget = "1.8" + } +} diff --git a/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/JsonRepair.kt b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/JsonRepair.kt new file mode 100644 index 0000000..6a9f02c --- /dev/null +++ b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/JsonRepair.kt @@ -0,0 +1,116 @@ +package io.github.androidpoet.nebula.genui + +/** + * Repairs a prefix of a streaming JSON document into the largest valid JSON it can. + * + * As an LLM emits a component tree token by token, the buffer is almost never a + * complete document. [repairToValidJson] finds the last position that forms a + * complete value and appends the closers needed to make everything up to that + * point parseable. Anything after that point — a dangling `,`, a half-typed key, + * a number still being written — is dropped and reappears on the next token. + * + * Returns `null` when nothing complete has arrived yet. + */ +public fun repairToValidJson(raw: CharSequence): String? { + // Object frame states. + val sEmpty = 0 // "{" — closeable as {} + val sExpectColon = 2 // read a key, waiting for ':' + val sExpectValue = 3 // read ':', waiting for a value + val sAfterValue = 4 // read a value — closeable + val sExpectKey = 5 // read ',', waiting for the next key + // Array frames reuse sEmpty (0), sAfterValue (4, after element) and sExpectKey (5, after ','). + + val types = ArrayDeque() // true = object, false = array + val states = ArrayDeque() + + var inString = false + var escaped = false + var stringIsKey = false + var inScalar = false + + var safeLen = -1 + var safeClosers = "" + + fun closersForStack(): String { + val sb = StringBuilder(types.size) + for (i in types.indices.reversed()) sb.append(if (types[i]) '}' else ']') + return sb.toString() + } + + fun markSafe(endExclusive: Int) { + safeLen = endExclusive + safeClosers = closersForStack() + } + + fun topCloseable(): Boolean { + val s = states.lastOrNull() ?: return types.isEmpty() + return s == sEmpty || s == sAfterValue + } + + fun endScalar(endExclusive: Int) { + if (!inScalar) return + inScalar = false + if (states.isNotEmpty()) states[states.size - 1] = sAfterValue + if (topCloseable()) markSafe(endExclusive) + } + + for (i in raw.indices) { + val c = raw[i] + + if (inString) { + when { + escaped -> escaped = false + c == '\\' -> escaped = true + c == '"' -> { + inString = false + if (states.isNotEmpty()) { + states[states.size - 1] = if (stringIsKey) sExpectColon else sAfterValue + } + if (!stringIsKey && topCloseable()) markSafe(i + 1) + } + } + continue + } + + when (c) { + '"' -> { + endScalar(i) + val topIsObject = types.lastOrNull() == true + val topState = states.lastOrNull() + stringIsKey = topIsObject && (topState == sEmpty || topState == sExpectKey) + inString = true + } + + '{', '[' -> { + endScalar(i) + types.addLast(c == '{') + states.addLast(sEmpty) + markSafe(i + 1) + } + + '}', ']' -> { + endScalar(i) + if (types.isNotEmpty()) { + types.removeLast() + states.removeLast() + if (states.isNotEmpty()) states[states.size - 1] = sAfterValue + } + if (topCloseable()) markSafe(i + 1) + } + + ':' -> if (states.isNotEmpty()) states[states.size - 1] = sExpectValue + + ',' -> { + endScalar(i) + if (states.isNotEmpty()) states[states.size - 1] = sExpectKey + } + + ' ', '\t', '\n', '\r' -> endScalar(i) + + else -> if (!inScalar) inScalar = true + } + } + + if (safeLen <= 0) return null + return raw.subSequence(0, safeLen).toString() + safeClosers +} diff --git a/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaPrompt.kt b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaPrompt.kt new file mode 100644 index 0000000..088544a --- /dev/null +++ b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaPrompt.kt @@ -0,0 +1,78 @@ +package io.github.androidpoet.nebula.genui + +/** + * Generates the system prompt that teaches a model to emit a Nebula component tree. + * + * The catalog below mirrors the `type` discriminators and fields of + * `NebulaComponent` and `NebulaAction`, so the prompt and the renderer never drift. + */ +public object NebulaPrompt { + + /** A single component the model may emit. */ + public data class Entry(val type: String, val signature: String) + + /** Every component `type` the renderer understands, with its shape. */ + public val components: List = listOf( + Entry("column", "{ children: Component[], spacing: Float, horizontalAlignment: Start|CenterHorizontally|End, verticalArrangement: Top|Center|Bottom|SpaceBetween|SpaceAround|SpaceEvenly }"), + Entry("row", "{ children: Component[], spacing: Float, verticalAlignment: Top|CenterVertically|Bottom, horizontalArrangement: Start|Center|End|SpaceBetween|SpaceAround|SpaceEvenly }"), + Entry("box", "{ children: Component[], contentAlignment: TopStart|Center|BottomEnd|... }"), + Entry("lazy_column", "{ children: Component[], spacing: Float }"), + Entry("lazy_row", "{ children: Component[], spacing: Float }"), + Entry("flow_row", "{ children: Component[], horizontalSpacing: Float, verticalSpacing: Float }"), + Entry("flow_column", "{ children: Component[], horizontalSpacing: Float, verticalSpacing: Float }"), + Entry("spacer", "{ modifier: { width?, height? } }"), + Entry("text", "{ content: String, style: { fontSize, fontWeight, color, textAlign }, maxLines: Int, overflow: Clip|Ellipsis|Visible }"), + Entry("image", "{ url: String, contentDescription: String, contentScale: Fit|Crop|FillBounds|... }"), + Entry("icon", "{ name: String, tint: String, size: Float }"), + Entry("divider", "{ thickness: Float, color: String }"), + Entry("progress_indicator", "{ type: Circular|Linear, progress: Float? }"), + Entry("badge", "{ label: String, color: String, child: Component? }"), + Entry("button", "{ text: String, style: Filled|Outlined|Text|Elevated|Tonal, action: Action, child: Component? }"), + Entry("icon_button", "{ icon: String, tint: String, action: Action }"), + Entry("text_field", "{ value: String, label: String, placeholder: String, variableKey: String, keyboardType: Text|Number|Email|Password, singleLine: Boolean }"), + Entry("checkbox", "{ checked: Boolean, label: String, variableKey: String }"), + Entry("switch", "{ checked: Boolean, label: String, variableKey: String }"), + Entry("slider", "{ value: Float, min: Float, max: Float, steps: Int, variableKey: String }"), + Entry("card", "{ children: Component[], elevation: Float, color: String, action: Action }"), + Entry("scaffold", "{ topBar: Component?, bottomBar: Component?, fab: Component?, body: Component? }"), + Entry("top_app_bar", "{ title: Component?, navigationIcon: Component?, actions: Component[], color: String }"), + Entry("conditional", "{ condition: String, ifTrue: Component?, ifFalse: Component? }"), + ) + + /** Every action `type` a component may fire. */ + public val actions: List = listOf( + Entry("navigate", "{ route: String, popCurrent: Boolean }"), + Entry("back", "{ }"), + Entry("open_url", "{ url: String }"), + Entry("set_value", "{ key: String, value: Any }"), + Entry("custom", "{ name: String, data: Object }"), + Entry("multi", "{ actions: Action[] }"), + Entry("snackbar", "{ message: String, actionLabel: String?, onAction: Action? }"), + ) + + /** + * Builds the full system prompt. Pass [extraInstructions] to add product-specific + * guidance (tone, which components to prefer, brand colors, etc.). + */ + public fun system(extraInstructions: String? = null): String = buildString { + appendLine("You render user interfaces as a single JSON object — a tree of Compose components.") + appendLine("Reply with ONLY the JSON object. No prose, no markdown fences, no comments.") + appendLine("Every node has a \"type\" field. Colors are \"#RRGGBB\" or \"#AARRGGBB\". Omit any field to use its default.") + appendLine() + appendLine("Every node also accepts an optional \"modifier\":") + appendLine(" { width, height, fillMaxWidth, fillMaxHeight, padding, paddingHorizontal, paddingVertical, background, cornerRadius, weight, alpha }") + appendLine() + appendLine("Components:") + components.forEach { appendLine(" \"${it.type}\": ${it.signature}") } + appendLine() + appendLine("Actions (used in \"action\" fields):") + actions.forEach { appendLine(" \"${it.type}\": ${it.signature}") } + appendLine() + appendLine("Bind interactive state with \"variableKey\"; reference it in a \"conditional\" \"condition\".") + appendLine("Prefer a \"scaffold\" at the root for full screens; a \"column\" or \"card\" for fragments.") + if (!extraInstructions.isNullOrBlank()) { + appendLine() + appendLine(extraInstructions.trim()) + } + }.trimEnd() +} diff --git a/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaStream.kt b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaStream.kt new file mode 100644 index 0000000..817a52e --- /dev/null +++ b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaStream.kt @@ -0,0 +1,71 @@ +package io.github.androidpoet.nebula.genui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import io.github.androidpoet.nebula.Nebula +import io.github.androidpoet.nebula.components.NebulaAction +import io.github.androidpoet.nebula.components.NebulaComponent +import io.github.androidpoet.nebula.renderer.NebulaRegistry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch + +/** + * Renders a UI that streams in from an LLM, filling in as tokens arrive. + * + * The partial tree is rendered on every meaningful update, so the user watches the + * screen assemble itself. When [tokens] is exhausted the final, complete tree remains. + * + * @param tokens raw JSON fragments from your model (see [NebulaUiSource]). + * @param onError invoked if the stream fails; the last good tree stays on screen. + */ +@Composable +public fun NebulaStream( + tokens: Flow, + registry: NebulaRegistry = remember { NebulaRegistry() }, + imageLoader: (@Composable (url: String, contentDescription: String?, modifier: Modifier) -> Unit)? = null, + onAction: ((NebulaAction) -> Unit)? = null, + onError: ((Throwable) -> Unit)? = null, +) { + val tree by produceState(initialValue = null, tokens) { + tokens.asNebulaTree() + .catch { onError?.invoke(it) } + .collect { value = it } + } + + tree?.let { + Nebula( + component = it, + registry = registry, + imageLoader = imageLoader, + onAction = onAction, + ) + } +} + +/** + * Renders streamed UI from a [NebulaUiSource], prompting it with [NebulaPrompt.system] + * plus any [extraInstructions]. + */ +@Composable +public fun NebulaStream( + source: NebulaUiSource, + userMessage: String, + extraInstructions: String? = null, + registry: NebulaRegistry = remember { NebulaRegistry() }, + imageLoader: (@Composable (url: String, contentDescription: String?, modifier: Modifier) -> Unit)? = null, + onAction: ((NebulaAction) -> Unit)? = null, + onError: ((Throwable) -> Unit)? = null, +) { + val prompt = remember(extraInstructions) { NebulaPrompt.system(extraInstructions) } + val tokens = remember(source, prompt, userMessage) { source.stream(prompt, userMessage) } + NebulaStream( + tokens = tokens, + registry = registry, + imageLoader = imageLoader, + onAction = onAction, + onError = onError, + ) +} diff --git a/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaUiSource.kt b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaUiSource.kt new file mode 100644 index 0000000..47df583 --- /dev/null +++ b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/NebulaUiSource.kt @@ -0,0 +1,38 @@ +package io.github.androidpoet.nebula.genui + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * A provider of streamed UI JSON. Bring your own model: adapt any LLM's token + * stream (OpenAI, Claude, a local model, an SSE endpoint) into a [Flow] of raw + * JSON fragments. Nebula stays provider-agnostic — it only consumes tokens. + * + * ```kotlin + * val source = NebulaUiSource { system, user -> + * openAi.streamChat(system, user).map { it.delta } // your client + * } + * ``` + */ +public fun interface NebulaUiSource { + /** Streams JSON tokens for [userMessage], guided by [systemPrompt]. */ + public fun stream(systemPrompt: String, userMessage: String): Flow +} + +/** + * A [NebulaUiSource] backed by a fixed JSON string, emitted in fixed-size chunks. + * Drives previews, samples and tests without a network or model. + */ +public class MockNebulaUiSource( + private val json: String, + private val chunkSize: Int = 12, +) : NebulaUiSource { + override fun stream(systemPrompt: String, userMessage: String): Flow = flow { + var index = 0 + while (index < json.length) { + val end = minOf(index + chunkSize, json.length) + emit(json.substring(index, end)) + index = end + } + } +} diff --git a/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/StreamingParser.kt b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/StreamingParser.kt new file mode 100644 index 0000000..b69be2a --- /dev/null +++ b/nebula-genui/src/commonMain/kotlin/io/github/androidpoet/nebula/genui/StreamingParser.kt @@ -0,0 +1,38 @@ +package io.github.androidpoet.nebula.genui + +import io.github.androidpoet.nebula.NebulaJson +import io.github.androidpoet.nebula.components.NebulaComponent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Turns a stream of raw JSON tokens into a stream of progressively-complete + * [NebulaComponent] trees. + * + * Each incoming token is appended to a buffer, the buffer is repaired into the + * largest valid JSON so far (see [repairToValidJson]) and decoded. A new tree is + * emitted only when it differs from the previous one, so recomposition downstream + * stays cheap. + */ +public fun Flow.asNebulaTree(): Flow = flow { + val buffer = StringBuilder() + var last: NebulaComponent? = null + collect { token -> + buffer.append(token) + val repaired = repairToValidJson(buffer) ?: return@collect + val tree = runCatching { NebulaJson.decodeFromString(repaired) }.getOrNull() + if (tree != null && tree != last) { + last = tree + emit(tree) + } + } +} + +/** + * Parses a single, possibly-incomplete JSON buffer into a [NebulaComponent], or + * `null` if nothing renderable has arrived yet. Useful for tests and non-Flow callers. + */ +public fun parsePartialNebula(buffer: CharSequence): NebulaComponent? { + val repaired = repairToValidJson(buffer) ?: return null + return runCatching { NebulaJson.decodeFromString(repaired) }.getOrNull() +} diff --git a/nebula-genui/src/commonTest/kotlin/io/github/androidpoet/nebula/genui/JsonRepairTest.kt b/nebula-genui/src/commonTest/kotlin/io/github/androidpoet/nebula/genui/JsonRepairTest.kt new file mode 100644 index 0000000..bc4c50f --- /dev/null +++ b/nebula-genui/src/commonTest/kotlin/io/github/androidpoet/nebula/genui/JsonRepairTest.kt @@ -0,0 +1,85 @@ +package io.github.androidpoet.nebula.genui + +import io.github.androidpoet.nebula.NebulaJson +import io.github.androidpoet.nebula.components.NebulaComponent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class JsonRepairTest { + + private val doc = + """{"type":"scaffold","body":{"type":"column","spacing":12.0,"children":[""" + + """{"type":"text","content":"Hello"},""" + + """{"type":"slider","value":0.5,"min":0.0,"max":1.0,"steps":4},""" + + """{"type":"button","text":"Go","action":{"type":"open_url","url":"https://x.com"}}""" + + """]}}""" + + @Test + fun emptyBufferIsNull() { + assertNull(repairToValidJson("")) + assertNull(repairToValidJson(" \n ")) + } + + @Test + fun openObjectClosesEmpty() { + assertEquals("{}", repairToValidJson("{")) + assertEquals("{}", repairToValidJson("{ ")) + } + + @Test + fun everyPrefixRepairsToStructurallyValidJson() { + for (i in 1..doc.length) { + val repaired = repairToValidJson(doc.substring(0, i)) ?: continue + // Must never throw — the whole point of the repair. + NebulaJson.parseToJsonElement(repaired) + } + } + + @Test + fun danglingKeyIsDropped() { + val repaired = repairToValidJson("""{"type":"text","content":"Hi","styl""") + assertEquals("""{"type":"text","content":"Hi"}""", repaired) + } + + @Test + fun danglingColonAndCommaAreDropped() { + assertEquals("""{"type":"column"}""", repairToValidJson("""{"type":"column",""")) + assertEquals("""{"type":"column"}""", repairToValidJson("""{"type":"column","children":""")) + } + + @Test + fun incompleteNumberIsDropped() { + assertEquals("""{"a":1}""", repairToValidJson("""{"a":1,"b":23""")) + } + + @Test + fun closedNumberIsKept() { + assertEquals("""{"a":1,"b":23}""", repairToValidJson("""{"a":1,"b":23}""")) + assertEquals("""[1,2]""", repairToValidJson("""[1,2,""")) + } + + @Test + fun midValueStringIsDroppedToLastCompleteField() { + val repaired = repairToValidJson("""{"type":"text","content":"Hel""") + assertEquals("""{"type":"text"}""", repaired) + } + + @Test + fun fullDocumentDecodesToExpectedTree() { + val repaired = assertNotNull(repairToValidJson(doc)) + val tree = NebulaJson.decodeFromString(repaired) + val scaffold = tree as NebulaComponent.Scaffold + val column = scaffold.body as NebulaComponent.Column + assertEquals(3, column.children.size) + assertEquals("Hello", (column.children[0] as NebulaComponent.Text).content) + } + + @Test + fun nestedArraysAndObjectsBalance() { + val repaired = assertNotNull(repairToValidJson("""{"a":[{"b":[1,2]},{"c":""")) + NebulaJson.parseToJsonElement(repaired) // does not throw + assertEquals("""{"a":[{"b":[1,2]},{}]}""", repaired) + } +} diff --git a/nebula-genui/src/commonTest/kotlin/io/github/androidpoet/nebula/genui/StreamingParserTest.kt b/nebula-genui/src/commonTest/kotlin/io/github/androidpoet/nebula/genui/StreamingParserTest.kt new file mode 100644 index 0000000..83e868d --- /dev/null +++ b/nebula-genui/src/commonTest/kotlin/io/github/androidpoet/nebula/genui/StreamingParserTest.kt @@ -0,0 +1,62 @@ +package io.github.androidpoet.nebula.genui + +import io.github.androidpoet.nebula.NebulaJson +import io.github.androidpoet.nebula.components.NebulaComponent +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StreamingParserTest { + + private val doc = + """{"type":"column","spacing":8.0,"children":[""" + + """{"type":"text","content":"Hello"},""" + + """{"type":"button","text":"Go"}""" + + """]}""" + + @Test + fun streamEmitsProgressivelyAndEndsComplete() = runTest { + val source = MockNebulaUiSource(doc, chunkSize = 5) + val emissions = source.stream("", "").asNebulaTree().toList() + + assertTrue(emissions.size >= 2, "expected progressive emissions, got ${emissions.size}") + + val expected = NebulaJson.decodeFromString(doc) + assertEquals(expected, emissions.last()) + + val firstColumn = emissions.first() as NebulaComponent.Column + val lastColumn = emissions.last() as NebulaComponent.Column + assertTrue(firstColumn.children.size <= lastColumn.children.size) + assertEquals(2, lastColumn.children.size) + } + + @Test + fun consecutiveEmissionsAreDistinct() = runTest { + val emissions = MockNebulaUiSource(doc, chunkSize = 3).stream("", "").asNebulaTree().toList() + for (i in 1 until emissions.size) { + assertTrue(emissions[i] != emissions[i - 1]) + } + } + + @Test + fun singleShotFullDocumentEmitsOnce() = runTest { + val emissions = MockNebulaUiSource(doc, chunkSize = doc.length).stream("", "").asNebulaTree().toList() + assertEquals(1, emissions.size) + assertEquals(NebulaJson.decodeFromString(doc), emissions.single()) + } + + @Test + fun promptListsEveryComponentAndAction() { + val prompt = NebulaPrompt.system() + NebulaPrompt.components.forEach { assertTrue(prompt.contains("\"${it.type}\""), "missing ${it.type}") } + NebulaPrompt.actions.forEach { assertTrue(prompt.contains("\"${it.type}\""), "missing ${it.type}") } + } + + @Test + fun extraInstructionsAreAppended() { + val prompt = NebulaPrompt.system("Use only teal accents.") + assertTrue(prompt.contains("Use only teal accents.")) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d243cc8..fd13d0d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { rootProject.name = "Nebula" include(":nebula-core") +include(":nebula-genui") include(":nebula-protocol") include(":nebula-protocol-creation") include(":nebula-protocol-player")