Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand All@@ -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<String>`:

```kotlin
val source = NebulaUiSource { system, user ->
myLlmClient.streamChat(system, user) // -> Flow<String> 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.
Expand Down
136 changes: 136 additions & 0 deletions nebula-genui/build.gradle.kts
Original file line numberDiff line numberDiff line change
@@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions {
jvmTarget = "1.8"
}
}
Original file line numberDiff line numberDiff line change
@@ -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<Boolean>() // true = object, false = array
val states = ArrayDeque<Int>()

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
}
Original file line numberDiff line numberDiff line change
@@ -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<Entry> = 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<Entry> = 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()
}
Loading
Loading