refactor: move Sage client bootstrap to app example
This commit is contained in:
parent
9d3593f810
commit
4993e550db
189
README.md
Normal file
189
README.md
Normal 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("登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -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 {
|
||||
val fullUrl = resolveUrl(url)
|
||||
loadWidget(url, showAsDialog = false)
|
||||
}
|
||||
}
|
||||
|
||||
val widget = if (sageClient != null) {
|
||||
sageClient.fetchUi(fullUrl).getOrNull()
|
||||
?: httpFetchWidget(fullUrl)
|
||||
} 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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[Bricks] urlwidget error: ${e.message}")
|
||||
onMessage?.invoke("Error", "Failed to load: ${e.message}", true)
|
||||
private suspend fun loadWidget(url: String, showAsDialog: Boolean, redirectDepth: Int = 0) {
|
||||
val fullUrl = resolveUrl(url)
|
||||
try {
|
||||
val widget = http.fetchUi(fullUrl, authToken = context.authToken)
|
||||
if (showAsDialog) {
|
||||
onDialog?.invoke(widget, true)
|
||||
} else {
|
||||
onWidgetLoaded?.invoke(widget)
|
||||
}
|
||||
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? {
|
||||
return try {
|
||||
val text = http.getText(url, authToken = context.authToken)
|
||||
BricksParser.parse(text)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -1,366 +1,178 @@
|
||||
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)
|
||||
}
|
||||
},
|
||||
onLoginError = { error ->
|
||||
errorMessage = error
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// 主应用界面
|
||||
if (isLoading) {
|
||||
LoadingScreen()
|
||||
} else if (currentWidget != null) {
|
||||
MainAppScreen(
|
||||
rootWidget = currentWidget!!,
|
||||
context = context,
|
||||
http = http,
|
||||
sageClient = sageClient,
|
||||
scope = scope,
|
||||
onLogout = {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} 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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
errorMessage?.let { msg ->
|
||||
LaunchedEffect(msg) {
|
||||
// 可以通过 dialog 显示错误
|
||||
println("[Sage] Error: $msg")
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录界面
|
||||
*/
|
||||
@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 = {
|
||||
val currentWidget by context.currentWidget.collectAsState()
|
||||
BricksHostScreen(
|
||||
widget = currentWidget,
|
||||
actionDispatcher = actionDispatcher,
|
||||
onReload = {
|
||||
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
|
||||
runCatching { http.fetchUi(System.getProperty("bricks.entry", "/")) }
|
||||
.onSuccess { context.setCurrentWidget(it) }
|
||||
.onFailure { message = Triple("Error", it.message ?: "Load failed", true) }
|
||||
}
|
||||
},
|
||||
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
|
||||
loginError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
fontSize = 14.sp
|
||||
message?.let { (title, body, _) ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { message = null },
|
||||
title = { Text(title) },
|
||||
text = { Text(body) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { message = null }) { Text("OK") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主应用界面
|
||||
*/
|
||||
@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
|
||||
RenderWidget(
|
||||
widget = widgetToRender,
|
||||
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("重试")
|
||||
}
|
||||
if (widget == null) {
|
||||
Text("No UI loaded", modifier = Modifier.padding(24.dp))
|
||||
} else {
|
||||
RenderWidget(
|
||||
widget = widget,
|
||||
actionDispatcher = actionDispatcher,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user