yumoqing 82c2b89ecd 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
2026-05-18 08:42:34 +08:00

367 lines
12 KiB
Kotlin

package com.bricks
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.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.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 {
val sageClient = remember { SageClient() }
val context = remember { BricksContext() }
val http = remember { BricksHttp(context) }
val scope = rememberCoroutineScope()
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
)
}
) {
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
}
)
} 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
}
)
}
}
)
}
}
// 错误提示
errorMessage?.let { msg ->
LaunchedEffect(msg) {
// 可以通过 dialog 显示错误
println("[Sage] Error: $msg")
}
}
}
}
/**
* 登录界面
*/
@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
}
},
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("重试")
}
}
}
}
}