refactor: move Sage client bootstrap to app example

This commit is contained in:
yumoqing 2026-05-18 23:18:57 +08:00
parent 9d3593f810
commit 4993e550db
6 changed files with 537 additions and 596 deletions

189
README.md Normal file
View File

@ -0,0 +1,189 @@
# 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("登录")
}
}
}
}
```

View File

@ -1,19 +1,25 @@
package com.bricks.mp.actions package com.bricks.mp.actions
import com.bricks.mp.core.* import com.bricks.mp.core.*
import com.bricks.mp.sage.SageClient import io.ktor.http.URLBuilder
import io.ktor.http.takeFrom
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.* import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/** /**
* 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event * 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event
* 支持回调机制以更新 UI 状态 * 支持回调机制以更新 UI 状态
*
* HTTP 错误处理保持通用
* - 403加载 /rbac/user/login.ui 并通过 onDialog 弹出
* - 401通过 onMessage 显示服务端错误内容
* - 3xx读取 Location 并按页面跳转加载目标 UI
*/ */
class ActionDispatcher( class ActionDispatcher(
private val context: BricksContext, private val context: BricksContext,
private val http: BricksHttp, private val http: BricksHttp,
private val sageClient: SageClient?,
private val scope: CoroutineScope private val scope: CoroutineScope
) { ) {
@ -28,6 +34,11 @@ class ActionDispatcher(
// 回调: 弹出/关闭对话框 // 回调: 弹出/关闭对话框
var onDialog: ((widget: BricksWidget?, show: Boolean) -> Unit)? = null var onDialog: ((widget: BricksWidget?, show: Boolean) -> Unit)? = null
/**
* 403 时默认加载的登录 UI应用可以改成自己的登录页路径
*/
var loginUiPath: String = "/rbac/user/login.ui"
/** /**
* 注册回调函数 (registerfunction) * 注册回调函数 (registerfunction)
*/ */
@ -54,49 +65,86 @@ class ActionDispatcher(
if (it is JsonPrimitive) it.contentOrNull else null if (it is JsonPrimitive) it.contentOrNull else null
} ?: bind.url ?: return } ?: bind.url ?: return
val method = bind.options["method"]?.let {
if (it is JsonPrimitive) it.contentOrNull?.uppercase() else null
} ?: "GET"
scope.launch { scope.launch {
try { loadWidget(url, showAsDialog = false)
val fullUrl = resolveUrl(url) }
}
val widget = if (sageClient != null) { private suspend fun loadWidget(url: String, showAsDialog: Boolean, redirectDepth: Int = 0) {
sageClient.fetchUi(fullUrl).getOrNull() val fullUrl = resolveUrl(url)
?: httpFetchWidget(fullUrl) try {
} else { val widget = http.fetchUi(fullUrl, authToken = context.authToken)
httpFetchWidget(fullUrl) if (showAsDialog) {
} onDialog?.invoke(widget, true)
} else {
if (widget != null) { onWidgetLoaded?.invoke(widget)
// Resolve entire_url templates
onWidgetLoaded?.invoke(widget)
println("[Bricks] urlwidget loaded: $fullUrl")
} else {
onMessage?.invoke("Error", "Failed to load: $url", true)
}
} catch (e: Exception) {
println("[Bricks] urlwidget error: ${e.message}")
onMessage?.invoke("Error", "Failed to load: ${e.message}", true)
} }
println("[Bricks] urlwidget loaded: $fullUrl")
} catch (e: BricksHttpException) {
handleHttpException(e, fullUrl, showAsDialog, redirectDepth)
} catch (e: Exception) {
println("[Bricks] urlwidget error: ${e.message}")
onMessage?.invoke("Error", "Failed to load: ${e.message}", true)
} }
} }
private suspend fun httpFetchWidget(url: String): BricksWidget? { private suspend fun handleHttpException(
return try { error: BricksHttpException,
val text = http.getText(url, authToken = context.authToken) requestUrl: String,
BricksParser.parse(text) showAsDialog: Boolean,
} catch (e: Exception) { redirectDepth: Int
null ) {
when (error.statusCode) {
403 -> showLoginDialog(error)
401 -> onMessage?.invoke("Unauthorized", error.displayBody(), true)
in 300..399 -> {
val location = error.location
if (location.isNullOrBlank()) {
onMessage?.invoke("Redirect", "HTTP ${error.statusCode} without Location", true)
return
}
if (redirectDepth >= MAX_REDIRECTS) {
onMessage?.invoke("Redirect", "Too many redirects: $location", true)
return
}
val nextUrl = resolveRedirectUrl(requestUrl, location)
println("[Bricks] redirect ${error.statusCode}: $requestUrl -> $nextUrl")
loadWidget(nextUrl, showAsDialog, redirectDepth + 1)
}
else -> onMessage?.invoke("HTTP ${error.statusCode}", error.displayBody(), true)
} }
} }
private suspend fun showLoginDialog(error: BricksHttpException) {
try {
val loginWidget = http.fetchUi(resolveUrl(loginUiPath), authToken = context.authToken)
onDialog?.invoke(loginWidget, true)
println("[Bricks] HTTP 403: login dialog loaded from $loginUiPath")
} catch (loginError: Exception) {
val message = "${error.displayBody()}\nLogin UI load failed: ${loginError.message}"
onMessage?.invoke("Forbidden", message, true)
}
}
private fun BricksHttpException.displayBody(): String =
responseBody.take(1000).ifBlank { message ?: "HTTP $statusCode" }
private fun resolveUrl(url: String): String { private fun resolveUrl(url: String): String {
if (url.startsWith("http")) return url if (url.startsWith("http://") || url.startsWith("https://")) return url
return context.entireUrl(url) return context.entireUrl(url)
} }
private fun resolveRedirectUrl(requestUrl: String, location: String): String {
if (location.startsWith("http://") || location.startsWith("https://")) return location
val base = URLBuilder().takeFrom(requestUrl)
return if (location.startsWith("/")) {
"${base.protocol.name}://${base.host}${if (base.port == base.protocol.defaultPort) "" else ":${base.port}"}$location"
} else {
val parentPath = base.encodedPath.substringBeforeLast('/', missingDelimiterValue = "")
"${base.protocol.name}://${base.host}${if (base.port == base.protocol.defaultPort) "" else ":${base.port}"}$parentPath/$location"
}
}
private fun handleMethod(bind: BricksBind) { private fun handleMethod(bind: BricksBind) {
val methodName = bind.methodname ?: bind.target ?: return val methodName = bind.methodname ?: bind.target ?: return
@ -153,4 +201,8 @@ class ActionDispatcher(
fun close() { fun close() {
registeredFunctions.clear() registeredFunctions.clear()
} }
private companion object {
const val MAX_REDIRECTS = 5
}
} }

View File

@ -6,11 +6,32 @@ import io.ktor.client.plugins.cookies.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.encodeURLParameter
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
/**
* HTTP error surfaced by BricksHttp before the response body is parsed.
* UI layers can map this to dialogs, messages or navigation without coupling
* the library to a specific product/application.
*/
class BricksHttpException(
val statusCode: Int,
val responseBody: String,
val location: String? = null,
val requestUrl: String? = null
) : Exception(buildMessage(statusCode, responseBody, location)) {
companion object {
private fun buildMessage(statusCode: Int, responseBody: String, location: String?): String {
val detail = responseBody.take(500).ifBlank { location.orEmpty() }
return "HTTP $statusCode${if (detail.isBlank()) "" else ": $detail"}"
}
}
}
/** /**
* Bricks HTTP 客户端 - 对应 JS HttpJson/HttpText * Bricks HTTP 客户端 - 对应 JS HttpJson/HttpText
* 支持 session cookie 管理和 Bearer token 认证 * 支持 session cookie 管理和 Bearer token 认证并将 .ui/.dspy 请求自动追加
* WebBricks 参数_webbricks__width_height_is_mobile_lang
*/ */
class BricksHttp(private val context: BricksContext? = null) { class BricksHttp(private val context: BricksContext? = null) {
@ -21,6 +42,27 @@ class BricksHttp(private val context: BricksContext? = null) {
storage = cookieStorage storage = cookieStorage
} }
expectSuccess = false expectSuccess = false
followRedirects = false
}
/**
* Runtime viewport/language context appended to .ui/.dspy backend requests.
*/
var requestContext: WebBricksRequestContext = WebBricksRequestContext()
private set
fun updateRequestContext(
width: Int = requestContext.width,
height: Int = requestContext.height,
isMobile: Boolean = requestContext.isMobile,
lang: String = requestContext.lang
) {
requestContext = WebBricksRequestContext(
width = width,
height = height,
isMobile = isMobile,
lang = lang
)
} }
/** /**
@ -31,20 +73,8 @@ class BricksHttp(private val context: BricksContext? = null) {
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
authToken: String = "" authToken: String = ""
): JsonObject { ): JsonObject {
val response = client.get(url) { val text = getText(url, params, authToken)
url { return parseJsonObjectOrError(text)
params.forEach { (k, v) -> parameters.append(k, v) }
}
if (authToken.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $authToken")
}
}
val body = response.bodyAsText()
return try {
Json.parseToJsonElement(body).jsonObject
} catch (e: Exception) {
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(body)))
}
} }
/** /**
@ -56,10 +86,9 @@ class BricksHttp(private val context: BricksContext? = null) {
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
authToken: String = "" authToken: String = ""
): JsonObject { ): JsonObject {
val requestParams = params.withBackendContextIfNeeded(url)
val response = client.post(url) { val response = client.post(url) {
url { appendQueryParameters(requestParams)
params.forEach { (k, v) -> parameters.append(k, v) }
}
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(body) setBody(body)
if (authToken.isNotEmpty()) { if (authToken.isNotEmpty()) {
@ -67,11 +96,8 @@ class BricksHttp(private val context: BricksContext? = null) {
} }
} }
val text = response.bodyAsText() val text = response.bodyAsText()
return try { response.throwIfHttpError(text, url)
Json.parseToJsonElement(text).jsonObject return parseJsonObjectOrError(text)
} catch (e: Exception) {
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(text)))
}
} }
/** /**
@ -82,17 +108,21 @@ class BricksHttp(private val context: BricksContext? = null) {
form: Map<String, String>, form: Map<String, String>,
authToken: String = "" authToken: String = ""
): String { ): String {
val requestParams = emptyMap<String, String>().withBackendContextIfNeeded(url)
val formBody = form.entries.joinToString("&") { (k, v) -> val formBody = form.entries.joinToString("&") { (k, v) ->
"${java.net.URLEncoder.encode(k, "UTF-8")}=${java.net.URLEncoder.encode(v, "UTF-8")}" "${k.encodeURLParameter()}=${v.encodeURLParameter()}"
} }
val response = client.post(url) { val response = client.post(url) {
appendQueryParameters(requestParams)
contentType(ContentType.Application.FormUrlEncoded) contentType(ContentType.Application.FormUrlEncoded)
setBody(formBody) setBody(formBody)
if (authToken.isNotEmpty()) { if (authToken.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $authToken") header(HttpHeaders.Authorization, "Bearer $authToken")
} }
} }
return response.bodyAsText() val text = response.bodyAsText()
response.throwIfHttpError(text, url)
return text
} }
/** /**
@ -103,15 +133,16 @@ class BricksHttp(private val context: BricksContext? = null) {
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
authToken: String = "" authToken: String = ""
): String { ): String {
val requestParams = params.withBackendContextIfNeeded(url)
val response = client.get(url) { val response = client.get(url) {
url { appendQueryParameters(requestParams)
params.forEach { (k, v) -> parameters.append(k, v) }
}
if (authToken.isNotEmpty()) { if (authToken.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $authToken") header(HttpHeaders.Authorization, "Bearer $authToken")
} }
} }
return response.bodyAsText() val text = response.bodyAsText()
response.throwIfHttpError(text, url)
return text
} }
/** /**
@ -130,7 +161,7 @@ class BricksHttp(private val context: BricksContext? = null) {
* 拼接 URL - 使用 context baseUrl * 拼接 URL - 使用 context baseUrl
*/ */
fun resolveUrl(path: String): String { fun resolveUrl(path: String): String {
if (path.startsWith("http")) return path if (path.startsWith("http://") || path.startsWith("https://")) return path
val base = context?.baseUrl ?: "" val base = context?.baseUrl ?: ""
val cleanBase = base.trimEnd('/') val cleanBase = base.trimEnd('/')
val cleanPath = path.trimStart('/') val cleanPath = path.trimStart('/')
@ -140,4 +171,34 @@ class BricksHttp(private val context: BricksContext? = null) {
fun close() { fun close() {
client.close() client.close()
} }
private fun Map<String, String>.withBackendContextIfNeeded(url: String): Map<String, String> =
if (url.isWebBricksBackendResource()) withWebBricksRequestContext(this, requestContext) else this
private fun HttpRequestBuilder.appendQueryParameters(params: Map<String, String>) {
url {
params.forEach { (k, v) ->
parameters.remove(k)
parameters.append(k, v)
}
}
}
private suspend fun HttpResponse.throwIfHttpError(body: String, requestUrl: String) {
val code = status.value
if (code in 300..399 || code == 401 || code == 403 || code >= 400) {
throw BricksHttpException(
statusCode = code,
responseBody = body,
location = headers[HttpHeaders.Location],
requestUrl = requestUrl
)
}
}
private fun parseJsonObjectOrError(text: String): JsonObject = try {
Json.parseToJsonElement(text).jsonObject
} catch (e: Exception) {
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(text)))
}
} }

View File

@ -0,0 +1,56 @@
package com.bricks.mp.core
/**
* Backend request context required by bricks-mp when loading server-side .ui/.dspy resources.
*
* bricks-mp runs the frontend runtime in the client, so every backend .ui/.dspy
* request must identify itself as webbricks and carry viewport/language metadata.
*/
data class WebBricksRequestContext(
val width: Int = DEFAULT_WIDTH,
val height: Int = DEFAULT_HEIGHT,
val isMobile: Boolean = DEFAULT_IS_MOBILE,
val lang: String = DEFAULT_LANG
) {
fun toQueryParameters(): Map<String, String> = mapOf(
PARAM_WEBBRICKS to "1",
PARAM_WIDTH to width.toString(),
PARAM_HEIGHT to height.toString(),
PARAM_IS_MOBILE to if (isMobile) "1" else "0",
PARAM_LANG to lang
)
companion object {
const val PARAM_WEBBRICKS = "_webbricks_"
const val PARAM_WIDTH = "_width"
const val PARAM_HEIGHT = "_height"
const val PARAM_IS_MOBILE = "_is_mobile"
const val PARAM_LANG = "_lang"
const val DEFAULT_WIDTH = 1280
const val DEFAULT_HEIGHT = 800
const val DEFAULT_IS_MOBILE = false
const val DEFAULT_LANG = "zh-CN"
}
}
/**
* Merge caller parameters with bricks-mp request context.
*
* Caller parameters may override viewport/language values when explicitly supplied,
* but _webbricks_ is always forced to 1 for .ui/.dspy backend requests.
*/
fun withWebBricksRequestContext(
params: Map<String, String>,
context: WebBricksRequestContext
): Map<String, String> {
val merged = context.toQueryParameters().toMutableMap()
merged.putAll(params)
merged[WebBricksRequestContext.PARAM_WEBBRICKS] = "1"
return merged
}
fun String.isWebBricksBackendResource(): Boolean {
val path = substringBefore('?').substringBefore('#')
return path.endsWith(".ui") || path.endsWith(".dspy")
}

View File

@ -1,229 +0,0 @@
package com.bricks.mp.sage
import com.bricks.mp.core.BricksParser
import com.bricks.mp.core.BricksWidget
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.cookies.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.*
/**
* Sage 客户端 - 处理登录Session 管理和 UI 加载
*
* Sage 使用 Cookie Session 认证
* 1. GET 首页获取初始 session cookie
* 2. POST /rbac/user/up_login.dspy 登录
* 3. 后续请求自动携带 cookie
* 4. GET /xxx.ui 获取 JSON 格式的 UI 描述
*/
class SageClient {
companion object {
const val DEFAULT_BASE_URL = "https://ai.atvoe.com"
}
private val cookieStorage = AcceptAllCookiesStorage()
private val client = HttpClient(CIO) {
install(HttpCookies) {
storage = cookieStorage
}
expectSuccess = false
followRedirects = true
}
var baseUrl: String = DEFAULT_BASE_URL
// 登录状态
private val _isLoggedIn = kotlinx.coroutines.flow.MutableStateFlow(false)
val isLoggedIn: kotlinx.coroutines.flow.StateFlow<Boolean> = _isLoggedIn
private val _loginError = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
val loginError: kotlinx.coroutines.flow.StateFlow<String?> = _loginError
private val mutex = Mutex()
/**
* 登录 Sage 服务器
* POST /rbac/user/up_login.dspy
*/
suspend fun login(username: String, password: String): Boolean = mutex.withLock {
_loginError.value = null
try {
// Step 1: GET the index page to obtain initial session cookies
// Sage requires a valid session cookie before accepting login POST
val indexResponse = client.get("$baseUrl/")
println("[Sage] GET / status: ${indexResponse.status}")
// Step 2: POST login
val url = "$baseUrl/rbac/user/up_login.dspy"
val encodedUser = java.net.URLEncoder.encode(username, "UTF-8")
val encodedPass = java.net.URLEncoder.encode(password, "UTF-8")
val formBody = "username=$encodedUser&password=$encodedPass"
val response = client.post(url) {
header(HttpHeaders.UserAgent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
header(HttpHeaders.Referrer, "$baseUrl/")
contentType(ContentType.Application.FormUrlEncoded)
setBody(formBody)
}
val body = response.bodyAsText()
println("[Sage] Login response status: ${response.status}")
println("[Sage] Login response body: ${body.take(300)}")
// Check if response is JSON
if (!body.trimStart().startsWith("{")) {
// Non-JSON response - likely an error page or auth failure
if (response.status.value == 401 || response.status.value == 403) {
_loginError.value = "登录失败: 服务器拒绝请求 (${response.status.value})"
} else {
_loginError.value = "登录失败: 服务器返回非JSON响应 (${response.status.value})"
}
println("[Sage] Non-JSON login response: ${body.take(200)}")
return@withLock false
}
// Sage 返回 UiMessage 格式: {"widgettype": "Message", "options": {...}}
val json = try {
Json.parseToJsonElement(body).jsonObject
} catch (e: Exception) {
_loginError.value = "登录响应解析错误: ${e.message}"
return@withLock false
}
val widgetType = json["widgettype"]?.jsonPrimitive?.content
if (widgetType == "Message" || widgetType == "UiMessage") {
val cookies = cookieStorage.get(URLBuilder(baseUrl).build())
println("[Sage] Login successful, cookies: ${cookies.size}")
_isLoggedIn.value = true
true
} else {
// 错误消息
val options = json["options"]?.jsonObject ?: JsonObject(emptyMap())
val message = options["message"]?.jsonPrimitive?.content
?: options["text"]?.jsonPrimitive?.content
?: body.take(100)
_loginError.value = message
println("[Sage] Login failed: $message")
false
}
} catch (e: Exception) {
_loginError.value = "登录失败: ${e.message}"
println("[Sage] Login error: ${e.message}")
false
}
}
/**
* 获取 .ui 文件JSON 格式的 UI 描述
*/
suspend fun fetchUi(path: String): Result<BricksWidget> = mutex.withLock {
try {
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
println("[Sage] Fetching UI: $url")
val response = client.get(url) {
header(HttpHeaders.UserAgent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
}
val body = response.bodyAsText()
if (!response.status.isSuccess()) {
return@withLock Result.failure(Exception("HTTP ${response.status.value}: ${body.take(200)}"))
}
val widget = BricksParser.parse(body)
Result.success(widget)
} catch (e: Exception) {
println("[Sage] Fetch UI error: ${e.message}")
Result.failure(e)
}
}
/**
* 获取 .dspy API 返回的 JSON
*/
suspend fun fetchApi(
path: String,
params: Map<String, String> = emptyMap(),
method: String = "GET",
formBody: Map<String, String> = emptyMap(),
jsonBody: JsonObject? = null
): Result<JsonObject> = mutex.withLock {
try {
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
val response = when (method.uppercase()) {
"POST" -> client.post(url) {
url {
params.forEach { (k, v) -> parameters.append(k, v) }
}
header(HttpHeaders.UserAgent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
when {
jsonBody != null -> {
contentType(ContentType.Application.Json)
setBody(jsonBody)
}
formBody.isNotEmpty() -> {
val bodyStr = formBody.entries.joinToString("&") { (k, v) ->
"${java.net.URLEncoder.encode(k, "UTF-8")}=${java.net.URLEncoder.encode(v, "UTF-8")}"
}
contentType(ContentType.Application.FormUrlEncoded)
setBody(bodyStr)
}
}
}
else -> client.get(url) {
url {
params.forEach { (k, v) -> parameters.append(k, v) }
}
header(HttpHeaders.UserAgent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
}
}
val body = response.bodyAsText()
val json = Json.parseToJsonElement(body).jsonObject
Result.success(json)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 获取文本内容
*/
suspend fun fetchText(path: String): Result<String> = mutex.withLock {
try {
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
val response = client.get(url)
Result.success(response.bodyAsText())
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 退出登录
*/
suspend fun logout() = mutex.withLock {
try {
_isLoggedIn.value = false
println("[Sage] Logged out")
} catch (e: Exception) {
println("[Sage] Logout error: ${e.message}")
}
}
/**
* 关闭客户端
*/
fun close() {
client.close()
}
}

View File

@ -1,366 +1,178 @@
package com.bricks package com.bricks
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button
import androidx.compose.material3.* import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.material3.Scaffold
import androidx.compose.ui.Alignment import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import com.bricks.mp.actions.ActionDispatcher
import com.bricks.mp.core.BricksContext import com.bricks.mp.core.BricksContext
import com.bricks.mp.core.BricksHttp import com.bricks.mp.core.BricksHttp
import com.bricks.mp.core.BricksWidget import com.bricks.mp.core.BricksWidget
import com.bricks.mp.core.RenderWidget import com.bricks.mp.core.RenderWidget
import com.bricks.mp.actions.ActionDispatcher
import com.bricks.mp.sage.SageClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.util.Locale
/** /**
* Sage macOS 桌面客户端入口 * Generic bricks-mp desktop host entry.
* 登录 Sage 服务器 -> 加载 UI -> 渲染 *
* Product-specific bootstrapping (for example Sage login and center.ui loading)
* belongs in the application project. See README.md for a Sage application sample.
*/ */
fun main() = application { fun main() = application {
val sageClient = remember { SageClient() }
val context = remember { BricksContext() } val context = remember { BricksContext() }
val http = remember { BricksHttp(context) } val http = remember { BricksHttp(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val windowState = remember { WindowState(width = 1280.dp, height = 800.dp) }
var isLoggedIn by remember { mutableStateOf(false) }
var currentWidget by remember { mutableStateOf<BricksWidget?>(null) }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// 初始化上下文
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
context.baseUrl = SageClient.DEFAULT_BASE_URL context.baseUrl = System.getProperty("bricks.baseUrl", "http://localhost:8080")
runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) }
.onSuccess { context.setCurrentWidget(it) }
.onFailure { println("[Bricks] Failed to load entry UI: ${it.message}") }
} }
Window( Window(
onCloseRequest = { onCloseRequest = {
sageClient.close()
http.close() http.close()
exitApplication() exitApplication()
}, },
title = if (isLoggedIn) "Sage" else "Sage - Login", title = "bricks-mp",
state = remember { state = windowState
WindowState(
placement = if (isLoggedIn) WindowPlacement.Maximized else WindowPlacement.Floating,
width = if (isLoggedIn) 1280.dp else 400.dp,
height = if (isLoggedIn) 800.dp else 500.dp
)
}
) { ) {
if (!isLoggedIn) { MaterialTheme {
// 登录界面 val density = LocalDensity.current
LoginScreen( val lang = remember { Locale.getDefault().toLanguageTag() }
sageClient = sageClient, val fallbackWidthPx = with(density) { windowState.size.width.roundToPx() }
onLoginSuccess = { val fallbackHeightPx = with(density) { windowState.size.height.roundToPx() }
isLoggedIn = true var windowSizePx by remember { mutableStateOf(IntSize(fallbackWidthPx, fallbackHeightPx)) }
// 登录后加载主 UI var dialogWidget by remember { mutableStateOf<BricksWidget?>(null) }
scope.launch(Dispatchers.IO) { var message by remember { mutableStateOf<Triple<String, String, Boolean>?>(null) }
isLoading = true
errorMessage = null DisposableEffect(window) {
// Sage 登录后加载入口 UI fun updateWindowSize() {
val result = sageClient.fetchUi("center.ui") val size = window.size
result.fold( if (size.width > 0 && size.height > 0) {
onSuccess = { widget -> windowSizePx = IntSize(size.width, size.height)
currentWidget = widget
isLoading = false
},
onFailure = { e ->
errorMessage = "加载主界面失败: ${e.message}"
isLoading = false
}
)
} }
},
onLoginError = { error ->
errorMessage = error
} }
)
} else { val listener = object : ComponentAdapter() {
// 主应用界面 override fun componentResized(e: ComponentEvent) = updateWindowSize()
if (isLoading) { override fun componentShown(e: ComponentEvent) = updateWindowSize()
LoadingScreen() }
} else if (currentWidget != null) {
MainAppScreen( updateWindowSize()
rootWidget = currentWidget!!, window.addComponentListener(listener)
context = context, onDispose { window.removeComponentListener(listener) }
http = http, }
sageClient = sageClient,
scope = scope, LaunchedEffect(windowSizePx, lang) {
onLogout = { http.updateRequestContext(
scope.launch { width = windowSizePx.width.takeIf { it > 0 } ?: fallbackWidthPx,
sageClient.logout() height = windowSizePx.height.takeIf { it > 0 } ?: fallbackHeightPx,
isLoggedIn = false isMobile = false,
currentWidget = null lang = lang
}
},
onNavigate = { path ->
scope.launch(Dispatchers.IO) {
isLoading = true
errorMessage = null
val result = sageClient.fetchUi(path)
result.fold(
onSuccess = { widget ->
currentWidget = widget
isLoading = false
},
onFailure = { e ->
errorMessage = "加载失败: ${e.message}"
isLoading = false
}
)
}
}
)
} else {
ErrorScreen(
message = errorMessage ?: "无法加载主界面",
onRetry = {
scope.launch(Dispatchers.IO) {
isLoading = true
errorMessage = null
val result = sageClient.fetchUi("center.ui")
result.fold(
onSuccess = { widget ->
currentWidget = widget
isLoading = false
},
onFailure = { e ->
errorMessage = "加载失败: ${e.message}"
isLoading = false
}
)
}
}
) )
} }
}
// 错误提示 val actionDispatcher = remember(context, http, scope) {
errorMessage?.let { msg -> ActionDispatcher(context = context, http = http, scope = scope).apply {
LaunchedEffect(msg) { onWidgetLoaded = { widget -> context.setCurrentWidget(widget) }
// 可以通过 dialog 显示错误 onDialog = { widget, show -> dialogWidget = if (show) widget else null }
println("[Sage] Error: $msg") onMessage = { title, body, isError -> message = Triple(title, body, isError) }
}
} }
}
}
}
/** val currentWidget by context.currentWidget.collectAsState()
* 登录界面 BricksHostScreen(
*/ widget = currentWidget,
@Composable actionDispatcher = actionDispatcher,
fun LoginScreen( onReload = {
sageClient: SageClient,
onLoginSuccess: () -> Unit,
onLoginError: (String) -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var loginError by remember { mutableStateOf<String?>(null) }
var baseUrl by remember { mutableStateOf(SageClient.DEFAULT_BASE_URL) }
val scope = rememberCoroutineScope()
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo / Title
Text(
text = "Sage",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "AI 平台",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 32.dp)
)
// Server URL
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("服务器地址") },
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
// Username
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
singleLine = true
)
// Password
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
// Login button
Button(
onClick = {
scope.launch { scope.launch {
isLoading = true runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) }
loginError = null .onSuccess { context.setCurrentWidget(it) }
sageClient.baseUrl = baseUrl .onFailure { message = Triple("Error", it.message ?: "Load failed", true) }
val success = sageClient.login(username, password)
if (success) {
onLoginSuccess()
} else {
loginError = sageClient.loginError.value ?: "登录失败"
onLoginError(loginError!!)
}
isLoading = false
} }
},
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
Text("登录", fontSize = 16.sp)
} }
)
dialogWidget?.let { widget ->
AlertDialog(
onDismissRequest = { dialogWidget = null },
title = { Text("Login") },
text = { RenderWidget(widget, actionDispatcher) },
confirmButton = {
TextButton(onClick = { dialogWidget = null }) { Text("Close") }
}
)
} }
// Error message message?.let { (title, body, _) ->
loginError?.let { error -> AlertDialog(
Text( onDismissRequest = { message = null },
text = error, title = { Text(title) },
color = MaterialTheme.colorScheme.error, text = { Text(body) },
modifier = Modifier.padding(top = 16.dp), confirmButton = {
fontSize = 14.sp TextButton(onClick = { message = null }) { Text("OK") }
}
) )
} }
} }
} }
} }
/**
* 主应用界面
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainAppScreen( private fun BricksHostScreen(
rootWidget: BricksWidget, widget: BricksWidget?,
context: BricksContext, actionDispatcher: ActionDispatcher,
http: BricksHttp, onReload: () -> Unit
sageClient: SageClient,
scope: kotlinx.coroutines.CoroutineScope,
onLogout: () -> Unit,
onNavigate: (String) -> Unit
) { ) {
val actionDispatcher = remember {
ActionDispatcher(
context = context,
http = http,
sageClient = sageClient,
scope = scope
).apply {
onWidgetLoaded = { widget ->
context.setCurrentWidget(widget)
}
onMessage = { title, message, isError ->
println("[Sage] Message: $title - $message (error: $isError)")
}
}
}
// 监听 widget 变化
val currentWidget by context.currentWidget.collectAsState()
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Sage") }, title = { Text("bricks-mp") },
actions = { actions = {
TextButton(onClick = onLogout) { Button(onClick = onReload, modifier = Modifier.padding(end = 8.dp)) {
Text("退出") Text("Reload")
} }
} }
) )
} }
) { padding -> ) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) { Box(modifier = Modifier.padding(padding).fillMaxSize()) {
val widgetToRender = currentWidget ?: rootWidget if (widget == null) {
RenderWidget( Text("No UI loaded", modifier = Modifier.padding(24.dp))
widget = widgetToRender, } else {
actionDispatcher = actionDispatcher, RenderWidget(
modifier = Modifier.fillMaxSize() widget = widget,
) actionDispatcher = actionDispatcher,
} modifier = Modifier.fillMaxSize()
} )
}
/**
* 加载界面
*/
@Composable
fun LoadingScreen() {
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("加载中...")
}
}
}
}
/**
* 错误界面
*/
@Composable
fun ErrorScreen(
message: String,
onRetry: () -> Unit
) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("", fontSize = 48.sp)
Spacer(modifier = Modifier.height(16.dp))
Text(message)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("重试")
}
} }
} }
} }