# 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: ```kotlin 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.ui` and show it through `onDialog`. - `401`: show the response body through `onMessage`. - `3xx` including `301`: read the `Location` header and load the redirected UI. Applications can override the login UI path: ```kotlin actionDispatcher.loginUiPath = "/my/login.ui" ``` ## 新建 Sage 应用主程序示例 下面代码是新建 Sage 桌面应用时可放到应用工程里的主程序示例。它演示如何用通用 `BricksHttp` 完成 Sage 的 cookie/session 登录、加载 `center.ui`,并接入 `ActionDispatcher`。这不是 bricks-mp 库包内容。 ```kotlin 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(null) } var dialogWidget by remember { mutableStateOf(null) } var message by remember { mutableStateOf(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("登录") } } } } ```