- 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
367 lines
12 KiB
Kotlin
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("重试")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|