yumoqing 43f416a6f0 feat: add DevMode with logging, HTTP interception, and debug panel
- DevLogStore: centralized log store with Flow-based observation (INFO/WARN/ERROR/EXCEPTION levels)
- DevHttpInterceptor: capture request/response pairs with timing and body details
- DevPanel: bottom panel with Logs/Network/Errors tabs, expandable entries, JSON formatting
- Integrated into BricksHttp (all HTTP methods), BricksParser, ActionDispatcher, Main
- Move Main.kt from shared/ to test/generic-client/ (library module should not have main)
- Add test/generic-client/ as generic bricks-mp desktop host with DevMode toggle
- Add kotlinx-datetime dependency for timestamp handling
- Add materialIconsExtended for DevPanel icons
2026-05-19 23:15:37 +08:00
2026-05-18 08:17:44 +08:00
2026-05-18 08:51:55 +08:00

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.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:

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("登录")
            }
        }
    }
}
Description
No description provided
Readme 266 KiB
Languages
Kotlin 92.4%
Shell 7.6%