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
This commit is contained in:
yumoqing 2026-05-18 08:42:34 +08:00
parent b9c585699c
commit 82c2b89ecd
13 changed files with 1455 additions and 205 deletions

15
.gitignore vendored Normal file
View File

@ -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/

View File

@ -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<com.bricks.mp.core.BricksWidget?>(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<BricksWidget?>(null) }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(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
}
}) {
Text("Parse & Render")
)
}
},
onLoginError = { error ->
errorMessage = error
}
// 渲染区
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")
// 主应用界面
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
}
)
}
}
)
}
}
// 错误提示
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<String?>(null) }
var baseUrl by remember { mutableStateOf(SageClient.DEFAULT_BASE_URL) }
val scope = rememberCoroutineScope()
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo / Title
Text(
text = "Sage",
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "AI 平台",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 32.dp)
)
// Server URL
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text("服务器地址") },
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
// Username
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
singleLine = true
)
// Password
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
// Login button
Button(
onClick = {
scope.launch {
isLoading = true
loginError = null
sageClient.baseUrl = baseUrl
val success = sageClient.login(username, password)
if (success) {
onLoginSuccess()
} else {
loginError = sageClient.loginError.value ?: "登录失败"
onLoginError(loginError!!)
}
isLoading = false
}
},
{
"widgettype": "Text",
"options": {
"text": "Cross-platform JSON-driven UI"
}
},
{
"widgettype": "KeyinText",
"options": {
"placeholder": "Type here...",
"label": "Input"
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
Text("登录", fontSize = 16.sp)
}
}
// Error message
loginError?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp),
fontSize = 14.sp
)
}
}
}
}
/**
* 主应用界面
*/
@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()

View File

@ -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" }

View File

@ -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)

View File

@ -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<String, () -> 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
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
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) {
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()
}
}

View File

@ -15,6 +15,10 @@ class BricksContext {
private val _sessionData = MutableStateFlow<Map<String, Any>>(emptyMap())
val sessionData: StateFlow<Map<String, Any>> = _sessionData.asStateFlow()
// 当前渲染的 widget 树
private val _currentWidget = MutableStateFlow<BricksWidget?>(null)
val currentWidget: StateFlow<BricksWidget?> = _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)
}
}
}

View File

@ -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<String, String> = emptyMap()): JsonObject {
private val client = HttpClient(CIO) {
install(HttpCookies) {
storage = cookieStorage
}
expectSuccess = false
}
/**
* GET 返回 JSON
*/
suspend fun getJson(
url: String,
params: Map<String, String> = emptyMap(),
authToken: String = ""
): 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}")
url {
params.forEach { (k, v) -> parameters.append(k, v) }
}
if (authToken.isNotEmpty()) {
header(HttpHeaders.Authorization, "Bearer $authToken")
}
}
return Json.parseToJsonElement(response.bodyAsText()).jsonObject
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)))
}
}
suspend fun postJson(url: String, body: JsonObject): JsonObject {
/**
* POST 返回 JSON
*/
suspend fun postJson(
url: String,
body: JsonObject,
params: Map<String, String> = 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<String, String> = 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<String, String> = 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()
}

View File

@ -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)
}
/**

View File

@ -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<String, kotlinx.serialization.json.JsonElement>()
}.map { obj ->
obj.mapValues { it.value }
}
} else {
emptyList()
}
// 维护表单值
val formValues = remember { mutableStateMapOf<String, String>() }
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<String>()
val tabWidgets = mutableListOf<BricksWidget?>()
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)
*/
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) {
fun BricksApp(
rootWidget: BricksWidget,
actionDispatcher: ActionDispatcher? = null
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
RenderWidget(rootWidget)
RenderWidget(rootWidget, actionDispatcher)
}
}
}

View File

@ -9,6 +9,7 @@ import kotlinx.serialization.json.*
@Serializable
data class BricksWidget(
val widgettype: String,
val id: String = "",
val options: Map<String, JsonElement> = emptyMap(),
val subwidgets: List<BricksWidget> = emptyList(),
val binds: List<BricksBind> = emptyList()
@ -22,7 +23,8 @@ data class BricksBind(
val data: Map<String, JsonElement> = emptyMap(),
val methodname: String? = null,
val script: String? = null,
val url: String? = null
val url: String? = null,
val options: Map<String, JsonElement> = emptyMap()
)
/**

View File

@ -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<String?>(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<BricksWidget> = mutex.withLock {
try {
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
println("[Sage] Fetching UI: $url")
val response = client.get(url)
val body = response.bodyAsText()
if (!response.status.isSuccess()) {
return@withLock Result.failure(Exception("HTTP ${response.status.value}: ${body.take(200)}"))
}
val widget = BricksParser.parse(body)
Result.success(widget)
} catch (e: Exception) {
println("[Sage] Fetch UI error: ${e.message}")
Result.failure(e)
}
}
/**
* 获取 .dspy API 返回的 JSON
*/
suspend fun fetchApi(
path: String,
params: Map<String, String> = emptyMap(),
method: String = "GET",
formBody: Parameters? = null,
jsonBody: JsonObject? = null
): Result<JsonObject> = mutex.withLock {
try {
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
val response = when (method.uppercase()) {
"POST" -> client.post(url) {
url {
params.forEach { (k, v) -> parameters.append(k, v) }
}
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<String> = mutex.withLock {
try {
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
val response = client.get(url)
Result.success(response.bodyAsText())
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 退出登录
*/
suspend fun logout() = mutex.withLock {
try {
// 清除 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()
}
}

View File

@ -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)
}

View File

@ -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