bricks-mp/README.md

190 lines
7.3 KiB
Markdown

# 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<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("登录")
}
}
}
}
```