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
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
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("重试")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user