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
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.launch
import kotlinx.serialization.json.*
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/**
* 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event
* 支持回调机制以更新 UI 状态
* 支持回调机制以更新 UI 状态
*
* HTTP 错误处理保持通用
* - 403加载 /rbac/user/login.ui 并通过 onDialog 弹出
* - 401通过 onMessage 显示服务端错误内容
* - 3xx读取 Location 并按页面跳转加载目标 UI
*/
class ActionDispatcher(
private val context: BricksContext,
private val http: BricksHttp,
private val sageClient: SageClient?,
private val scope: CoroutineScope
) {
@ -28,6 +34,11 @@ class ActionDispatcher(
// 回调: 弹出/关闭对话框
var onDialog: ((widget: BricksWidget?, show: Boolean) -> Unit)? = null
/**
* 403 时默认加载的登录 UI应用可以改成自己的登录页路径
*/
var loginUiPath: String = "/rbac/user/login.ui"
/**
* 注册回调函数 (registerfunction)
*/
@ -54,49 +65,86 @@ class ActionDispatcher(
if (it is JsonPrimitive) it.contentOrNull else null
} ?: bind.url ?: return
val method = bind.options["method"]?.let {
if (it is JsonPrimitive) it.contentOrNull?.uppercase() else null
} ?: "GET"
scope.launch {
try {
loadWidget(url, showAsDialog = false)
}
}
private suspend fun loadWidget(url: String, showAsDialog: Boolean, redirectDepth: Int = 0) {
val fullUrl = resolveUrl(url)
val widget = if (sageClient != null) {
sageClient.fetchUi(fullUrl).getOrNull()
?: httpFetchWidget(fullUrl)
try {
val widget = http.fetchUi(fullUrl, authToken = context.authToken)
if (showAsDialog) {
onDialog?.invoke(widget, true)
} else {
httpFetchWidget(fullUrl)
}
if (widget != null) {
// Resolve entire_url templates
onWidgetLoaded?.invoke(widget)
println("[Bricks] urlwidget loaded: $fullUrl")
} else {
onMessage?.invoke("Error", "Failed to load: $url", 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 handleHttpException(
error: BricksHttpException,
requestUrl: String,
showAsDialog: Boolean,
redirectDepth: Int
) {
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 httpFetchWidget(url: String): BricksWidget? {
return try {
val text = http.getText(url, authToken = context.authToken)
BricksParser.parse(text)
} catch (e: Exception) {
null
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 {
if (url.startsWith("http")) return url
if (url.startsWith("http://") || url.startsWith("https://")) return 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) {
val methodName = bind.methodname ?: bind.target ?: return
@ -153,4 +201,8 @@ class ActionDispatcher(
fun close() {
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.statement.*
import io.ktor.http.*
import io.ktor.http.encodeURLParameter
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
* 支持 session cookie 管理和 Bearer token 认证
* 支持 session cookie 管理和 Bearer token 认证并将 .ui/.dspy 请求自动追加
* WebBricks 参数_webbricks__width_height_is_mobile_lang
*/
class BricksHttp(private val context: BricksContext? = null) {
@ -21,6 +42,27 @@ class BricksHttp(private val context: BricksContext? = null) {
storage = cookieStorage
}
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(),
authToken: String = ""
): JsonObject {
val response = client.get(url) {
url {
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)))
}
val text = getText(url, params, authToken)
return parseJsonObjectOrError(text)
}
/**
@ -56,10 +86,9 @@ class BricksHttp(private val context: BricksContext? = null) {
params: Map<String, String> = emptyMap(),
authToken: String = ""
): JsonObject {
val requestParams = params.withBackendContextIfNeeded(url)
val response = client.post(url) {
url {
params.forEach { (k, v) -> parameters.append(k, v) }
}
appendQueryParameters(requestParams)
contentType(ContentType.Application.Json)
setBody(body)
if (authToken.isNotEmpty()) {
@ -67,11 +96,8 @@ class BricksHttp(private val context: BricksContext? = null) {
}
}
val text = response.bodyAsText()
return try {
Json.parseToJsonElement(text).jsonObject
} catch (e: Exception) {
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(text)))
}
response.throwIfHttpError(text, url)
return parseJsonObjectOrError(text)
}
/**
@ -82,17 +108,21 @@ class BricksHttp(private val context: BricksContext? = null) {
form: Map<String, String>,
authToken: String = ""
): String {
val requestParams = emptyMap<String, String>().withBackendContextIfNeeded(url)
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) {
appendQueryParameters(requestParams)
contentType(ContentType.Application.FormUrlEncoded)
setBody(formBody)
if (authToken.isNotEmpty()) {
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(),
authToken: String = ""
): String {
val requestParams = params.withBackendContextIfNeeded(url)
val response = client.get(url) {
url {
params.forEach { (k, v) -> parameters.append(k, v) }
}
appendQueryParameters(requestParams)
if (authToken.isNotEmpty()) {
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
*/
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 cleanBase = base.trimEnd('/')
val cleanPath = path.trimStart('/')
@ -140,4 +171,34 @@ class BricksHttp(private val context: BricksContext? = null) {
fun 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,367 +1,179 @@
package com.bricks
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box
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.Scaffold
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowState
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 com.bricks.mp.actions.ActionDispatcher
import com.bricks.mp.sage.SageClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.util.Locale
/**
* Sage macOS 桌面客户端入口
* 登录 Sage 服务器 -> 加载 UI -> 渲染
* Generic bricks-mp desktop host entry.
*
* 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 {
val sageClient = remember { SageClient() }
val context = remember { BricksContext() }
val http = remember { BricksHttp(context) }
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) {
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(
onCloseRequest = {
sageClient.close()
http.close()
exitApplication()
},
title = if (isLoggedIn) "Sage" else "Sage - Login",
state = remember {
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
)
}
title = "bricks-mp",
state = windowState
) {
if (!isLoggedIn) {
// 登录界面
LoginScreen(
sageClient = sageClient,
onLoginSuccess = {
isLoggedIn = true
// 登录后加载主 UI
scope.launch(Dispatchers.IO) {
isLoading = true
errorMessage = null
// Sage 登录后加载入口 UI
val result = sageClient.fetchUi("center.ui")
result.fold(
onSuccess = { widget ->
currentWidget = widget
isLoading = false
},
onFailure = { e ->
errorMessage = "加载主界面失败: ${e.message}"
isLoading = false
MaterialTheme {
val density = LocalDensity.current
val lang = remember { Locale.getDefault().toLanguageTag() }
val fallbackWidthPx = with(density) { windowState.size.width.roundToPx() }
val fallbackHeightPx = with(density) { windowState.size.height.roundToPx() }
var windowSizePx by remember { mutableStateOf(IntSize(fallbackWidthPx, fallbackHeightPx)) }
var dialogWidget by remember { mutableStateOf<BricksWidget?>(null) }
var message by remember { mutableStateOf<Triple<String, String, Boolean>?>(null) }
DisposableEffect(window) {
fun updateWindowSize() {
val size = window.size
if (size.width > 0 && size.height > 0) {
windowSizePx = IntSize(size.width, size.height)
}
}
val listener = object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) = updateWindowSize()
override fun componentShown(e: ComponentEvent) = updateWindowSize()
}
updateWindowSize()
window.addComponentListener(listener)
onDispose { window.removeComponentListener(listener) }
}
LaunchedEffect(windowSizePx, lang) {
http.updateRequestContext(
width = windowSizePx.width.takeIf { it > 0 } ?: fallbackWidthPx,
height = windowSizePx.height.takeIf { it > 0 } ?: fallbackHeightPx,
isMobile = false,
lang = lang
)
}
},
onLoginError = { error ->
errorMessage = error
val actionDispatcher = remember(context, http, scope) {
ActionDispatcher(context = context, http = http, scope = scope).apply {
onWidgetLoaded = { widget -> context.setCurrentWidget(widget) }
onDialog = { widget, show -> dialogWidget = if (show) widget else null }
onMessage = { title, body, isError -> message = Triple(title, body, isError) }
}
)
} else {
// 主应用界面
if (isLoading) {
LoadingScreen()
} else if (currentWidget != null) {
MainAppScreen(
rootWidget = currentWidget!!,
context = context,
http = http,
sageClient = sageClient,
scope = scope,
onLogout = {
}
val currentWidget by context.currentWidget.collectAsState()
BricksHostScreen(
widget = currentWidget,
actionDispatcher = actionDispatcher,
onReload = {
scope.launch {
sageClient.logout()
isLoggedIn = false
currentWidget = null
}
},
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
}
)
runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) }
.onSuccess { context.setCurrentWidget(it) }
.onFailure { message = Triple("Error", it.message ?: "Load failed", true) }
}
}
)
} 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
dialogWidget?.let { widget ->
AlertDialog(
onDismissRequest = { dialogWidget = null },
title = { Text("Login") },
text = { RenderWidget(widget, actionDispatcher) },
confirmButton = {
TextButton(onClick = { dialogWidget = null }) { Text("Close") }
}
)
}
}
)
}
}
// 错误提示
errorMessage?.let { msg ->
LaunchedEffect(msg) {
// 可以通过 dialog 显示错误
println("[Sage] Error: $msg")
message?.let { (title, body, _) ->
AlertDialog(
onDismissRequest = { message = null },
title = { Text(title) },
text = { Text(body) },
confirmButton = {
TextButton(onClick = { message = null }) { Text("OK") }
}
}
}
}
/**
* 登录界面
*/
@Composable
fun LoginScreen(
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 {
isLoading = true
loginError = null
sageClient.baseUrl = baseUrl
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)
}
}
// Error message
loginError?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp),
fontSize = 14.sp
)
}
}
}
}
/**
* 主应用界面
*/
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
fun MainAppScreen(
rootWidget: BricksWidget,
context: BricksContext,
http: BricksHttp,
sageClient: SageClient,
scope: kotlinx.coroutines.CoroutineScope,
onLogout: () -> Unit,
onNavigate: (String) -> Unit
private fun BricksHostScreen(
widget: BricksWidget?,
actionDispatcher: ActionDispatcher,
onReload: () -> 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(
topBar = {
TopAppBar(
title = { Text("Sage") },
title = { Text("bricks-mp") },
actions = {
TextButton(onClick = onLogout) {
Text("退出")
Button(onClick = onReload, modifier = Modifier.padding(end = 8.dp)) {
Text("Reload")
}
}
)
}
) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
val widgetToRender = currentWidget ?: rootWidget
if (widget == null) {
Text("No UI loaded", modifier = Modifier.padding(24.dp))
} else {
RenderWidget(
widget = widgetToRender,
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("重试")
}
}
}
}
}