From e939fb5ae8cf50462689cad2355087bb02749023 Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Sun, 7 Jun 2026 13:56:21 +0530 Subject: [PATCH] feat: add native Kotlin integration --- packages/kotlin/.gitignore | 5 + packages/kotlin/README.md | 62 ++ packages/kotlin/build.gradle.kts | 12 + .../kotlin/openui-compose/build.gradle.kts | 26 + .../src/main/AndroidManifest.xml | 1 + .../openui/compose/OpenUIComposeRenderer.kt | 189 +++++++ packages/kotlin/openui-lang/build.gradle.kts | 15 + .../main/kotlin/dev/openui/lang/Library.kt | 58 ++ .../kotlin/dev/openui/lang/OpenUIParser.kt | 529 ++++++++++++++++++ .../kotlin/dev/openui/lang/PromptGenerator.kt | 85 +++ .../src/main/kotlin/dev/openui/lang/Types.kt | 157 ++++++ .../dev/openui/lang/OpenUIParserTest.kt | 94 ++++ .../sample/android/app/build.gradle.kts | 32 ++ .../android/app/src/main/AndroidManifest.xml | 16 + .../java/dev/openui/sample/MainActivity.kt | 106 ++++ .../app/src/main/res/values/styles.xml | 3 + .../kotlin/sample/android/build.gradle.kts | 5 + packages/kotlin/settings.gradle.kts | 21 + 18 files changed, 1416 insertions(+) create mode 100644 packages/kotlin/.gitignore create mode 100644 packages/kotlin/README.md create mode 100644 packages/kotlin/build.gradle.kts create mode 100644 packages/kotlin/openui-compose/build.gradle.kts create mode 100644 packages/kotlin/openui-compose/src/main/AndroidManifest.xml create mode 100644 packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt create mode 100644 packages/kotlin/openui-lang/build.gradle.kts create mode 100644 packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt create mode 100644 packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt create mode 100644 packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt create mode 100644 packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt create mode 100644 packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt create mode 100644 packages/kotlin/sample/android/app/build.gradle.kts create mode 100644 packages/kotlin/sample/android/app/src/main/AndroidManifest.xml create mode 100644 packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt create mode 100644 packages/kotlin/sample/android/app/src/main/res/values/styles.xml create mode 100644 packages/kotlin/sample/android/build.gradle.kts create mode 100644 packages/kotlin/settings.gradle.kts diff --git a/packages/kotlin/.gitignore b/packages/kotlin/.gitignore new file mode 100644 index 000000000..24fce2426 --- /dev/null +++ b/packages/kotlin/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +*/build/ +sample/android/build/ +sample/android/app/build/ diff --git a/packages/kotlin/README.md b/packages/kotlin/README.md new file mode 100644 index 000000000..5e8562ea2 --- /dev/null +++ b/packages/kotlin/README.md @@ -0,0 +1,62 @@ +# OpenUI Kotlin + +Native Kotlin support for OpenUI Lang. The package is split into: + +- `openui-lang`: Kotlin component library definitions, prompt generation, OpenUI Lang parsing, and streaming parse state. +- `openui-compose`: a Jetpack Compose renderer with a registry for host app components and action callbacks. +- `sample/android`: a minimal Android app that parses an editable OpenUI Lang buffer and renders it with Compose. + +## Define a Library + +```kotlin +val library = openUILibrary { + component( + name = "Root", + description = "Top-level screen container.", + props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))), + ) + component( + name = "Button", + description = "Triggers app behavior.", + props = listOf( + PropDef("label", OpenUIType.StringType), + PropDef("action", OpenUIType.AnyType, required = false), + ), + ) + root("Root") +} + +val systemPrompt = library.prompt() +``` + +## Parse and Stream + +```kotlin +val parser = OpenUIParser(library) +val stream = parser.streamParser() + +val partial = stream.push("root = Root([") +val complete = stream.push("Button(\"Save\")])") +``` + +## Render with Compose + +```kotlin +OpenUIRenderer( + node = complete.root, + onAction = { action -> + // Route generated UI events into your Android app. + }, +) +``` + +The default Compose registry includes common primitives for layout, text, forms, buttons, lists, tables, and chart placeholders. Apps can register native components with `OpenUIComposeRegistry.register`. + +## Verify + +From `packages/kotlin`: + +```sh +gradle :openui-lang:test +gradle :sample:android:app:assembleDebug +``` diff --git a/packages/kotlin/build.gradle.kts b/packages/kotlin/build.gradle.kts new file mode 100644 index 000000000..3069cd628 --- /dev/null +++ b/packages/kotlin/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") version "2.1.21" apply false + id("com.android.library") version "8.10.1" apply false + id("com.android.application") version "8.10.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.21" apply false +} + +subprojects { + group = "dev.openui" + version = "0.1.0" +} diff --git a/packages/kotlin/openui-compose/build.gradle.kts b/packages/kotlin/openui-compose/build.gradle.kts new file mode 100644 index 000000000..c183d5085 --- /dev/null +++ b/packages/kotlin/openui-compose/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "dev.openui.compose" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":openui-lang")) + implementation(platform("androidx.compose:compose-bom:2025.05.01")) + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") +} diff --git a/packages/kotlin/openui-compose/src/main/AndroidManifest.xml b/packages/kotlin/openui-compose/src/main/AndroidManifest.xml new file mode 100644 index 000000000..94cbbcfc3 --- /dev/null +++ b/packages/kotlin/openui-compose/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt b/packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt new file mode 100644 index 000000000..ad00d8a6f --- /dev/null +++ b/packages/kotlin/openui-compose/src/main/kotlin/dev/openui/compose/OpenUIComposeRenderer.kt @@ -0,0 +1,189 @@ +package dev.openui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.openui.lang.ElementNode +import dev.openui.lang.OpenUIValue +import dev.openui.lang.asArrayOrEmpty +import dev.openui.lang.asElementOrNull +import dev.openui.lang.asStringOrNull + +public data class OpenUIAction( + public val component: String, + public val label: String?, + public val payload: OpenUIValue?, +) + +public fun interface ComponentRenderer { + @Composable + public fun Render(node: ElementNode, context: OpenUIRenderContext) +} + +public class OpenUIRenderContext( + private val registry: OpenUIComposeRegistry, + private val onAction: (OpenUIAction) -> Unit, +) { + @Composable + public fun Render(node: ElementNode?) { + OpenUIRenderer(node = node, registry = registry, onAction = onAction) + } + + public fun dispatch(action: OpenUIAction) { + onAction(action) + } +} + +public class OpenUIComposeRegistry( + renderers: Map = emptyMap(), +) { + private val renderers = LinkedHashMap(defaultRenderers()).apply { + putAll(renderers) + } + + public fun register(name: String, renderer: ComponentRenderer): OpenUIComposeRegistry = apply { + renderers[name] = renderer + } + + internal fun renderer(name: String): ComponentRenderer? = renderers[name] +} + +@Composable +public fun OpenUIRenderer( + node: ElementNode?, + registry: OpenUIComposeRegistry = OpenUIComposeRegistry(), + onAction: (OpenUIAction) -> Unit = {}, +) { + val context = OpenUIRenderContext(registry, onAction) + if (node == null) { + Text("No UI to render", color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + registry.renderer(node.typeName)?.Render(node, context) + ?: Text("Unknown component: ${node.typeName}", color = MaterialTheme.colorScheme.error) +} + +private fun defaultRenderers(): Map = + mapOf( + "Root" to ComponentRenderer { node, context -> + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + node.children().forEach { context.Render(it) } + } + }, + "Stack" to ComponentRenderer { node, context -> + val direction = node.stringProp("direction") ?: "column" + if (direction == "row") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + node.children().forEach { context.Render(it) } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + node.children().forEach { context.Render(it) } + } + } + }, + "VStack" to ComponentRenderer { node, context -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + node.children().forEach { context.Render(it) } + } + }, + "HStack" to ComponentRenderer { node, context -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + node.children().forEach { context.Render(it) } + } + }, + "Card" to ComponentRenderer { node, context -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + node.children().forEach { context.Render(it) } + } + } + }, + "Text" to ComponentRenderer { node, _ -> + Text(node.stringProp("text") ?: node.stringProp("children") ?: "") + }, + "TextContent" to ComponentRenderer { node, _ -> + Text(node.stringProp("text") ?: "") + }, + "Heading" to ComponentRenderer { node, _ -> + Text(node.stringProp("text") ?: "", style = MaterialTheme.typography.headlineSmall) + }, + "Button" to ComponentRenderer { node, context -> + val label = node.stringProp("label") ?: node.stringProp("text") ?: "Button" + Button( + onClick = { + context.dispatch(OpenUIAction(node.typeName, label, node.props["action"])) + }, + ) { + Text(label) + } + }, + "Input" to ComponentRenderer { node, context -> + OutlinedTextField( + value = node.stringProp("value") ?: "", + onValueChange = { + context.dispatch(OpenUIAction(node.typeName, node.stringProp("name"), OpenUIValue.StringValue(it))) + }, + label = { Text(node.stringProp("label") ?: "") }, + modifier = Modifier.fillMaxWidth(), + ) + }, + "Checkbox" to ComponentRenderer { node, context -> + Row { + Checkbox( + checked = (node.props["checked"] as? OpenUIValue.BooleanValue)?.value ?: false, + onCheckedChange = { + context.dispatch(OpenUIAction(node.typeName, node.stringProp("name"), OpenUIValue.BooleanValue(it))) + }, + ) + Text(node.stringProp("label") ?: "") + } + }, + "List" to ComponentRenderer { node, context -> + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(node.children()) { child -> context.Render(child) } + } + }, + "Table" to ComponentRenderer { node, _ -> + Column(modifier = Modifier.fillMaxWidth()) { + Text(node.stringProp("title") ?: "Table", style = MaterialTheme.typography.titleMedium) + Divider() + Text( + "Rows: ${node.props["rows"]?.asArrayOrEmpty().orEmpty().size}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + "Chart" to ComponentRenderer { node, _ -> + Column(modifier = Modifier.fillMaxWidth()) { + Text(node.stringProp("title") ?: "Chart", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(96.dp)) + Text("Chart data ready", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + ) + +private fun ElementNode.children(): List { + val direct = props["children"]?.asArrayOrEmpty().orEmpty().mapNotNull { it.asElementOrNull() } + if (direct.isNotEmpty()) return direct + return props["items"]?.asArrayOrEmpty().orEmpty().mapNotNull { it.asElementOrNull() } +} + +private fun ElementNode.stringProp(name: String): String? = + props[name]?.asStringOrNull() diff --git a/packages/kotlin/openui-lang/build.gradle.kts b/packages/kotlin/openui-lang/build.gradle.kts new file mode 100644 index 000000000..93862fe40 --- /dev/null +++ b/packages/kotlin/openui-lang/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(17) +} + +tasks.test { + useJUnitPlatform() +} + +dependencies { + testImplementation(kotlin("test")) +} diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt new file mode 100644 index 000000000..e5bcf4dd3 --- /dev/null +++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Library.kt @@ -0,0 +1,58 @@ +package dev.openui.lang + +public class ComponentLibrary( + components: List = emptyList(), + public val componentGroups: List = emptyList(), + public val root: String? = null, +) { + private val componentMap: LinkedHashMap = linkedMapOf() + + public val components: Map + get() = componentMap.toMap() + + init { + components.forEach(::register) + require(root == null || componentMap.containsKey(root)) { + "Root component \"$root\" was not found in the component library." + } + } + + public fun register(component: ComponentDef): ComponentLibrary = apply { + componentMap[component.name] = component + } + + public fun prompt(options: PromptOptions = PromptOptions()): String = + PromptGenerator.generate(this, options) + + public fun paramNames(componentName: String): List = + componentMap[componentName]?.props?.map { it.name }.orEmpty() + + public fun component(componentName: String): ComponentDef? = componentMap[componentName] +} + +public class ComponentLibraryBuilder { + private val components = mutableListOf() + private val groups = mutableListOf() + private var root: String? = null + + public fun component( + name: String, + description: String, + props: List = emptyList(), + ) { + components += ComponentDef(name, description, props) + } + + public fun group(name: String, components: List, notes: List = emptyList()) { + groups += ComponentGroup(name, components, notes) + } + + public fun root(name: String) { + root = name + } + + internal fun build(): ComponentLibrary = ComponentLibrary(components, groups, root) +} + +public fun openUILibrary(block: ComponentLibraryBuilder.() -> Unit): ComponentLibrary = + ComponentLibraryBuilder().apply(block).build() diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt new file mode 100644 index 000000000..b000eca8b --- /dev/null +++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/OpenUIParser.kt @@ -0,0 +1,529 @@ +package dev.openui.lang + +private sealed interface Expr { + data object NullExpr : Expr + data class Str(val value: String) : Expr + data class Num(val value: Double) : Expr + data class Bool(val value: Boolean) : Expr + data class Arr(val values: List) : Expr + data class Obj(val fields: Map) : Expr + data class Ref(val name: String) : Expr + data class Comp(val name: String, val args: List, val namedArgs: Map) : Expr +} + +private data class Statement(val id: String, val expr: Expr) + +public class OpenUIParser(private val library: ComponentLibrary) { + public fun parse(input: String): ParseResult { + val cleaned = stripComments(stripFences(input.trim())).trim() + if (cleaned.isEmpty()) return emptyResult(incomplete = true) + + val (source, incomplete) = autoClose(cleaned) + val statements = parseStatements(source) + if (statements.isEmpty()) return emptyResult(incomplete) + + val statementMap = statements.associateBy { it.id } + val entryId = pickEntryId(statementMap) + val unresolved = linkedSetOf() + val errors = mutableListOf() + val reached = linkedSetOf() + + val root = statementMap[entryId] + ?.let { materializeElement(it.expr, it.id, statementMap, unresolved, errors, reached, incomplete) } + ?.copy(statementId = entryId) + + val stateDeclarations = linkedMapOf() + val queries = mutableListOf() + val mutations = mutableListOf() + + statements.forEach { statement -> + when { + statement.id.startsWith("$") -> { + stateDeclarations[statement.id] = materializeValue( + statement.expr, + statement.id, + statementMap, + unresolved, + errors, + reached, + incomplete, + ) + } + statement.expr is Expr.Comp && statement.expr.name == "Query" -> { + queries += statement.expr.toQuery(statement.id, statementMap, unresolved, errors, reached, incomplete) + } + statement.expr is Expr.Comp && statement.expr.name == "Mutation" -> { + mutations += statement.expr.toMutation(statement.id, statementMap, unresolved, errors, reached, incomplete) + } + } + } + + val orphaned = statements + .map { it.id } + .filterNot { it == entryId || it.startsWith("$") || it in reached } + .filterNot { id -> + val expr = statementMap[id]?.expr + expr is Expr.Comp && (expr.name == "Query" || expr.name == "Mutation") + } + + return ParseResult( + root = root, + meta = ParseResult.Meta( + incomplete = incomplete, + unresolved = unresolved.toList(), + orphaned = orphaned, + statementCount = statementMap.size, + errors = errors, + ), + stateDeclarations = stateDeclarations, + queryStatements = queries, + mutationStatements = mutations, + ) + } + + public fun streamParser(): OpenUIStreamParser = OpenUIStreamParser(this) + + private fun Expr.Comp.toQuery( + statementId: String, + statements: Map, + unresolved: MutableSet, + errors: MutableList, + reached: MutableSet, + incomplete: Boolean, + ): QueryStatementInfo = + QueryStatementInfo( + statementId = statementId, + tool = args.getOrNull(0)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) }, + args = args.getOrNull(1)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) }, + defaults = args.getOrNull(2)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) }, + refreshInterval = args.getOrNull(3)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) }, + complete = !incomplete, + ) + + private fun Expr.Comp.toMutation( + statementId: String, + statements: Map, + unresolved: MutableSet, + errors: MutableList, + reached: MutableSet, + incomplete: Boolean, + ): MutationStatementInfo = + MutationStatementInfo( + statementId = statementId, + tool = args.getOrNull(0)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) }, + args = args.getOrNull(1)?.let { materializeValue(it, statementId, statements, unresolved, errors, reached, incomplete) }, + ) + + private fun materializeValue( + expr: Expr, + currentStatementId: String?, + statements: Map, + unresolved: MutableSet, + errors: MutableList, + reached: MutableSet, + incomplete: Boolean, + ): OpenUIValue = + when (expr) { + Expr.NullExpr -> OpenUIValue.NullValue + is Expr.Str -> OpenUIValue.StringValue(expr.value) + is Expr.Num -> OpenUIValue.NumberValue(expr.value) + is Expr.Bool -> OpenUIValue.BooleanValue(expr.value) + is Expr.Arr -> OpenUIValue.ArrayValue( + expr.values.map { + materializeValue(it, currentStatementId, statements, unresolved, errors, reached, incomplete) + }, + ) + is Expr.Obj -> OpenUIValue.ObjectValue( + expr.fields.mapValues { (_, value) -> + materializeValue(value, currentStatementId, statements, unresolved, errors, reached, incomplete) + }, + ) + is Expr.Ref -> { + val statement = statements[expr.name] + if (statement == null) { + unresolved += expr.name + OpenUIValue.NullValue + } else { + reached += expr.name + materializeValue(statement.expr, statement.id, statements, unresolved, errors, reached, incomplete) + } + } + is Expr.Comp -> { + materializeElement(expr, currentStatementId, statements, unresolved, errors, reached, incomplete) + ?.let(OpenUIValue::ElementValue) + ?: OpenUIValue.NullValue + } + } + + private fun materializeElement( + expr: Expr, + currentStatementId: String?, + statements: Map, + unresolved: MutableSet, + errors: MutableList, + reached: MutableSet, + incomplete: Boolean, + ): ElementNode? { + val comp = expr as? Expr.Comp ?: return null + if (comp.name == "Query" || comp.name == "Mutation") return null + + val def = library.component(comp.name) + if (def == null && !comp.name.isBuiltInCall()) { + errors += ValidationError( + code = ValidationErrorCode.UnknownComponent, + component = comp.name, + path = "", + message = "Unknown component: ${comp.name}", + statementId = currentStatementId, + ) + } + + val propDefs = def?.props.orEmpty() + val props = linkedMapOf() + + comp.args.forEachIndexed { index, arg -> + val prop = propDefs.getOrNull(index) + if (prop == null && comp.name.isBuiltInCall()) { + props["arg$index"] = materializeValue(arg, currentStatementId, statements, unresolved, errors, reached, incomplete) + } else if (prop == null) { + errors += ValidationError( + code = ValidationErrorCode.ExcessArgs, + component = comp.name, + path = "/$index", + message = "Component ${comp.name} does not declare positional argument $index.", + statementId = currentStatementId, + ) + } else { + props[prop.name] = materializeValue(arg, currentStatementId, statements, unresolved, errors, reached, incomplete) + } + } + + comp.namedArgs.forEach { (name, arg) -> + props[name] = materializeValue(arg, currentStatementId, statements, unresolved, errors, reached, incomplete) + } + + propDefs.forEach { prop -> + val value = props[prop.name] + if (value == null && prop.defaultValue != null) { + props[prop.name] = prop.defaultValue + } else if (value == null && prop.required && !incomplete) { + errors += ValidationError( + code = ValidationErrorCode.MissingRequired, + component = comp.name, + path = "/${prop.name}", + message = "Missing required prop ${prop.name} for component ${comp.name}.", + statementId = currentStatementId, + ) + } else if (value == OpenUIValue.NullValue && prop.required && !incomplete) { + errors += ValidationError( + code = ValidationErrorCode.NullRequired, + component = comp.name, + path = "/${prop.name}", + message = "Required prop ${prop.name} for component ${comp.name} cannot be null.", + statementId = currentStatementId, + ) + } + } + + return ElementNode( + statementId = currentStatementId, + typeName = comp.name, + props = props, + partial = incomplete, + ) + } + + private fun parseStatements(source: String): List { + val parser = ExpressionParser(source) + return parser.statements() + } + + private fun pickEntryId(statements: Map): String = + when { + statements.containsKey("root") -> "root" + library.root != null && statements.containsKey(library.root) -> library.root + else -> statements.keys.first() + } +} + +public class OpenUIStreamParser(private val parser: OpenUIParser) { + private var buffer: String = "" + + public fun push(chunk: String): ParseResult { + buffer += chunk + return parser.parse(buffer) + } + + public fun set(fullText: String): ParseResult { + buffer = fullText + return parser.parse(buffer) + } + + public fun getResult(): ParseResult = parser.parse(buffer) +} + +private class ExpressionParser(private val source: String) { + private var index = 0 + + fun statements(): List { + val statements = mutableListOf() + while (!eof()) { + skipWhitespace() + val id = parseIdentifier(allowState = true) + if (id == null) { + index++ + continue + } + skipWhitespace() + if (!match("=")) continue + val expr = parseExpression() ?: continue + statements += Statement(id, expr) + } + return statements + } + + private fun parseExpression(): Expr? { + skipWhitespace() + if (eof()) return null + return when (peek()) { + '"' -> Expr.Str(parseString()) + '[' -> Expr.Arr(parseArray()) + '{' -> Expr.Obj(parseObject()) + else -> when { + startsWith("true") -> { + index += 4 + Expr.Bool(true) + } + startsWith("false") -> { + index += 5 + Expr.Bool(false) + } + startsWith("null") -> { + index += 4 + Expr.NullExpr + } + peek() == '-' || peek().isDigit() -> Expr.Num(parseNumber()) + else -> parseIdentifier(allowState = true, allowBuiltin = true)?.let { ident -> + skipWhitespace() + if (match("(")) { + val (args, named) = parseArguments() + Expr.Comp(ident, args, named) + } else { + Expr.Ref(ident) + } + } + } + } + } + + private fun parseArguments(): Pair, Map> { + val args = mutableListOf() + val named = linkedMapOf() + while (!eof()) { + skipWhitespace() + if (match(")")) break + val checkpoint = index + val maybeName = parseIdentifier(allowState = false, allowBuiltin = false) + if (maybeName != null) { + skipWhitespace() + if (match(":")) { + parseExpression()?.let { named[maybeName] = it } + } else { + index = checkpoint + if (parseExpression()?.let(args::add) == null) index++ + } + } else { + if (parseExpression()?.let(args::add) == null) index++ + } + skipWhitespace() + match(",") + } + return args to named + } + + private fun parseArray(): List { + match("[") + val values = mutableListOf() + while (!eof()) { + skipWhitespace() + if (match("]")) break + if (parseExpression()?.let(values::add) == null) index++ + skipWhitespace() + match(",") + } + return values + } + + private fun parseObject(): Map { + match("{") + val fields = linkedMapOf() + while (!eof()) { + skipWhitespace() + if (match("}")) break + val key = if (peek() == '"') parseString() else parseIdentifier(allowState = false, allowBuiltin = false) + skipWhitespace() + if (key != null && match(":")) { + if (parseExpression()?.let { fields[key] = it } == null) index++ + } else if (key == null) { + index++ + } + skipWhitespace() + match(",") + } + return fields + } + + private fun parseString(): String { + match("\"") + val result = StringBuilder() + while (!eof()) { + val ch = source[index++] + if (ch == '"') break + if (ch == '\\' && !eof()) { + val escaped = source[index++] + result.append( + when (escaped) { + 'n' -> '\n' + 'r' -> '\r' + 't' -> '\t' + '"' -> '"' + '\\' -> '\\' + else -> escaped + }, + ) + } else { + result.append(ch) + } + } + return result.toString() + } + + private fun parseNumber(): Double { + val start = index + if (peek() == '-') index++ + while (!eof() && peek().isDigit()) index++ + if (!eof() && peek() == '.') { + index++ + while (!eof() && peek().isDigit()) index++ + } + return source.substring(start, index).toDoubleOrNull() ?: 0.0 + } + + private fun parseIdentifier(allowState: Boolean, allowBuiltin: Boolean = false): String? { + if (eof()) return null + val start = index + if (allowState && peek() == '$') index++ + if (allowBuiltin && !eof() && peek() == '@') index++ + if (eof() || !(peek().isLetter() || peek() == '_')) { + index = start + return null + } + index++ + while (!eof() && (peek().isLetterOrDigit() || peek() == '_' || peek() == '-')) index++ + return source.substring(start, index) + } + + private fun skipWhitespace() { + while (!eof() && peek().isWhitespace()) index++ + } + + private fun match(token: String): Boolean { + if (!startsWith(token)) return false + index += token.length + return true + } + + private fun startsWith(token: String): Boolean = source.startsWith(token, index) + private fun eof(): Boolean = index >= source.length + private fun peek(): Char = source[index] +} + +private fun String.isBuiltInCall(): Boolean = this == "Action" || startsWith("@") + +private fun emptyResult(incomplete: Boolean): ParseResult = + ParseResult( + root = null, + meta = ParseResult.Meta( + incomplete = incomplete, + unresolved = emptyList(), + orphaned = emptyList(), + statementCount = 0, + errors = emptyList(), + ), + stateDeclarations = emptyMap(), + queryStatements = emptyList(), + mutationStatements = emptyList(), + ) + +private fun stripFences(input: String): String { + if (!input.startsWith("```")) return input + val firstNewline = input.indexOf('\n') + if (firstNewline == -1) return "" + val body = input.substring(firstNewline + 1) + val trailing = body.lastIndexOf("```") + return if (trailing >= 0) body.substring(0, trailing) else body +} + +private fun stripComments(input: String): String = + input.lineSequence().joinToString("\n") { line -> + var inString = false + var escaped = false + for (i in line.indices) { + val ch = line[i] + if (escaped) { + escaped = false + continue + } + if (ch == '\\' && inString) { + escaped = true + continue + } + if (ch == '"') inString = !inString + if (!inString && ch == '#') return@joinToString line.substring(0, i).trimEnd() + if (!inString && ch == '/' && line.getOrNull(i + 1) == '/') { + return@joinToString line.substring(0, i).trimEnd() + } + } + line + } + +private fun autoClose(input: String): Pair { + var parens = 0 + var brackets = 0 + var braces = 0 + var inString = false + var escaped = false + input.forEach { ch -> + if (escaped) { + escaped = false + return@forEach + } + if (ch == '\\' && inString) { + escaped = true + return@forEach + } + if (ch == '"') { + inString = !inString + return@forEach + } + if (!inString) { + when (ch) { + '(' -> parens++ + ')' -> parens = (parens - 1).coerceAtLeast(0) + '[' -> brackets++ + ']' -> brackets = (brackets - 1).coerceAtLeast(0) + '{' -> braces++ + '}' -> braces = (braces - 1).coerceAtLeast(0) + } + } + } + val incomplete = inString || parens > 0 || brackets > 0 || braces > 0 + val closed = buildString { + append(input) + if (inString) append('"') + repeat(braces) { append('}') } + repeat(brackets) { append(']') } + repeat(parens) { append(')') } + } + return closed to incomplete +} diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt new file mode 100644 index 000000000..09fb27f8d --- /dev/null +++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/PromptGenerator.kt @@ -0,0 +1,85 @@ +package dev.openui.lang + +public data class ToolSpec( + public val name: String, + public val description: String? = null, + public val inputSchema: Map = emptyMap(), + public val outputSchema: Map = emptyMap(), +) + +public data class PromptOptions( + public val preamble: String? = null, + public val additionalRules: List = emptyList(), + public val examples: List = emptyList(), + public val toolExamples: List = emptyList(), + public val tools: List = emptyList(), + public val editMode: Boolean = false, + public val inlineMode: Boolean = false, + public val toolCalls: Boolean = tools.isNotEmpty(), + public val bindings: Boolean = toolCalls, +) + +public object PromptGenerator { + private const val DefaultPreamble = + "You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code - no markdown, no explanations, just openui-lang." + + public fun generate(library: ComponentLibrary, options: PromptOptions = PromptOptions()): String { + val rootName = library.root ?: library.components.keys.firstOrNull() ?: "Root" + return buildString { + appendLine(options.preamble ?: DefaultPreamble) + appendLine() + appendLine("## Syntax Rules") + appendLine("1. Each statement is on its own line: `identifier = Expression`") + appendLine("2. `root` is the entry point - every program must define `root = $rootName(...)`") + appendLine("3. Expressions are strings, numbers, booleans, null, arrays, objects, or component calls.") + appendLine("4. Arguments are positional and follow the component signature order.") + appendLine("5. Define reusable child nodes as statements and reference them from parent arrays.") + if (options.bindings) { + appendLine("6. Declare mutable state with `\$` variables, for example `\$search = \"\"`.") + } + appendLine() + appendLine("## Components") + library.components.values.sortedBy { it.name }.forEach { component -> + appendLine("- `${component.signature()}` - ${component.description}") + } + if (library.componentGroups.isNotEmpty()) { + appendLine() + appendLine("## Component Groups") + library.componentGroups.forEach { group -> + appendLine("- ${group.name}: ${group.components.joinToString(", ")}") + group.notes.forEach { appendLine(" - $it") } + } + } + if (options.toolCalls) { + appendLine() + appendLine("## Query and Mutation") + appendLine("- Use `Query(\"tool\", {args}, {defaults}, refreshSeconds?)` for live data.") + appendLine("- Use `Mutation(\"tool\", {args})` for actions that change data.") + appendLine("- Use `Action([@Run(queryName)])` in interactive components to trigger work.") + } + if (options.tools.isNotEmpty()) { + appendLine() + appendLine("## Tools") + options.tools.forEach { tool -> + append("- `${tool.name}`") + if (tool.description != null) append(" - ${tool.description}") + appendLine() + } + } + if (options.additionalRules.isNotEmpty()) { + appendLine() + appendLine("## Additional Rules") + options.additionalRules.forEach { appendLine("- $it") } + } + if (options.examples.isNotEmpty() || options.toolExamples.isNotEmpty()) { + appendLine() + appendLine("## Examples") + (options.examples + options.toolExamples).forEach { example -> + appendLine("```") + appendLine(example.trim()) + appendLine("```") + } + } + }.trim() + } +} diff --git a/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt new file mode 100644 index 000000000..eea462f5b --- /dev/null +++ b/packages/kotlin/openui-lang/src/main/kotlin/dev/openui/lang/Types.kt @@ -0,0 +1,157 @@ +package dev.openui.lang + +public sealed interface OpenUIType { + public fun signature(): String + + public data object StringType : OpenUIType { + override fun signature(): String = "string" + } + + public data object NumberType : OpenUIType { + override fun signature(): String = "number" + } + + public data object BooleanType : OpenUIType { + override fun signature(): String = "boolean" + } + + public data object AnyType : OpenUIType { + override fun signature(): String = "any" + } + + public data class Literal(public val value: String) : OpenUIType { + override fun signature(): String = "\"$value\"" + } + + public data class Enum(public val values: List) : OpenUIType { + override fun signature(): String = values.joinToString(" | ") { "\"$it\"" } + } + + public data class Component(public val name: String) : OpenUIType { + override fun signature(): String = name + } + + public data class ArrayOf(public val item: OpenUIType) : OpenUIType { + override fun signature(): String = "${item.signature()}[]" + } + + public data class RecordOf( + public val key: OpenUIType = StringType, + public val value: OpenUIType = AnyType, + ) : OpenUIType { + override fun signature(): String = "Record<${key.signature()}, ${value.signature()}>" + } + + public data class ObjectOf(public val fields: List) : OpenUIType { + override fun signature(): String = + fields.joinToString(prefix = "{", postfix = "}") { field -> + val optional = if (field.required) "" else "?" + "${field.name}$optional: ${field.type.signature()}" + } + } + + public data class Binding(public val inner: OpenUIType) : OpenUIType { + override fun signature(): String = "\$binding<${inner.signature()}>" + } +} + +public data class PropDef( + public val name: String, + public val type: OpenUIType = OpenUIType.AnyType, + public val required: Boolean = true, + public val defaultValue: OpenUIValue? = null, +) + +public data class ComponentDef( + public val name: String, + public val description: String, + public val props: List = emptyList(), +) { + public fun signature(): String { + val params = props.joinToString(", ") { prop -> + val optional = if (prop.required) "" else "?" + "${prop.name}$optional: ${prop.type.signature()}" + } + return "$name($params)" + } +} + +public data class ComponentGroup( + public val name: String, + public val components: List, + public val notes: List = emptyList(), +) + +public sealed interface OpenUIValue { + public data object NullValue : OpenUIValue + public data class StringValue(public val value: String) : OpenUIValue + public data class NumberValue(public val value: Double) : OpenUIValue + public data class BooleanValue(public val value: Boolean) : OpenUIValue + public data class ArrayValue(public val values: List) : OpenUIValue + public data class ObjectValue(public val fields: Map) : OpenUIValue + public data class ElementValue(public val node: ElementNode) : OpenUIValue +} + +public data class ElementNode( + public val statementId: String? = null, + public val typeName: String, + public val props: Map, + public val partial: Boolean, + public val hasDynamicProps: Boolean = false, +) + +public enum class ValidationErrorCode { + MissingRequired, + NullRequired, + UnknownComponent, + InlineReserved, + ExcessArgs, +} + +public data class ValidationError( + public val code: ValidationErrorCode, + public val component: String, + public val path: String, + public val message: String, + public val statementId: String? = null, +) + +public data class QueryStatementInfo( + public val statementId: String, + public val tool: OpenUIValue?, + public val args: OpenUIValue?, + public val defaults: OpenUIValue?, + public val refreshInterval: OpenUIValue?, + public val complete: Boolean, +) + +public data class MutationStatementInfo( + public val statementId: String, + public val tool: OpenUIValue?, + public val args: OpenUIValue?, +) + +public data class ParseResult( + public val root: ElementNode?, + public val meta: Meta, + public val stateDeclarations: Map, + public val queryStatements: List, + public val mutationStatements: List, +) { + public data class Meta( + public val incomplete: Boolean, + public val unresolved: List, + public val orphaned: List, + public val statementCount: Int, + public val errors: List, + ) +} + +public fun OpenUIValue.asStringOrNull(): String? = + (this as? OpenUIValue.StringValue)?.value + +public fun OpenUIValue.asArrayOrEmpty(): List = + (this as? OpenUIValue.ArrayValue)?.values.orEmpty() + +public fun OpenUIValue.asElementOrNull(): ElementNode? = + (this as? OpenUIValue.ElementValue)?.node diff --git a/packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt b/packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt new file mode 100644 index 000000000..a2ed9efcc --- /dev/null +++ b/packages/kotlin/openui-lang/src/test/kotlin/dev/openui/lang/OpenUIParserTest.kt @@ -0,0 +1,94 @@ +package dev.openui.lang + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OpenUIParserTest { + private val library = openUILibrary { + component( + name = "Root", + description = "Root layout.", + props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))), + ) + component( + name = "Text", + description = "Text content.", + props = listOf(PropDef("text", OpenUIType.StringType)), + ) + component( + name = "Button", + description = "Action button.", + props = listOf( + PropDef("label", OpenUIType.StringType), + PropDef("action", OpenUIType.AnyType, required = false), + ), + ) + component( + name = "Table", + description = "Data table.", + props = listOf( + PropDef("columns", OpenUIType.ArrayOf(OpenUIType.StringType)), + PropDef("rows", OpenUIType.ArrayOf(OpenUIType.RecordOf())), + ), + ) + root("Root") + } + + @Test + fun promptIncludesTypedComponentSignatures() { + val prompt = library.prompt() + + assertTrue(prompt.contains("Root(children: Component[])")) + assertTrue(prompt.contains("Button(label: string, action?: any)")) + } + + @Test + fun parsesRootAndMapsPositionalProps() { + val result = OpenUIParser(library).parse( + """ + root = Root([copy, action]) + copy = Text("Hello Kotlin") + action = Button("Open", Action([@OpenUrl("https://openui.com")])) + """.trimIndent(), + ) + + assertFalse(result.meta.incomplete) + assertTrue(result.meta.errors.isEmpty()) + assertEquals("Root", result.root?.typeName) + + val children = result.root?.props?.get("children") as OpenUIValue.ArrayValue + val text = children.values.first() as OpenUIValue.ElementValue + assertEquals("Hello Kotlin", text.node.props["text"]?.asStringOrNull()) + } + + @Test + fun streamParserReturnsPartialThenCompleteResult() { + val stream = OpenUIParser(library).streamParser() + + val partial = stream.push("root = Root([Text(\"loa") + assertTrue(partial.meta.incomplete) + assertNotNull(partial.root) + + val complete = stream.push("ded\")])") + assertFalse(complete.meta.incomplete) + assertEquals("Root", complete.root?.typeName) + } + + @Test + fun extractsStateAndQueryStatements() { + val result = OpenUIParser(library).parse( + """ + ${'$'}search = "kotlin" + rows = Query("searchRows", {q: ${'$'}search}, {rows: []}, 30) + root = Root([Text("Loaded")]) + """.trimIndent(), + ) + + assertEquals("kotlin", result.stateDeclarations["${'$'}search"]?.asStringOrNull()) + assertEquals(1, result.queryStatements.size) + assertEquals("rows", result.queryStatements.first().statementId) + } +} diff --git a/packages/kotlin/sample/android/app/build.gradle.kts b/packages/kotlin/sample/android/app/build.gradle.kts new file mode 100644 index 000000000..90b7d39b3 --- /dev/null +++ b/packages/kotlin/sample/android/app/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "dev.openui.sample" + compileSdk = 35 + + defaultConfig { + applicationId = "dev.openui.sample" + minSdk = 23 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":openui-lang")) + implementation(project(":openui-compose")) + implementation(platform("androidx.compose:compose-bom:2025.05.01")) + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") +} diff --git a/packages/kotlin/sample/android/app/src/main/AndroidManifest.xml b/packages/kotlin/sample/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..059a61cda --- /dev/null +++ b/packages/kotlin/sample/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt b/packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt new file mode 100644 index 000000000..e81219622 --- /dev/null +++ b/packages/kotlin/sample/android/app/src/main/java/dev/openui/sample/MainActivity.kt @@ -0,0 +1,106 @@ +package dev.openui.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.openui.compose.OpenUIRenderer +import dev.openui.lang.OpenUIParser +import dev.openui.lang.OpenUIType +import dev.openui.lang.PropDef +import dev.openui.lang.openUILibrary + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + StreamingChatDemo() + } + } + } + } +} + +@Composable +private fun StreamingChatDemo() { + val library = remember { sampleLibrary() } + val parser = remember { OpenUIParser(library).streamParser() } + var source by remember { + mutableStateOf( + """ + root = Root([title, card]) + title = Heading("Native Kotlin OpenUI") + card = Card([copy, action]) + copy = Text("This UI was parsed from OpenUI Lang and rendered by Jetpack Compose.") + action = Button("Continue", Action([@ToAssistant("Show me the next step")])) + """.trimIndent(), + ) + } + val result = parser.set(source) + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = source, + onValueChange = { source = it }, + modifier = Modifier.weight(1f), + label = { Text("Streaming OpenUI Lang buffer") }, + ) + OpenUIRenderer( + node = result.root, + onAction = { action -> + source += "\nnotice = Text(\"Action: ${action.label ?: action.component}\")" + }, + ) + } +} + +private fun sampleLibrary() = openUILibrary { + component( + name = "Root", + description = "Top-level container for a generated screen.", + props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))), + ) + component( + name = "Card", + description = "Grouped content surface.", + props = listOf(PropDef("children", OpenUIType.ArrayOf(OpenUIType.Component("Component")))), + ) + component( + name = "Heading", + description = "Large section heading.", + props = listOf(PropDef("text", OpenUIType.StringType)), + ) + component( + name = "Text", + description = "Body text.", + props = listOf(PropDef("text", OpenUIType.StringType)), + ) + component( + name = "Button", + description = "Button that dispatches an OpenUI action callback.", + props = listOf( + PropDef("label", OpenUIType.StringType), + PropDef("action", OpenUIType.AnyType, required = false), + ), + ) + root("Root") +} diff --git a/packages/kotlin/sample/android/app/src/main/res/values/styles.xml b/packages/kotlin/sample/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..27adb98d5 --- /dev/null +++ b/packages/kotlin/sample/android/app/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + +