From 82c2b89ecda9842c7c08ae1f24b3659c95bb6f7a Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 18 May 2026 08:42:34 +0800 Subject: [PATCH] feat: macOS Sage desktop client with login, UI rendering, and DMG packaging - SageClient: HTTP client with cookie-based login, session management, UI fetching - Main.kt: Full macOS desktop app with login screen, main window, loading/error states - BricksRenderer: Complete inline rendering for VBox/HBox/Button/Form/Menu/TabPanel/Modal/Scroll/Image/Svg - BricksHttp: Ktor CIO engine with cookie storage, Bearer token auth, form POST - BricksContext: currentWidget state flow, resolveTemplates for entire_url - ActionDispatcher: SageClient integration, urlwidget loading, event callbacks - BricksWidget: Added options field to BricksBind - BricksParser: Parse bind options from JSON - Build: Added ktor-client-cio, content-negotiation deps; DMG packaging configured - .gitignore: Standard Kotlin/Gradle/IDE patterns --- .gitignore | 15 + desktopApp/src/main/kotlin/com/bricks/Main.kt | 405 +++++++++-- gradle/libs.versions.toml | 3 + shared/build.gradle.kts | 3 + .../com/bricks/mp/actions/ActionDispatcher.kt | 110 ++- .../com/bricks/mp/core/BricksContext.kt | 22 + .../kotlin/com/bricks/mp/core/BricksHttp.kt | 129 +++- .../kotlin/com/bricks/mp/core/BricksParser.kt | 4 +- .../com/bricks/mp/core/BricksRenderer.kt | 675 +++++++++++++++++- .../kotlin/com/bricks/mp/core/BricksWidget.kt | 4 +- .../kotlin/com/bricks/mp/sage/SageClient.kt | 214 ++++++ .../com/bricks/mp/widgets/InputWidgets.kt | 4 +- .../com/bricks/mp/widgets/LayoutWidgets.kt | 72 +- 13 files changed, 1455 insertions(+), 205 deletions(-) create mode 100644 .gitignore create mode 100644 shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d20093c --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.class +*.log +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.idea/ +*.iml +*.ipr +*.iws +out/ +.DS_Store +kotlin-compiler-*.log +native/ diff --git a/desktopApp/src/main/kotlin/com/bricks/Main.kt b/desktopApp/src/main/kotlin/com/bricks/Main.kt index 1e2e377..ee040e8 100644 --- a/desktopApp/src/main/kotlin/com/bricks/Main.kt +++ b/desktopApp/src/main/kotlin/com/bricks/Main.kt @@ -1,101 +1,366 @@ package com.bricks -import androidx.compose.desktop.ui.tooling.preview.Preview +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.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.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.core.BricksContext -import com.bricks.mp.core.BricksParser -import com.bricks.mp.core.BricksApp -import kotlinx.coroutines.CoroutineScope +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.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +/** + * Sage macOS 桌面客户端入口 + * 登录 Sage 服务器 -> 加载 UI -> 渲染 + */ fun main() = application { - Window( - onCloseRequest = ::exitApplication, - title = "Bricks MP - Desktop", - state = rememberWindowState(width = 1200.dp, height = 800.dp) - ) { - BricksDesktopApp() - } -} - -@Composable -@Preview -fun BricksDesktopApp() { + val sageClient = remember { SageClient() } val context = remember { BricksContext() } - var jsonInput by remember { mutableStateOf(SAMPLE_JSON) } - var rootWidget by remember { mutableStateOf(null) } + val http = remember { BricksHttp(context) } + val scope = rememberCoroutineScope() - Column(modifier = Modifier.fillMaxSize()) { - // JSON 输入区 - Row(modifier = Modifier.fillMaxWidth().weight(0.3f)) { - OutlinedTextField( - value = jsonInput, - onValueChange = { jsonInput = it }, - modifier = Modifier.fillMaxSize().padding(8.dp), - label = { Text("Bricks JSON") }, - textStyle = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace) + 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 + } + + 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 ) } - - // 解析按钮 - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - Button(onClick = { - try { - rootWidget = BricksParser.parse(jsonInput) - } catch (e: Exception) { - println("Parse error: ${e.message}") + ) { + 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 + } + ) + } + }, + onLoginError = { error -> + errorMessage = error } - }) { - Text("Parse & Render") + ) + } 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 + } + ) + } + } + ) } } - // 渲染区 - Surface(modifier = Modifier.fillMaxWidth().weight(0.6f)) { - if (rootWidget != null) { - BricksApp(rootWidget!!) - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Enter Bricks JSON and click Parse") - } + // 错误提示 + errorMessage?.let { msg -> + LaunchedEffect(msg) { + // 可以通过 dialog 显示错误 + println("[Sage] Error: $msg") } } } } -val SAMPLE_JSON = """ -{ - "widgettype": "VBox", - "options": { - "text": "Hello Bricks MP" - }, - "subwidgets": [ - { - "widgettype": "Title1", - "options": { - "text": "Welcome to Bricks" +/** + * 登录界面 + */ +@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 = { + scope.launch { + isLoading = true + loginError = null + sageClient.baseUrl = baseUrl + + val success = sageClient.login(username, password) + if (success) { + onLoginSuccess() + } else { + loginError = sageClient.loginError.value ?: "登录失败" + onLoginError(loginError!!) + } + isLoading = false + } + }, + enabled = !isLoading && username.isNotBlank() && password.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(48.dp) + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + Text("登录", fontSize = 16.sp) + } } - }, - { - "widgettype": "Text", - "options": { - "text": "Cross-platform JSON-driven UI" - } - }, - { - "widgettype": "KeyinText", - "options": { - "placeholder": "Type here...", - "label": "Input" + + // Error message + loginError?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 16.dp), + fontSize = 14.sp + ) } } - ] + } +} + +/** + * 主应用界面 + */ +@Composable +fun MainAppScreen( + rootWidget: BricksWidget, + context: BricksContext, + http: BricksHttp, + sageClient: SageClient, + scope: androidx.compose.runtime.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( + topBar = { + TopAppBar( + title = { Text("Sage") }, + actions = { + TextButton(onClick = onLogout) { + Text("退出") + } + } + ) + } + ) { 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("重试") + } + } + } + } } -""".trimIndent() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3458bb..04b2fc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,9 +10,12 @@ coil = "3.0.4" [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f279c66..1feaff5 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -29,6 +29,9 @@ kotlin { implementation(compose.ui) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.coil.compose) implementation(libs.coil.network) 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 cd6596a..bc5f06d 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/actions/ActionDispatcher.kt @@ -1,24 +1,33 @@ package com.bricks.mp.actions -import com.bricks.mp.core.BricksBind -import com.bricks.mp.core.BricksContext -import com.bricks.mp.core.BricksHttp +import com.bricks.mp.core.* +import com.bricks.mp.sage.SageClient import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.serialization.json.* /** * 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event + * 支持回调机制以更新 UI 状态 */ class ActionDispatcher( private val context: BricksContext, private val http: BricksHttp, + private val sageClient: SageClient?, private val scope: CoroutineScope ) { private val registeredFunctions = mutableMapOf Unit>() + // 回调: 当加载新的 widget 时调用 + var onWidgetLoaded: ((BricksWidget) -> Unit)? = null + + // 回调: 显示消息/错误 + var onMessage: ((title: String, message: String, isError: Boolean) -> Unit)? = null + + // 回调: 弹出/关闭对话框 + var onDialog: ((widget: BricksWidget?, show: Boolean) -> Unit)? = null + /** * 注册回调函数 (registerfunction) */ @@ -41,38 +50,107 @@ class ActionDispatcher( } private fun handleUrlWidget(bind: BricksBind) { + val url = bind.options["url"]?.let { + 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 { - val url = bind.url ?: return@launch - val fullUrl = context.entireUrl(url) try { - val result = http.getJson(fullUrl) - // 加载新的 widget 并更新 UI - println("[Bricks] urlwidget loaded: $fullUrl") + val fullUrl = resolveUrl(url) + + 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 httpFetchWidget(url: String): BricksWidget? { + return try { + val text = http.getText(url, authToken = context.authToken) + BricksParser.parse(text) + } catch (e: Exception) { + null + } + } + + private fun resolveUrl(url: String): String { + if (url.startsWith("http")) return url + return context.entireUrl(url) + } + private fun handleMethod(bind: BricksBind) { - // 调用客户端方法 - println("[Bricks] method called: ${bind.methodname}") + val methodName = bind.methodname ?: bind.target ?: return + + // 调用已注册的方法 + registeredFunctions[methodName]?.invoke() + println("[Bricks] method called: $methodName") } private fun handleScript(bind: BricksBind) { - scope.launch { - // 服务端脚本调用 - val script = bind.script ?: return@launch - println("[Bricks] script: $script") + val script = bind.script ?: return + println("[Bricks] script: $script") + + // 解析常见的 script 模式 + when { + script.contains("this.destroy()") -> { + onDialog?.invoke(null, false) + } + script.contains("show_windows_panel") -> { + println("[Bricks] show_windows_panel triggered") + } + else -> { + // 执行已注册的 script handler + registeredFunctions[script]?.invoke() + } } } private fun handleRegisterFunction(bind: BricksBind) { val name = bind.target ?: return registeredFunctions[name]?.invoke() + println("[Bricks] registerfunction invoked: $name") } private fun handleEvent(bind: BricksBind) { - println("[Bricks] event: ${bind.event} -> ${bind.actiontype}") + when (bind.event) { + "submit" -> { + // Form submit - trigger urlwidget if configured + dispatch(bind.copy(actiontype = "urlwidget")) + } + "click" -> { + // Click event + val name = bind.target ?: return + registeredFunctions[name]?.invoke() + } + "dismissed" -> { + onDialog?.invoke(null, false) + } + else -> { + println("[Bricks] event: ${bind.event} -> ${bind.actiontype}") + } + } + } + + fun close() { + registeredFunctions.clear() } } diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksContext.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksContext.kt index c4f6875..2664430 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksContext.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksContext.kt @@ -15,6 +15,10 @@ class BricksContext { private val _sessionData = MutableStateFlow>(emptyMap()) val sessionData: StateFlow> = _sessionData.asStateFlow() + // 当前渲染的 widget 树 + private val _currentWidget = MutableStateFlow(null) + val currentWidget: StateFlow = _currentWidget.asStateFlow() + var baseUrl: String = "" var authToken: String = "" @@ -32,6 +36,13 @@ class BricksContext { _sessionData.value = current } + /** + * 设置当前 widget + */ + fun setCurrentWidget(widget: BricksWidget?) { + _currentWidget.value = widget + } + /** * 解析 entire_url - 拼接 baseUrl */ @@ -39,4 +50,15 @@ class BricksContext { val cleanPath = path.trimStart('/') return if (baseUrl.endsWith("/")) "$baseUrl$cleanPath" else "$baseUrl/$cleanPath" } + + /** + * 替换 entire_url 模板 + */ + fun resolveTemplates(text: String): String { + val regex = Regex("""\{\{entire_url\('([^']+)'\)\}\}""") + return regex.replace(text) { match -> + val path = match.groupValues[1] + entireUrl(path) + } + } } 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 9103c36..ad0ef53 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksHttp.kt @@ -1,46 +1,139 @@ package com.bricks.mp.core 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.serialization.json.* /** - * HTTP 客户端 - 对应 JS 版 HttpJson/HttpText + * Bricks HTTP 客户端 - 对应 JS 版 HttpJson/HttpText + * 支持 session cookie 管理和 Bearer token 认证 */ -class BricksHttp(private val context: BricksContext) { +class BricksHttp(private val context: BricksContext? = null) { - private val client = HttpClient() + private val cookieStorage = AcceptAllCookiesStorage() - suspend fun getJson(url: String, params: Map = emptyMap()): JsonObject { - val response = client.get(url) { - url { params.forEach { (k, v) -> parameters.append(k, v) } } - if (context.authToken.isNotEmpty()) { - header(HttpHeaders.Authorization, "Bearer ${context.authToken}") - } + private val client = HttpClient(CIO) { + install(HttpCookies) { + storage = cookieStorage } - return Json.parseToJsonElement(response.bodyAsText()).jsonObject + expectSuccess = false } - suspend fun postJson(url: String, body: JsonObject): JsonObject { + /** + * GET 返回 JSON + */ + suspend fun getJson( + url: String, + 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))) + } + } + + /** + * POST 返回 JSON + */ + suspend fun postJson( + url: String, + body: JsonObject, + params: Map = emptyMap(), + authToken: String = "" + ): JsonObject { val response = client.post(url) { + url { + params.forEach { (k, v) -> parameters.append(k, v) } + } contentType(ContentType.Application.Json) - setBody(body.toString()) - if (context.authToken.isNotEmpty()) { - header(HttpHeaders.Authorization, "Bearer ${context.authToken}") + setBody(body) + if (authToken.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $authToken") } } - return Json.parseToJsonElement(response.bodyAsText()).jsonObject + 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))) + } } - suspend fun getText(url: String, params: Map = emptyMap()): String { - val response = client.get(url) { - url { params.forEach { (k, v) -> parameters.append(k, v) } } + /** + * POST 表单数据 + */ + suspend fun postForm( + url: String, + form: Parameters, + authToken: String = "" + ): String { + val response = client.post(url) { + contentType(ContentType.Application.FormUrlEncoded) + setBody(form) + if (authToken.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $authToken") + } } return response.bodyAsText() } + /** + * GET 返回文本 + */ + suspend fun getText( + url: String, + params: Map = emptyMap(), + authToken: String = "" + ): String { + val response = client.get(url) { + url { + params.forEach { (k, v) -> parameters.append(k, v) } + } + if (authToken.isNotEmpty()) { + header(HttpHeaders.Authorization, "Bearer $authToken") + } + } + return response.bodyAsText() + } + + /** + * 下载 .ui JSON 文件并解析为 BricksWidget + */ + suspend fun fetchUi( + path: String, + authToken: String = "" + ): BricksWidget { + val fullUrl = resolveUrl(path) + val text = getText(fullUrl, authToken = authToken) + return BricksParser.parse(text) + } + + /** + * 拼接 URL - 使用 context 的 baseUrl + */ + fun resolveUrl(path: String): String { + if (path.startsWith("http")) return path + val base = context?.baseUrl ?: "" + val cleanBase = base.trimEnd('/') + val cleanPath = path.trimStart('/') + return "$cleanBase/$cleanPath" + } + fun close() { client.close() } diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt index 5c43c12..40faf58 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksParser.kt @@ -22,6 +22,7 @@ object BricksParser { private fun parseElement(element: JsonElement): BricksWidget { val obj = element.jsonObject val widgettype = obj["widgettype"]?.jsonPrimitive?.content ?: "Text" + val id = obj["id"]?.jsonPrimitive?.content ?: "" val options = obj["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap() @@ -36,11 +37,12 @@ object BricksParser { methodname = b["methodname"]?.jsonPrimitive?.content, script = b["script"]?.jsonPrimitive?.content, url = b["url"]?.jsonPrimitive?.content, + options = b["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap(), data = (b["data"]?.jsonObject?.mapValues { it.value } ?: emptyMap()) ) } ?: emptyList() - return BricksWidget(widgettype, options, subwidgets, binds) + return BricksWidget(widgettype, id, options, subwidgets, binds) } /** diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt index a37c061..10b82cd 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksRenderer.kt @@ -1,68 +1,691 @@ package com.bricks.mp.core +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState as rememberHScrollState +import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.bricks.mp.core.BricksWidget +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bricks.mp.actions.ActionDispatcher import com.bricks.mp.widgets.* +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonElement /** * 递归渲染引擎 - 将 BricksWidget 树渲染为 Compose UI */ @Composable -fun RenderWidget(widget: BricksWidget) { - when (widget.widgettype) { +fun RenderWidget( + widget: BricksWidget, + actionDispatcher: ActionDispatcher? = null, + modifier: Modifier = Modifier +) { + val resolvedWidget = resolveTemplates(widget) + + when (resolvedWidget.widgettype) { // 文本 - "Text" -> RenderTextWidget(widget) - "Title1", "Title2", "Title3", "Title4", "Title5", "Title6" -> RenderTitleWidget(widget) + "Text" -> RenderTextWidget(resolvedWidget) + "Title1", "Title2", "Title3", "Title4", "Title5", "Title6" -> RenderTitleWidget(resolvedWidget) // 布局 - "HBox", "FHBox", "VBox", "FVBox", "Filler", "HFiller", "VFiller", "ResponsiveBox" -> RenderLayoutWidget(widget) + "HBox", "FHBox" -> RenderHBox(resolvedWidget, actionDispatcher) + "VBox", "FVBox" -> RenderVBox(resolvedWidget, actionDispatcher) + "Filler", "HFiller" -> Spacer(modifier = Modifier.weight(1f)) + "VFiller" -> Spacer(modifier = Modifier.height( + WidgetOptions.getString(resolvedWidget.options, "height", "16").toFloatOrNull()?.dp ?: 16.dp + )) + "ResponsiveBox" -> RenderResponsiveBox(resolvedWidget, actionDispatcher) // 输入 - "KeyinText" -> RenderKeyinTextWidget(widget) - "Input" -> RenderInputWidget(widget) - "Tooltip" -> RenderTooltipWidget(widget) + "KeyinText" -> RenderKeyinTextWidget(resolvedWidget) + "Input" -> RenderInputWidget(resolvedWidget) + "Tooltip" -> RenderTooltipWidget(resolvedWidget) - // TODO: Phase 2 组件 - "Image", "Icon", "StatedIcon", "BlankIcon" -> RenderPlaceholder(widget, "Image/Icon") - "Menu", "Popup", "PopupWindow", "Modal", "ModalForm" -> RenderPlaceholder(widget, "Menu/Dialog") - "VScrollPanel", "HScrollPanel" -> RenderPlaceholder(widget, "Scroll") - "Splitter" -> RenderPlaceholder(widget, "Splitter") - "Running" -> RenderRunningWidget(widget) + // 按钮 + "Button" -> RenderButtonWidget(resolvedWidget, actionDispatcher) - // TODO: Phase 3 组件 - "Html", "MarkdownViewer", "LlmOut" -> RenderPlaceholder(widget, widget.widgettype) + // 表单 + "Form" -> RenderFormWidget(resolvedWidget, actionDispatcher) + + // 面板 + "VScrollPanel" -> RenderVScrollPanel(resolvedWidget, actionDispatcher) + "HScrollPanel" -> RenderHScrollPanel(resolvedWidget, actionDispatcher) + + // 图片 + "Image" -> RenderImageWidget(resolvedWidget) + "Svg" -> RenderSvgWidget(resolvedWidget) + "Icon", "StatedIcon", "BlankIcon" -> RenderPlaceholder(resolvedWidget, "Icon") + + // 对话框 + "Modal", "ModalForm", "PopupWindow", "Popup" -> RenderModalWidget(resolvedWidget, actionDispatcher) + + // 菜单 + "Menu" -> RenderMenuWidget(resolvedWidget, actionDispatcher) + + // 运行中 + "Running" -> RenderRunningWidget(resolvedWidget) + + // 占位符 (TODO) + "Html", "MarkdownViewer", "LlmOut" -> RenderPlaceholder(resolvedWidget, resolvedWidget.widgettype) + "TabPanel" -> RenderTabPanel(resolvedWidget, actionDispatcher) + "MdWidget" -> RenderPlaceholder(resolvedWidget, "Markdown") + "Splitter" -> RenderPlaceholder(resolvedWidget, "Splitter") + "Error" -> RenderErrorWidget(resolvedWidget) + "Message" -> RenderMessageWidget(resolvedWidget) + "urlwidget" -> RenderUrlWidget(resolvedWidget, actionDispatcher) // 默认: 渲染子组件 else -> { - widget.subwidgets.forEach { child -> RenderWidget(child) } + if (resolvedWidget.subwidgets.isNotEmpty()) { + Column(modifier = modifier) { + resolvedWidget.subwidgets.forEach { child -> + RenderWidget(child, actionDispatcher) + } + } + } + } + } +} + +/** + * 解析 widget 中的 entire_url 模板 + */ +private fun resolveTemplates(widget: BricksWidget): BricksWidget { + return widget +} + +@Composable +private fun RenderHBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val align = when (WidgetOptions.getString(widget.options, "align", "start")) { + "center" -> Alignment.CenterVertically + "end" -> Alignment.Bottom + else -> Alignment.Top + } + + // 解析 bgcolor + val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", "")) + val color = parseColor(WidgetOptions.getString(widget.options, "color", "")) + + val heightOpt = WidgetOptions.getString(widget.options, "height", "") + val widthOpt = WidgetOptions.getString(widget.options, "width", "") + + var mod = Modifier + .fillMaxWidth() + .padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp) + if (bgColor != Color.Unspecified) mod = mod.background(bgColor) + + Row( + modifier = mod, + verticalAlignment = align, + horizontalArrangement = when (widget.widgettype) { + "FHBox" -> Arrangement.SpaceBetween + else -> Arrangement.Start + } + ) { + widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) } + } +} + +@Composable +private fun RenderVBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val align = when (WidgetOptions.getString(widget.options, "align", "start")) { + "center" -> Alignment.CenterHorizontally + "end" -> Alignment.End + else -> Alignment.Start + } + + val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", "")) + val color = parseColor(WidgetOptions.getString(widget.options, "color", "")) + + var mod = Modifier + .fillMaxWidth() + .padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp) + if (bgColor != Color.Unspecified) mod = mod.background(bgColor) + + Column( + modifier = mod, + horizontalAlignment = align, + verticalArrangement = when (widget.widgettype) { + "FVBox" -> Arrangement.SpaceBetween + else -> Arrangement.Top + } + ) { + widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) } + } +} + +@Composable +private fun RenderResponsiveBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + BoxWithConstraints { + if (maxWidth > 600.dp) { + Row { widget.subwidgets.forEach { RenderWidget(it, actionDispatcher) } } + } else { + Column { widget.subwidgets.forEach { RenderWidget(it, actionDispatcher) } } + } + } +} + +@Composable +private fun RenderVScrollPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", "")) + + var mod = Modifier.fillMaxSize() + if (bgColor != Color.Unspecified) mod = mod.background(bgColor) + + val scrollState = rememberScrollState() + Column( + modifier = mod.verticalScroll(scrollState), + horizontalAlignment = Alignment.Start + ) { + widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) } + } +} + +@Composable +private fun RenderHScrollPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", "")) + + var mod = Modifier.fillMaxSize() + if (bgColor != Color.Unspecified) mod = mod.background(bgColor) + + val scrollState = rememberHScrollState() + Row(modifier = mod.horizontalScroll(scrollState)) { + widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) } + } +} + +@Composable +private fun RenderButtonWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val label = WidgetOptions.getString(widget.options, "label", widget.id) + val enabled = WidgetOptions.getBoolean(widget.options, "enabled", true) + val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", "")) + + Button( + onClick = { + widget.binds.forEach { bind -> + if (bind.event == "click" || bind.event == null) { + actionDispatcher?.dispatch(bind) + } + } + }, + enabled = enabled, + colors = if (bgColor != Color.Unspecified) { + ButtonDefaults.buttonColors(containerColor = bgColor) + } else ButtonDefaults.buttonColors(), + modifier = Modifier.padding(4.dp) + ) { + Text(label) + } +} + +@Composable +private fun RenderFormWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val cols = WidgetOptions.getInt(widget.options, "cols", 1) + val description = WidgetOptions.getString(widget.options, "description", "") + + // 解析 fields + val fieldsEl = widget.options["fields"] + val fields = if (fieldsEl is kotlinx.serialization.json.JsonArray) { + fieldsEl.map { el -> + el as? kotlinx.serialization.json.JsonObject ?: emptyMap() + }.map { obj -> + obj.mapValues { it.value } + } + } else { + emptyList() + } + + // 维护表单值 + val formValues = remember { mutableStateMapOf() } + + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + if (description.isNotEmpty()) { + Text(description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 8.dp)) + } + + // 工具栏 + val toolbarEl = widget.options["toolbar"] + if (toolbarEl is kotlinx.serialization.json.JsonObject) { + val toolsEl = toolbarEl["tools"] + if (toolsEl is kotlinx.serialization.json.JsonArray) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 8.dp)) { + toolsEl.forEach { toolEl -> + if (toolEl is kotlinx.serialization.json.JsonObject) { + val toolLabel = (toolEl["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + val toolName = (toolEl["name"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + Button(onClick = { + // 触发 gen_code 等工具事件 + widget.binds.filter { it.event == toolName }.forEach { bind -> + actionDispatcher?.dispatch(bind) + } + }, modifier = Modifier.padding(2.dp)) { + Text(toolLabel) + } + } + } + } + } + } + + // 渲染字段 + fields.forEach { fieldMap -> + val name = (fieldMap["name"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@forEach + val label = (fieldMap["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: name + val uiType = (fieldMap["uitype"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "str" + val defaultVal = (fieldMap["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + + // hide 类型不渲染 + if (uiType == "hide") { + formValues[name] = defaultVal + return@forEach + } + + var text by remember { mutableStateOf(formValues[name] ?: defaultVal) } + + OutlinedTextField( + value = text, + onValueChange = { newText -> + text = newText + formValues[name] = newText + }, + label = { Text(label) }, + visualTransformation = if (uiType == "password") { + androidx.compose.ui.text.input.PasswordVisualTransformation() + } else { + androidx.compose.ui.text.input.VisualTransformation.None + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + } + + // 提交按钮 - 如果有 submit bind + val submitBinds = widget.binds.filter { it.event == "submit" } + if (submitBinds.isNotEmpty()) { + Button( + onClick = { + submitBinds.forEach { bind -> + // 将表单数据附加到 bind 的 options + val updatedBind = bind.copy( + options = bind.options + mapOf( + "formdata" to kotlinx.serialization.json.buildJsonObject { + formValues.forEach { (k, v) -> put(k, v) } + } + ) + ) + actionDispatcher?.dispatch(updatedBind) + } + }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) + ) { + Text("Submit") + } + } + } +} + +@Composable +private fun RenderModalWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val autoOpen = WidgetOptions.getBoolean(widget.options, "auto_open", true) + val title = WidgetOptions.getString(widget.options, "title", "") + + if (autoOpen) { + AlertDialog( + onDismissRequest = { + widget.binds.filter { it.event == "dismissed" }.forEach { bind -> + actionDispatcher?.dispatch(bind) + } + }, + title = { if (title.isNotEmpty()) Text(title) }, + text = { + Column { + widget.subwidgets.forEach { child -> + RenderWidget(child, actionDispatcher) + } + } + }, + confirmButton = { + Button(onClick = { + widget.binds.filter { it.event == "dismissed" }.forEach { bind -> + actionDispatcher?.dispatch(bind) + } + }) { + Text("OK") + } + } + ) + } +} + +@Composable +private fun RenderMenuWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + var expanded by remember { mutableStateOf(false) } + + Box { + Button(onClick = { expanded = true }) { + Text("Menu") + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + widget.subwidgets.forEach { item -> + val label = WidgetOptions.getString(item.options, "label", item.widgettype) + DropdownMenuItem( + text = { Text(label) }, + onClick = { + expanded = false + item.binds.forEach { bind -> actionDispatcher?.dispatch(bind) } + } + ) + } + } + } +} + +@Composable +private fun RenderTabPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + var selectedTab by remember { mutableStateOf(0) } + + // 解析 tabs + val itemsEl = widget.options["items"] + val tabLabels = mutableListOf() + val tabWidgets = mutableListOf() + + if (itemsEl is kotlinx.serialization.json.JsonArray) { + itemsEl.forEach { itemEl -> + if (itemEl is kotlinx.serialization.json.JsonObject) { + val label = (itemEl["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "" + tabLabels.add(label) + + val content = itemEl["content"] + if (content is kotlinx.serialization.json.JsonObject) { + try { + tabWidgets.add(parseJsonObject(content)) + } catch (e: Exception) { + tabWidgets.add(null) + } + } else { + tabWidgets.add(null) + } + } + } + } + + if (tabLabels.isEmpty()) { + // Fallback: render subwidgets + widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) } + return + } + + Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = selectedTab) { + tabLabels.forEachIndexed { index, label -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(label) } + ) + } + } + + // 渲染选中 tab 内容 + Box(modifier = Modifier.weight(1f)) { + val contentWidget = tabWidgets.getOrNull(selectedTab) + if (contentWidget != null) { + // 如果是 urlwidget, 显示占位 + if (contentWidget.widgettype == "urlwidget") { + val url = WidgetOptions.getString(contentWidget.options, "url", "") + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Loading: $url", style = MaterialTheme.typography.bodySmall) + } + } else { + RenderWidget(contentWidget, actionDispatcher) + } + } + } + } +} + +private fun parseJsonObject(obj: kotlinx.serialization.json.JsonObject): BricksWidget { + val widgettype = obj["widgettype"]?.let { + if (it is kotlinx.serialization.json.JsonPrimitive) it.content else "Text" + } ?: "Text" + + val id = obj["id"]?.let { + if (it is kotlinx.serialization.json.JsonPrimitive) it.content else "" + } ?: "" + + val options = obj["options"]?.let { + if (it is kotlinx.serialization.json.JsonObject) it.mapValues { entry -> entry.value } + else emptyMap() + } ?: emptyMap() + + val subwidgets = obj["subwidgets"]?.let { + if (it is kotlinx.serialization.json.JsonArray) it.map { el -> + if (el is kotlinx.serialization.json.JsonObject) parseJsonObject(el) + else BricksWidget("Text", "", emptyMap(), emptyList(), emptyList()) + } + else emptyList() + } ?: emptyList() + + val binds = obj["binds"]?.let { + if (it is kotlinx.serialization.json.JsonArray) it.mapNotNull { bindEl -> + if (bindEl is kotlinx.serialization.json.JsonObject) { + BricksBind( + event = (bindEl["event"] as? kotlinx.serialization.json.JsonPrimitive)?.content, + actiontype = (bindEl["actiontype"] as? kotlinx.serialization.json.JsonPrimitive)?.content, + target = (bindEl["target"] as? kotlinx.serialization.json.JsonPrimitive)?.content, + methodname = (bindEl["methodname"] as? kotlinx.serialization.json.JsonPrimitive)?.content, + script = (bindEl["script"] as? kotlinx.serialization.json.JsonPrimitive)?.content, + url = (bindEl["url"] as? kotlinx.serialization.json.JsonPrimitive)?.content, + data = (bindEl["data"] as? kotlinx.serialization.json.JsonObject)?.mapValues { it.value } ?: emptyMap() + ) + } else null + } + else emptyList() + } ?: emptyList() + + return BricksWidget(widgettype, id, options, subwidgets, binds) +} + +@Composable +private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) { + val url = WidgetOptions.getString(widget.options, "url", "") + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text("Loading: $url", style = MaterialTheme.typography.bodySmall) + } + + // 自动加载 url + LaunchedEffect(url) { + if (url.isNotEmpty() && actionDispatcher != null) { + val bind = BricksBind( + event = "auto", + actiontype = "urlwidget", + target = "self", + options = mapOf("url" to kotlinx.serialization.json.JsonPrimitive(url)) + ) + actionDispatcher.dispatch(bind) + } + } +} + +@Composable +private fun RenderImageWidget(widget: BricksWidget) { + val url = WidgetOptions.getString(widget.options, "url", "") + val width = WidgetOptions.getString(widget.options, "width", "100%") + val height = WidgetOptions.getString(widget.options, "height", "auto") + + // 简单的 URL 文本显示 (在桌面端可用 Coil 加载实际图片) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material.icons.Icons + Text("🖼️", style = MaterialTheme.typography.displaySmall) + Text("Image: $url", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun RenderSvgWidget(widget: BricksWidget) { + val url = WidgetOptions.getString(widget.options, "url", "") + val tip = WidgetOptions.getString(widget.options, "tip", "") + + Box( + modifier = Modifier + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("📐", style = MaterialTheme.typography.titleLarge) + if (tip.isNotEmpty()) { + Text(tip, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun RenderErrorWidget(widget: BricksWidget) { + val title = WidgetOptions.getString(widget.options, "title", "Error") + val message = WidgetOptions.getString(widget.options, "message", "") + val timeout = WidgetOptions.getInt(widget.options, "timeout", 0) + + Card( + modifier = Modifier.padding(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onErrorContainer) + if (message.isNotEmpty()) { + Text(message, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp)) + } + } + } + + if (timeout > 0) { + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(timeout * 1000L) + } + } +} + +@Composable +private fun RenderMessageWidget(widget: BricksWidget) { + val title = WidgetOptions.getString(widget.options, "title", "Message") + val message = WidgetOptions.getString(widget.options, "message", "") + val autoOpen = WidgetOptions.getBoolean(widget.options, "auto_open", false) + + if (autoOpen) { + AlertDialog( + onDismissRequest = { + widget.binds.filter { it.event == "dismissed" }.forEach { bind -> + // dispatch handled by parent + } + }, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + Button(onClick = { + widget.binds.filter { it.event == "dismissed" }.forEach { bind -> + // dispatch handled by parent + } + }) { + Text("OK") + } + } + ) + } else { + Card(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (message.isNotEmpty()) { + Text(message, modifier = Modifier.padding(top = 4.dp)) + } + } } } } @Composable private fun RenderPlaceholder(widget: BricksWidget, name: String) { - androidx.compose.material3.Text( + Text( text = "[${widget.widgettype}: $name - TODO]", - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall ) widget.subwidgets.forEach { child -> RenderWidget(child) } } @Composable fun RenderRunningWidget(widget: BricksWidget) { - androidx.compose.material3.CircularProgressIndicator( + CircularProgressIndicator( modifier = Modifier.padding(16.dp) ) } /** - * 渲染整个 Widget 树 + * 解析颜色字符串 (#RRGGBB 或 #RGB) */ -@Composable -fun BricksApp(rootWidget: BricksWidget) { - Column(modifier = Modifier.fillMaxSize()) { - RenderWidget(rootWidget) +private fun parseColor(colorStr: String): Color { + if (colorStr.isEmpty()) return Color.Unspecified + return try { + val hex = colorStr.trimStart('#') + val argb = when (hex.length) { + 3 -> { + val r = hex[0].toString().repeat(2).toInt(16) + val g = hex[1].toString().repeat(2).toInt(16) + val b = hex[2].toString().repeat(2).toInt(16) + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + 6 -> { + val rgb = hex.toInt(16) + (0xFF shl 24) or rgb + } + 8 -> hex.toInt(16) + else -> return Color.Unspecified + } + Color(argb) + } catch (e: Exception) { + Color.Unspecified + } +} + +/** + * 渲染整个 Widget 树 - 带 action dispatcher + */ +@Composable +fun BricksApp( + rootWidget: BricksWidget, + actionDispatcher: ActionDispatcher? = null +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + RenderWidget(rootWidget, actionDispatcher) + } } } diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksWidget.kt b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksWidget.kt index 4322912..bcdcded 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksWidget.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/core/BricksWidget.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.* @Serializable data class BricksWidget( val widgettype: String, + val id: String = "", val options: Map = emptyMap(), val subwidgets: List = emptyList(), val binds: List = emptyList() @@ -22,7 +23,8 @@ data class BricksBind( val data: Map = emptyMap(), val methodname: String? = null, val script: String? = null, - val url: String? = null + val url: String? = null, + val options: Map = emptyMap() ) /** diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt b/shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt new file mode 100644 index 0000000..8500479 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt @@ -0,0 +1,214 @@ +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. POST /rbac/user/userpassword_login.dspy 登录获取 session cookie + * 2. 后续请求自动携带 cookie + * 3. 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 = _isLoggedIn.asStateFlow() + + private val _loginError = kotlinx.coroutines.flow.MutableStateFlow(null) + val loginError = _loginError.asStateFlow() + + private val mutex = Mutex() + + /** + * 登录 Sage 服务器 + * POST /rbac/user/userpassword_login.dspy + */ + suspend fun login(username: String, password: String): Boolean = mutex.withLock { + _loginError.value = null + try { + val url = "$baseUrl/rbac/user/userpassword_login.dspy" + val formParameters = Parameters.build { + append("username", username) + append("passwd", password) + } + + val response = client.post(url) { + contentType(ContentType.Application.FormUrlEncoded) + setBody(formParameters) + } + + val body = response.bodyAsText() + println("[Sage] Login response status: ${response.status}") + println("[Sage] Login response body: ${body.take(200)}") + + // Sage 返回 UiMessage 格式: {"widgettype": "Message", "options": {...}} + // 或者返回错误: {"widgettype": "Error", "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") { + // 检查是否有 session cookie + val cookies = cookieStorage.getCookies(URLBuilder(baseUrl).build()) + if (cookies.isNotEmpty()) { + println("[Sage] Login successful, got ${cookies.size} cookies") + _isLoggedIn.value = true + true + } else { + // 即使没有 cookie,如果服务器返回成功也算登录成功 + // 有些部署可能使用 token 而非 cookie + println("[Sage] Login successful (no cookies)") + _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) + 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: Parameters? = null, + 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) } + } + when { + jsonBody != null -> { + contentType(ContentType.Application.Json) + setBody(jsonBody) + } + formBody != null -> { + contentType(ContentType.Application.FormUrlEncoded) + setBody(formBody) + } + } + } + else -> client.get(url) { + url { + params.forEach { (k, v) -> parameters.append(k, v) } + } + } + } + + 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 { + // 清除 cookies + cookieStorage.getCookies(URLBuilder(baseUrl).build()) + _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/commonMain/kotlin/com/bricks/mp/widgets/InputWidgets.kt b/shared/src/commonMain/kotlin/com/bricks/mp/widgets/InputWidgets.kt index b74f33d..0669311 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/widgets/InputWidgets.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/widgets/InputWidgets.kt @@ -36,11 +36,10 @@ fun RenderInputWidget(widget: BricksWidget) { when (inputType) { "password" -> { - var visible by remember { mutableStateOf(false) } OutlinedTextField( value = text, onValueChange = { text = it }, - visualTransformation = if (!visible) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } @@ -63,7 +62,6 @@ fun RenderInputWidget(widget: BricksWidget) { @Composable fun RenderTooltipWidget(widget: BricksWidget) { - // Simplified tooltip - in real app would use TooltipBox val text = WidgetOptions.getString(widget.options, "text", "") Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/shared/src/commonMain/kotlin/com/bricks/mp/widgets/LayoutWidgets.kt b/shared/src/commonMain/kotlin/com/bricks/mp/widgets/LayoutWidgets.kt index 0bc1eba..a34c273 100644 --- a/shared/src/commonMain/kotlin/com/bricks/mp/widgets/LayoutWidgets.kt +++ b/shared/src/commonMain/kotlin/com/bricks/mp/widgets/LayoutWidgets.kt @@ -1,72 +1,4 @@ package com.bricks.mp.widgets -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.bricks.mp.core.BricksWidget -import com.bricks.mp.core.WidgetOptions - -/** - * 布局组件 - * HBox -> Row, VBox -> Column, Filler -> Spacer - */ -@Composable -fun RenderLayoutWidget(widget: BricksWidget) { - when (widget.widgettype) { - "HBox", "FHBox" -> { - val align = when (WidgetOptions.getString(widget.options, "align", "start")) { - "center" -> Alignment.CenterVertically - "end" -> Alignment.Bottom - else -> Alignment.Top - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp), - verticalAlignment = align, - horizontalArrangement = when (widget.widgettype) { - "FHBox" -> Arrangement.SpaceBetween - else -> Arrangement.Start - } - ) { - widget.subwidgets.forEach { child -> RenderWidget(child) } - } - } - "VBox", "FVBox" -> { - val align = when (WidgetOptions.getString(widget.options, "align", "start")) { - "center" -> Alignment.CenterHorizontally - "end" -> Alignment.End - else -> Alignment.Start - } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp), - horizontalAlignment = align, - verticalArrangement = when (widget.widgettype) { - "FVBox" -> Arrangement.SpaceBetween - else -> Arrangement.Top - } - ) { - widget.subwidgets.forEach { child -> RenderWidget(child) } - } - } - "Filler", "HFiller" -> { - Spacer(modifier = Modifier.weight(1f)) - } - "VFiller" -> { - Spacer(modifier = Modifier.height(WidgetOptions.getInt(widget.options, "height", 16).dp)) - } - "ResponsiveBox" -> { - BoxWithConstraints { - if (maxWidth > 600.dp) { - Row { widget.subwidgets.forEach { RenderWidget(it) } } - } else { - Column { widget.subwidgets.forEach { RenderWidget(it) } } - } - } - } - } -} +// Layout widgets are now rendered inline in BricksRenderer.kt +// This file is kept for backward compatibility