diff --git a/README.md b/README.md new file mode 100644 index 0000000..08296b3 --- /dev/null +++ b/README.md @@ -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(null) } + var dialogWidget by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(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("登录") + } + } + } +} +``` diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt index bc5f06d..8084a54 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt @@ -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 + } } diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt index 9949ffe..c4758bb 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt @@ -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 = 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 = 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, authToken: String = "" ): String { + val requestParams = emptyMap().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 = 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.withBackendContextIfNeeded(url: String): Map = + if (url.isWebBricksBackendResource()) withWebBricksRequestContext(this, requestContext) else this + + private fun HttpRequestBuilder.appendQueryParameters(params: Map) { + 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))) + } } diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt new file mode 100644 index 0000000..8b9f396 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/WebBricksRequestContext.kt @@ -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 = 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, + context: WebBricksRequestContext +): Map { + 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") +} diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt b/shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt deleted file mode 100644 index 454a81a..0000000 --- a/shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt +++ /dev/null @@ -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 = _isLoggedIn - - private val _loginError = kotlinx.coroutines.flow.MutableStateFlow(null) - val loginError: kotlinx.coroutines.flow.StateFlow = _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 = 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 = emptyMap(), - method: String = "GET", - formBody: Map = emptyMap(), - jsonBody: JsonObject? = null - ): Result = 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 = 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() - } -} diff --git a/shared/src/jvmMain/kotlin/com/bricks/Main.kt b/shared/src/jvmMain/kotlin/com/bricks/Main.kt index 3a89723..b3cb580 100644 --- a/shared/src/jvmMain/kotlin/com/bricks/Main.kt +++ b/shared/src/jvmMain/kotlin/com/bricks/Main.kt @@ -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(null) } - var isLoading by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(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(null) } + var message by remember { mutableStateOf?>(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(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() + ) } } }