- isWebBricksBackendResource() now matches /public and / as WebBricks backend resources - BricksHttp.kt switched from manual URL query string to Ktor parameter() API - Added test/generic-client/build.sh for one-click build/run/package
bricks-mp
Kotlin Multiplatform / Compose runtime for rendering WebBricks UI descriptions.
The library is intentionally product-neutral. Application-specific clients, login flows and entry UI loading should live in the application project, not in the com.bricks.mp library packages.
WebBricks backend request parameters
BricksHttp automatically appends the WebBricks runtime parameters to .ui and .dspy requests:
_webbricks__width_height_is_mobile_lang
Update viewport/language metadata from the host application when the window or device changes:
http.updateRequestContext(
width = windowWidthPx,
height = windowHeightPx,
isMobile = false,
lang = Locale.getDefault().toLanguageTag()
)
Generic HTTP handling
BricksHttp does not silently parse error responses as widgets. It throws BricksHttpException for HTTP errors and redirects. ActionDispatcher provides generic UI behavior:
403: load/rbac/user/login.uiand show it throughonDialog.401: show the response body throughonMessage.3xxincluding301: read theLocationheader and load the redirected UI.
Applications can override the login UI path:
actionDispatcher.loginUiPath = "/my/login.ui"
新建 Sage 应用主程序示例
下面代码是新建 Sage 桌面应用时可放到应用工程里的主程序示例。它演示如何用通用 BricksHttp 完成 Sage 的 cookie/session 登录、加载 center.ui,并接入 ActionDispatcher。这不是 bricks-mp 库包内容。
package com.example.sage
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import com.bricks.mp.actions.ActionDispatcher
import com.bricks.mp.core.BricksContext
import com.bricks.mp.core.BricksHttp
import com.bricks.mp.core.BricksWidget
import com.bricks.mp.core.RenderWidget
import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonPrimitive
private const val DEFAULT_SAGE_BASE_URL = "https://ai.atvoe.com"
fun main() = application {
val context = remember { BricksContext().apply { baseUrl = DEFAULT_SAGE_BASE_URL } }
val http = remember { BricksHttp(context) }
val scope = rememberCoroutineScope()
var loggedIn by remember { mutableStateOf(false) }
var rootWidget by remember { mutableStateOf<BricksWidget?>(null) }
var dialogWidget by remember { mutableStateOf<BricksWidget?>(null) }
var message by remember { mutableStateOf<String?>(null) }
Window(
onCloseRequest = {
http.close()
exitApplication()
},
title = if (loggedIn) "Sage" else "Sage - Login"
) {
MaterialTheme {
if (!loggedIn) {
SageLoginScreen(
baseUrl = context.baseUrl,
onBaseUrlChange = { context.baseUrl = it },
onLogin = { username, password ->
scope.launch {
runCatching {
// Sage requires an initial request so the server can issue a session cookie.
http.getText(context.baseUrl.trimEnd('/') + "/")
val loginJson = http.getJson(
url = context.entireUrl("/rbac/user/up_login.dspy"),
params = mapOf(
"username" to username,
"password" to password
)
)
val widgetType = loginJson["widgettype"]?.jsonPrimitive?.content
require(widgetType == "Message" || widgetType == "UiMessage") {
loginJson["options"]?.toString() ?: "登录失败"
}
http.fetchUi("center.ui")
}.onSuccess { widget ->
rootWidget = widget
context.setCurrentWidget(widget)
loggedIn = true
}.onFailure { error ->
message = error.message ?: "登录失败"
}
}
}
)
} else {
val actionDispatcher = remember {
ActionDispatcher(context = context, http = http, scope = scope).apply {
loginUiPath = "/rbac/user/login.ui"
onWidgetLoaded = { widget -> context.setCurrentWidget(widget) }
onDialog = { widget, show -> dialogWidget = if (show) widget else null }
onMessage = { title, body, _ -> message = "$title\n$body" }
}
}
val currentWidget by context.currentWidget.collectAsState()
RenderWidget(
widget = currentWidget ?: rootWidget ?: return@MaterialTheme,
actionDispatcher = actionDispatcher,
modifier = Modifier.fillMaxSize()
)
dialogWidget?.let { widget ->
AlertDialog(
onDismissRequest = { dialogWidget = null },
text = { RenderWidget(widget, actionDispatcher) },
confirmButton = {
TextButton(onClick = { dialogWidget = null }) { Text("Close") }
}
)
}
}
message?.let { text ->
AlertDialog(
onDismissRequest = { message = null },
title = { Text("Sage") },
text = { Text(text) },
confirmButton = { TextButton(onClick = { message = null }) { Text("OK") } }
)
}
}
}
}
@Composable
private fun SageLoginScreen(
baseUrl: String,
onBaseUrlChange: (String) -> Unit,
onLogin: (username: String, password: String) -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Box(Modifier.fillMaxSize().padding(32.dp)) {
Column {
Text("Sage")
OutlinedTextField(baseUrl, onBaseUrlChange, label = { Text("服务器地址") })
OutlinedTextField(username, { username = it }, label = { Text("用户名") })
OutlinedTextField(password, { password = it }, label = { Text("密码") })
Button(
onClick = { onLogin(username, password) },
enabled = username.isNotBlank() && password.isNotBlank()
) {
Text("登录")
}
}
}
}