feat: add realtime mobile sync dashboard
This commit is contained in:
@@ -210,6 +210,8 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
安卓端当前包含:
|
||||
|
||||
- SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度
|
||||
- 事件流断开时自动降级轮询,尽量保持跨端数据连续同步
|
||||
- 会话创建、切换、持续对话
|
||||
- 任务分组查看、暂停、恢复、取消、重排
|
||||
- 审批查看与批准/拒绝
|
||||
@@ -217,6 +219,12 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
- 绑定新设备并生成启动命令
|
||||
- 云端 Boss 地址切换与重排入口
|
||||
|
||||
同步模型说明:
|
||||
|
||||
- 电脑端 Web 控制台已经使用 SSE 实时事件流
|
||||
- 安卓端现在也使用 SSE,并在断线时自动重连和降级轮询
|
||||
- 手机和电脑都直接读写同一套 Boss control plane 状态,所以项目、消息、审批和任务进度会尽量保持高同步
|
||||
|
||||
一键本地 demo:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package site.hyzq.bossandroid.network
|
||||
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.BufferedSource
|
||||
import site.hyzq.bossandroid.model.BossEvent
|
||||
|
||||
enum class SyncConnectionState {
|
||||
CONNECTING,
|
||||
LIVE,
|
||||
RECONNECTING,
|
||||
}
|
||||
|
||||
class BossRealtimeSync(
|
||||
private val client: OkHttpClient = OkHttpClient(),
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
},
|
||||
) {
|
||||
suspend fun run(
|
||||
baseUrl: String,
|
||||
onStatusChanged: (SyncConnectionState) -> Unit,
|
||||
onEvent: (BossEvent) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
var firstAttempt = true
|
||||
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
onStatusChanged(if (firstAttempt) SyncConnectionState.CONNECTING else SyncConnectionState.RECONNECTING)
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("${normalizeBaseUrl(baseUrl)}/api/events/stream")
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("SSE connection failed: ${response.code}")
|
||||
}
|
||||
|
||||
val source = response.body?.source() ?: throw IOException("Empty event stream response")
|
||||
onStatusChanged(SyncConnectionState.LIVE)
|
||||
readStream(source, onEvent)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) {
|
||||
throw error
|
||||
}
|
||||
onStatusChanged(SyncConnectionState.RECONNECTING)
|
||||
delay(RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
firstAttempt = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStream(
|
||||
source: BufferedSource,
|
||||
onEvent: (BossEvent) -> Unit,
|
||||
) {
|
||||
val payloadLines = mutableListOf<String>()
|
||||
|
||||
while (!source.exhausted()) {
|
||||
val rawLine = source.readUtf8Line() ?: break
|
||||
when {
|
||||
rawLine.startsWith(":") -> Unit
|
||||
rawLine.isBlank() -> {
|
||||
if (payloadLines.isNotEmpty()) {
|
||||
val event = json.decodeFromString<BossEvent>(payloadLines.joinToString("\n"))
|
||||
onEvent(event)
|
||||
payloadLines.clear()
|
||||
}
|
||||
}
|
||||
|
||||
rawLine.startsWith("data:") -> {
|
||||
payloadLines += rawLine.removePrefix("data:").trimStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeBaseUrl(input: String): String {
|
||||
val trimmed = input.trim().trimEnd('/')
|
||||
return if (trimmed.isBlank()) {
|
||||
BossApi.DEFAULT_BASE_URL
|
||||
} else if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
trimmed
|
||||
} else {
|
||||
"http://$trimmed"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RETRY_DELAY_MS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package site.hyzq.bossandroid.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -76,6 +77,7 @@ import site.hyzq.bossandroid.model.Message
|
||||
import site.hyzq.bossandroid.model.Session
|
||||
import site.hyzq.bossandroid.model.TaskItem
|
||||
import site.hyzq.bossandroid.model.WorkerNode
|
||||
import site.hyzq.bossandroid.network.SyncConnectionState
|
||||
|
||||
private val TaskGroups = listOf(
|
||||
"进行中" to listOf("assigned", "running"),
|
||||
@@ -111,7 +113,7 @@ fun BossApp(
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Boss 主控")
|
||||
Text(
|
||||
text = topBarSubtitle(uiState, selectedSession, selectedWorker),
|
||||
text = "${syncStatusShortLabel(uiState.syncConnectionState)} · ${topBarSubtitle(uiState, selectedSession, selectedWorker)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -162,6 +164,7 @@ fun BossApp(
|
||||
selectedSession = selectedSession,
|
||||
selectedWorker = selectedWorker,
|
||||
onSelectSession = viewModel::selectSession,
|
||||
onSelectWorker = viewModel::selectWorker,
|
||||
onSelectTab = viewModel::selectConversationTab,
|
||||
onCreateSession = viewModel::createSession,
|
||||
onSendMessage = viewModel::sendMessage,
|
||||
@@ -202,6 +205,7 @@ private fun ConversationsScreen(
|
||||
selectedSession: Session?,
|
||||
selectedWorker: WorkerNode?,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onSelectWorker: (String?) -> Unit,
|
||||
onSelectTab: (ConversationTab) -> Unit,
|
||||
onCreateSession: (String) -> Unit,
|
||||
onSendMessage: (String) -> Unit,
|
||||
@@ -233,6 +237,7 @@ private fun ConversationsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CreateSessionCard(onCreateSession = onCreateSession)
|
||||
GlobalSyncCard(uiState = uiState)
|
||||
SessionSelector(
|
||||
sessions = uiState.sessions,
|
||||
selectedSessionId = uiState.selectedSessionId,
|
||||
@@ -248,20 +253,16 @@ private fun ConversationsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedSession == null) {
|
||||
EmptyStateCard(
|
||||
title = "还没有可用会话",
|
||||
body = "先创建一个项目会话,然后在这里持续对话、改需求、审批和看进度。",
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (uiState.conversationTab != ConversationTab.OVERVIEW) {
|
||||
selectedSession?.let { session ->
|
||||
SessionSummaryCard(
|
||||
session = selectedSession,
|
||||
session = session,
|
||||
workerCount = uiState.workers.size,
|
||||
onArchive = onArchiveSession,
|
||||
onRestore = onRestoreSession,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TabRow(selectedTabIndex = uiState.conversationTab.ordinal) {
|
||||
ConversationTab.entries.forEach { tab ->
|
||||
@@ -271,6 +272,7 @@ private fun ConversationsScreen(
|
||||
text = {
|
||||
Text(
|
||||
text = when (tab) {
|
||||
ConversationTab.OVERVIEW -> "总览"
|
||||
ConversationTab.CHAT -> "对话"
|
||||
ConversationTab.TASKS -> "任务"
|
||||
ConversationTab.APPROVALS -> "审批"
|
||||
@@ -282,13 +284,32 @@ private fun ConversationsScreen(
|
||||
}
|
||||
|
||||
when (uiState.conversationTab) {
|
||||
ConversationTab.CHAT -> ChatTab(
|
||||
ConversationTab.OVERVIEW -> OverviewTab(
|
||||
uiState = uiState,
|
||||
onSelectSession = onSelectSession,
|
||||
onSelectWorker = onSelectWorker,
|
||||
)
|
||||
|
||||
ConversationTab.CHAT -> if (selectedSession == null) {
|
||||
EmptyStateCard(
|
||||
title = "先选择一个项目",
|
||||
body = "总览里能看到所有客户端的对话;如果要继续某个项目,请先选中它。",
|
||||
)
|
||||
} else {
|
||||
ChatTab(
|
||||
sessionMessages = sessionMessages,
|
||||
sessionEvents = sessionEvents,
|
||||
onSendMessage = onSendMessage,
|
||||
)
|
||||
}
|
||||
|
||||
ConversationTab.TASKS -> TasksTab(
|
||||
ConversationTab.TASKS -> if (selectedSession == null) {
|
||||
EmptyStateCard(
|
||||
title = "先选择一个项目",
|
||||
body = "选中项目后,这里会展示它的任务树和实时进度。",
|
||||
)
|
||||
} else {
|
||||
TasksTab(
|
||||
tasks = filteredTasks,
|
||||
workers = uiState.workers,
|
||||
onPauseTask = onPauseTask,
|
||||
@@ -296,8 +317,15 @@ private fun ConversationsScreen(
|
||||
onCancelTask = onCancelTask,
|
||||
onRequeueTask = onRequeueTask,
|
||||
)
|
||||
}
|
||||
|
||||
ConversationTab.APPROVALS -> ApprovalsTab(
|
||||
ConversationTab.APPROVALS -> if (selectedSession == null) {
|
||||
EmptyStateCard(
|
||||
title = "先选择一个项目",
|
||||
body = "选中项目后,这里会展示对应的审批项。",
|
||||
)
|
||||
} else {
|
||||
ApprovalsTab(
|
||||
approvals = sessionApprovals,
|
||||
tasks = sessionTasks,
|
||||
onApprove = onApprove,
|
||||
@@ -306,6 +334,7 @@ private fun ConversationsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DevicesScreen(
|
||||
@@ -320,6 +349,9 @@ private fun DevicesScreen(
|
||||
val relatedTasks = remember(uiState.tasks) {
|
||||
uiState.tasks.associateBy { it.id }
|
||||
}
|
||||
val relatedSessions = remember(uiState.sessions) {
|
||||
uiState.sessions.associateBy { it.id }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -354,9 +386,11 @@ private fun DevicesScreen(
|
||||
SectionHeading("已绑定设备")
|
||||
uiState.workers.forEach { worker ->
|
||||
val task = worker.currentTaskId?.let { relatedTasks[it] }
|
||||
val session = task?.let { relatedSessions[it.sessionId] }
|
||||
WorkerCard(
|
||||
worker = worker,
|
||||
currentTask = task,
|
||||
currentSession = session,
|
||||
selected = worker.id == uiState.selectedWorkerId,
|
||||
onSelect = { onSelectWorker(worker.id) },
|
||||
onMarkOffline = { onMarkOffline(worker.id) },
|
||||
@@ -419,10 +453,11 @@ private fun SettingsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("系统状态", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
MetricRow("连接状态", if (uiState.isRefreshing) "同步中" else "在线")
|
||||
MetricRow("同步模式", syncStatusShortLabel(uiState.syncConnectionState))
|
||||
MetricRow("会话数", uiState.health?.sessions?.toString() ?: uiState.sessions.size.toString())
|
||||
MetricRow("设备数", uiState.health?.workers?.toString() ?: uiState.workers.size.toString())
|
||||
MetricRow("最后同步", uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步")
|
||||
MetricRow("最近事件", uiState.lastEventType ?: "暂无")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onReconcile) {
|
||||
Icon(Icons.Outlined.SyncAlt, contentDescription = null)
|
||||
@@ -440,7 +475,7 @@ private fun SettingsScreen(
|
||||
) {
|
||||
Text("APP 范围", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"这个安卓版本已经覆盖了主控对话、设备绑定、设备切换、任务查看、审批处理和主控地址切换。下一步可以继续补推送通知、SSE 实时流和扫码绑定。",
|
||||
"这个安卓版本现在会优先走 SSE 实时同步,自动刷新所有客户端的项目、对话和任务进度;当事件流断开时,会自动降级轮询保证数据继续同步。",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -448,6 +483,164 @@ private fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlobalSyncCard(
|
||||
uiState: BossUiState,
|
||||
) {
|
||||
val activeTaskCount = uiState.tasks.count { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("全局同步", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
syncStatusLongLabel(uiState.syncConnectionState),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
StatusChip(
|
||||
label = syncStatusShortLabel(uiState.syncConnectionState),
|
||||
tone = syncTone(uiState.syncConnectionState),
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
AssistChip(onClick = {}, label = { Text("${uiState.sessions.size} 个项目") })
|
||||
AssistChip(onClick = {}, label = { Text("${uiState.workers.size} 台客户端") })
|
||||
AssistChip(onClick = {}, label = { Text("$activeTaskCount 个活跃任务") })
|
||||
}
|
||||
|
||||
Text(
|
||||
"最近事件:${uiState.lastEventType ?: "等待新事件"}",
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Text(
|
||||
"最后同步:${uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.9f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewTab(
|
||||
uiState: BossUiState,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onSelectWorker: (String?) -> Unit,
|
||||
) {
|
||||
val messagesBySession = remember(uiState.messages) {
|
||||
uiState.messages.groupBy { it.sessionId }
|
||||
}
|
||||
val tasksBySession = remember(uiState.tasks) {
|
||||
uiState.tasks.groupBy { it.sessionId }
|
||||
}
|
||||
val workerById = remember(uiState.workers) {
|
||||
uiState.workers.associateBy { it.id }
|
||||
}
|
||||
val activeTasks = remember(uiState.tasks) {
|
||||
uiState.tasks.filter { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
|
||||
}
|
||||
val recentMessages = remember(uiState.messages) {
|
||||
uiState.messages.sortedByDescending { it.createdAt }.take(12)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SectionHeading("客户端实时视图")
|
||||
if (uiState.workers.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
title = "还没有在线客户端",
|
||||
body = "绑定电脑端 worker 后,这里会自动显示每台设备当前正在处理的项目和任务。",
|
||||
)
|
||||
} else {
|
||||
uiState.workers.forEach { worker ->
|
||||
val task = worker.currentTaskId?.let { taskId -> uiState.tasks.firstOrNull { it.id == taskId } }
|
||||
val session = task?.let { uiState.sessions.firstOrNull { sessionItem -> sessionItem.id == it.sessionId } }
|
||||
ClientActivityCard(
|
||||
worker = worker,
|
||||
task = task,
|
||||
session = session,
|
||||
onSelectWorker = { onSelectWorker(worker.id) },
|
||||
onSelectSession = { session?.id?.let(onSelectSession) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeading("所有项目")
|
||||
if (uiState.sessions.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
title = "还没有项目",
|
||||
body = "你可以在手机上创建,也可以在电脑入口创建。Boss 会自动把它们同步到这里。",
|
||||
)
|
||||
} else {
|
||||
uiState.sessions.forEach { session ->
|
||||
val recentMessage = messagesBySession[session.id]?.maxByOrNull { it.createdAt }
|
||||
val sessionTasks = tasksBySession[session.id].orEmpty()
|
||||
val sessionWorkerNames = sessionTasks
|
||||
.mapNotNull { task -> task.assignedWorkerId?.let(workerById::get)?.name }
|
||||
.distinct()
|
||||
ProjectOverviewCard(
|
||||
session = session,
|
||||
recentMessage = recentMessage,
|
||||
activeTaskCount = sessionTasks.count {
|
||||
it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval")
|
||||
},
|
||||
workerNames = sessionWorkerNames,
|
||||
onSelectSession = { onSelectSession(session.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeading("最近对话")
|
||||
if (recentMessages.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
title = "还没有对话",
|
||||
body = "手机、Web 和命令行入口发出的消息都会汇总到这里。",
|
||||
)
|
||||
} else {
|
||||
recentMessages.forEach { message ->
|
||||
val session = uiState.sessions.firstOrNull { it.id == message.sessionId }
|
||||
GlobalMessageCard(
|
||||
message = message,
|
||||
sessionTitle = session?.title ?: message.sessionId,
|
||||
onSelectSession = { onSelectSession(message.sessionId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeading("活跃任务")
|
||||
if (activeTasks.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
title = "目前没有活跃任务",
|
||||
body = "一旦任何电脑端开始跑任务,进度会实时刷新到这里。",
|
||||
)
|
||||
} else {
|
||||
activeTasks.forEach { task ->
|
||||
TaskCard(
|
||||
task = task,
|
||||
worker = task.assignedWorkerId?.let(workerById::get),
|
||||
onPauseTask = {},
|
||||
onResumeTask = {},
|
||||
onCancelTask = {},
|
||||
onRequeueTask = {},
|
||||
actionsEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateSessionCard(
|
||||
onCreateSession: (String) -> Unit,
|
||||
@@ -816,6 +1009,7 @@ private fun BindDeviceCard(
|
||||
private fun WorkerCard(
|
||||
worker: WorkerNode,
|
||||
currentTask: TaskItem?,
|
||||
currentSession: Session?,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onMarkOffline: () -> Unit,
|
||||
@@ -858,6 +1052,12 @@ private fun WorkerCard(
|
||||
"当前任务:${it.title}"
|
||||
} ?: "当前任务:空闲",
|
||||
)
|
||||
if (currentSession != null) {
|
||||
Text(
|
||||
"当前项目:${currentSession.title}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -875,6 +1075,158 @@ private fun WorkerCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClientActivityCard(
|
||||
worker: WorkerNode,
|
||||
task: TaskItem?,
|
||||
session: Session?,
|
||||
onSelectWorker: () -> Unit,
|
||||
onSelectSession: () -> Unit,
|
||||
) {
|
||||
val health = workerHealth(worker)
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectWorker() },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(worker.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text("${worker.os} · ${health.label}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
StatusChip(label = health.label, tone = health.tone)
|
||||
}
|
||||
|
||||
Text("当前项目:${session?.title ?: "暂无"}")
|
||||
Text("当前任务:${task?.title ?: "空闲"}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(onClick = onSelectWorker) {
|
||||
Text("看这台设备")
|
||||
}
|
||||
if (session != null) {
|
||||
TextButton(onClick = onSelectSession) {
|
||||
Text("打开项目")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectOverviewCard(
|
||||
session: Session,
|
||||
recentMessage: Message?,
|
||||
activeTaskCount: Int,
|
||||
workerNames: List<String>,
|
||||
onSelectSession: () -> Unit,
|
||||
) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectSession() },
|
||||
colors = CardDefaults.outlinedCardColors(
|
||||
containerColor = if (session.status == "archived") {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(session.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text("最近更新 ${formatRelative(session.updatedAt)}", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
StatusChip(
|
||||
label = if (session.status == "archived") "已归档" else "活跃",
|
||||
tone = session.status,
|
||||
)
|
||||
}
|
||||
|
||||
if (session.activeObjective.isNotBlank()) {
|
||||
Text("当前目标:${session.activeObjective}")
|
||||
}
|
||||
if (recentMessage != null) {
|
||||
Text(
|
||||
"最近对话:${recentMessage.content}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
AssistChip(onClick = {}, label = { Text("$activeTaskCount 个活跃任务") })
|
||||
if (workerNames.isNotEmpty()) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
label = { Text(workerNames.joinToString(" · ").take(28)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlobalMessageCard(
|
||||
message: Message,
|
||||
sessionTitle: String,
|
||||
onSelectSession: () -> Unit,
|
||||
) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelectSession() },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(sessionTitle, fontWeight = FontWeight.SemiBold)
|
||||
Text(formatClock(message.createdAt), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text(
|
||||
"${roleLabel(message.role)} · ${message.channel}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
message.content,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageCard(
|
||||
message: Message,
|
||||
@@ -939,6 +1291,7 @@ private fun TaskCard(
|
||||
onResumeTask: (String) -> Unit,
|
||||
onCancelTask: (String) -> Unit,
|
||||
onRequeueTask: (String) -> Unit,
|
||||
actionsEnabled: Boolean = true,
|
||||
) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
@@ -976,6 +1329,7 @@ private fun TaskCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (actionsEnabled) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
when (task.status) {
|
||||
"paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") }
|
||||
@@ -1000,6 +1354,7 @@ private fun TaskCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApprovalCard(
|
||||
@@ -1181,12 +1536,36 @@ private fun topBarSubtitle(
|
||||
selectedWorker: WorkerNode?,
|
||||
): String {
|
||||
return when (uiState.section) {
|
||||
MainSection.CONVERSATIONS -> selectedSession?.title ?: "在手机上直接和 Boss 对话"
|
||||
MainSection.CONVERSATIONS -> selectedSession?.title ?: "查看所有客户端的项目与对话"
|
||||
MainSection.DEVICES -> selectedWorker?.let { "设备焦点:${it.name}" } ?: "绑定、切换和检查设备"
|
||||
MainSection.SETTINGS -> "云端地址、健康状态与重排"
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncStatusShortLabel(state: SyncConnectionState): String {
|
||||
return when (state) {
|
||||
SyncConnectionState.CONNECTING -> "连接中"
|
||||
SyncConnectionState.LIVE -> "实时同步"
|
||||
SyncConnectionState.RECONNECTING -> "重连中"
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncStatusLongLabel(state: SyncConnectionState): String {
|
||||
return when (state) {
|
||||
SyncConnectionState.CONNECTING -> "正在连接实时事件流,准备订阅电脑端和手机端的所有变化。"
|
||||
SyncConnectionState.LIVE -> "项目、对话、审批和任务进度正在通过事件流实时刷新。"
|
||||
SyncConnectionState.RECONNECTING -> "实时事件流暂时中断,系统正在重连,并且用轮询保持同步。"
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncTone(state: SyncConnectionState): String {
|
||||
return when (state) {
|
||||
SyncConnectionState.LIVE -> "live"
|
||||
SyncConnectionState.CONNECTING -> "queued"
|
||||
SyncConnectionState.RECONNECTING -> "lagging"
|
||||
}
|
||||
}
|
||||
|
||||
private fun roleLabel(role: String): String {
|
||||
return when (role) {
|
||||
"user" -> "你"
|
||||
|
||||
@@ -24,6 +24,8 @@ import site.hyzq.bossandroid.model.Session
|
||||
import site.hyzq.bossandroid.model.TaskItem
|
||||
import site.hyzq.bossandroid.model.WorkerNode
|
||||
import site.hyzq.bossandroid.network.BossApi
|
||||
import site.hyzq.bossandroid.network.BossRealtimeSync
|
||||
import site.hyzq.bossandroid.network.SyncConnectionState
|
||||
|
||||
enum class MainSection {
|
||||
CONVERSATIONS,
|
||||
@@ -32,6 +34,7 @@ enum class MainSection {
|
||||
}
|
||||
|
||||
enum class ConversationTab {
|
||||
OVERVIEW,
|
||||
CHAT,
|
||||
TASKS,
|
||||
APPROVALS,
|
||||
@@ -51,7 +54,7 @@ data class GeneratedWorkerCommand(
|
||||
data class BossUiState(
|
||||
val baseUrl: String = BossApi.DEFAULT_BASE_URL,
|
||||
val section: MainSection = MainSection.CONVERSATIONS,
|
||||
val conversationTab: ConversationTab = ConversationTab.CHAT,
|
||||
val conversationTab: ConversationTab = ConversationTab.OVERVIEW,
|
||||
val sessions: List<Session> = emptyList(),
|
||||
val messages: List<Message> = emptyList(),
|
||||
val tasks: List<TaskItem> = emptyList(),
|
||||
@@ -60,17 +63,21 @@ data class BossUiState(
|
||||
val events: List<BossEvent> = emptyList(),
|
||||
val selectedSessionId: String? = null,
|
||||
val selectedWorkerId: String? = null,
|
||||
val syncConnectionState: SyncConnectionState = SyncConnectionState.CONNECTING,
|
||||
val health: HealthPayload? = null,
|
||||
val isRefreshing: Boolean = false,
|
||||
val generatedCommand: GeneratedWorkerCommand? = null,
|
||||
val notice: UiNotice? = null,
|
||||
val lastSyncedAt: Long? = null,
|
||||
val lastEventAt: Long? = null,
|
||||
val lastEventType: String? = null,
|
||||
)
|
||||
|
||||
class BossViewModel(
|
||||
application: Application,
|
||||
) : AndroidViewModel(application) {
|
||||
private val api = BossApi()
|
||||
private val realtimeSync = BossRealtimeSync()
|
||||
private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val refreshMutex = Mutex()
|
||||
private val _uiState = MutableStateFlow(
|
||||
@@ -81,10 +88,12 @@ class BossViewModel(
|
||||
val uiState: StateFlow<BossUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private var streamJob: Job? = null
|
||||
private var scheduledRefreshJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh(showSpinner = true)
|
||||
startPolling()
|
||||
restartRealtimeSync()
|
||||
}
|
||||
|
||||
fun selectSection(section: MainSection) {
|
||||
@@ -117,6 +126,7 @@ class BossViewModel(
|
||||
val sanitized = sanitizeBaseUrl(input)
|
||||
prefs.edit().putString(KEY_BASE_URL, sanitized).apply()
|
||||
_uiState.update { it.copy(baseUrl = sanitized) }
|
||||
restartRealtimeSync()
|
||||
refresh(showSpinner = true)
|
||||
}
|
||||
|
||||
@@ -259,6 +269,13 @@ class BossViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
streamJob?.cancel()
|
||||
pollingJob?.cancel()
|
||||
scheduledRefreshJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun runTaskMutation(
|
||||
taskId: String,
|
||||
successMessage: String,
|
||||
@@ -365,6 +382,53 @@ class BossViewModel(
|
||||
|
||||
private fun currentBaseUrl(): String = uiState.value.baseUrl
|
||||
|
||||
private fun restartRealtimeSync() {
|
||||
streamJob?.cancel()
|
||||
stopPolling()
|
||||
scheduledRefreshJob?.cancel()
|
||||
streamJob = viewModelScope.launch {
|
||||
realtimeSync.run(
|
||||
baseUrl = currentBaseUrl(),
|
||||
onStatusChanged = ::handleSyncStatusChange,
|
||||
onEvent = ::handleRealtimeEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSyncStatusChange(state: SyncConnectionState) {
|
||||
_uiState.update { current ->
|
||||
current.copy(syncConnectionState = state)
|
||||
}
|
||||
|
||||
if (state == SyncConnectionState.LIVE) {
|
||||
stopPolling()
|
||||
} else if (state == SyncConnectionState.RECONNECTING) {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRealtimeEvent(event: BossEvent) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
events = (listOf(event) + current.events).distinctBy { it.id }.take(MAX_EVENT_CACHE),
|
||||
lastEventAt = System.currentTimeMillis(),
|
||||
lastEventType = event.type,
|
||||
)
|
||||
}
|
||||
scheduleRealtimeRefresh()
|
||||
}
|
||||
|
||||
private fun scheduleRealtimeRefresh() {
|
||||
if (scheduledRefreshJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduledRefreshJob = viewModelScope.launch {
|
||||
delay(250)
|
||||
refreshSnapshot(showSpinner = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = viewModelScope.launch {
|
||||
@@ -375,6 +439,11 @@ class BossViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPolling() {
|
||||
pollingJob?.cancel()
|
||||
pollingJob = null
|
||||
}
|
||||
|
||||
private fun sanitizeBaseUrl(input: String): String {
|
||||
val trimmed = input.trim().trimEnd('/')
|
||||
if (trimmed.isBlank()) {
|
||||
@@ -465,7 +534,8 @@ class BossViewModel(
|
||||
|
||||
companion object {
|
||||
private const val KEY_BASE_URL = "boss_base_url"
|
||||
private const val POLL_INTERVAL_MS = 4_500L
|
||||
private const val MAX_EVENT_CACHE = 300
|
||||
private const val POLL_INTERVAL_MS = 2_000L
|
||||
private const val PREFS_NAME = "boss_android"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEFAULT_SERIAL="adb-A9TU024628000647-B0UNif._adb-tls-connect._tcp"
|
||||
DEFAULT_SDK_DIR="/opt/homebrew/share/android-commandlinetools"
|
||||
PACKAGE_NAME="site.hyzq.bossandroid"
|
||||
ACTIVITY_NAME=".MainActivity"
|
||||
APK_PATH="$ROOT_DIR/android-app/app/build/outputs/apk/debug/app-debug.apk"
|
||||
TARGET_SERIAL="${BOSS_ANDROID_SERIAL:-$DEFAULT_SERIAL}"
|
||||
|
||||
@@ -18,6 +20,7 @@ Commands:
|
||||
launch Launch the Boss app on the Honor device
|
||||
build-install Build the debug APK and install it on the Honor device
|
||||
screenshot Save a screenshot to /tmp/boss-honor-screen.png
|
||||
ui-dump Dump the current UI hierarchy to /tmp/boss-honor-ui.xml
|
||||
logcat Tail logcat for the Boss app only
|
||||
|
||||
Environment:
|
||||
@@ -47,6 +50,12 @@ ensure_apk() {
|
||||
build_debug_apk() {
|
||||
(
|
||||
cd "$ROOT_DIR/android-app"
|
||||
if [[ -z "${ANDROID_HOME:-}" && -d "$DEFAULT_SDK_DIR" ]]; then
|
||||
export ANDROID_HOME="$DEFAULT_SDK_DIR"
|
||||
fi
|
||||
if [[ -z "${ANDROID_SDK_ROOT:-}" && -d "${ANDROID_HOME:-}" ]]; then
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
fi
|
||||
./gradlew assembleDebug
|
||||
)
|
||||
}
|
||||
@@ -71,13 +80,13 @@ case "$command" in
|
||||
;;
|
||||
launch)
|
||||
ensure_connected
|
||||
adb -s "$TARGET_SERIAL" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1
|
||||
adb -s "$TARGET_SERIAL" shell am start -n "$PACKAGE_NAME/$ACTIVITY_NAME"
|
||||
;;
|
||||
build-install)
|
||||
ensure_connected
|
||||
build_debug_apk
|
||||
adb -s "$TARGET_SERIAL" install -r "$APK_PATH"
|
||||
adb -s "$TARGET_SERIAL" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1
|
||||
adb -s "$TARGET_SERIAL" shell am start -n "$PACKAGE_NAME/$ACTIVITY_NAME"
|
||||
;;
|
||||
screenshot)
|
||||
ensure_connected
|
||||
@@ -86,6 +95,13 @@ case "$command" in
|
||||
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-screen.png >/dev/null
|
||||
echo "/tmp/boss-honor-screen.png"
|
||||
;;
|
||||
ui-dump)
|
||||
ensure_connected
|
||||
adb -s "$TARGET_SERIAL" shell uiautomator dump /sdcard/boss-honor-ui.xml >/dev/null
|
||||
adb -s "$TARGET_SERIAL" pull /sdcard/boss-honor-ui.xml /tmp/boss-honor-ui.xml >/dev/null
|
||||
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-ui.xml >/dev/null
|
||||
echo "/tmp/boss-honor-ui.xml"
|
||||
;;
|
||||
logcat)
|
||||
ensure_connected
|
||||
adb -s "$TARGET_SERIAL" logcat --pid="$(adb -s "$TARGET_SERIAL" shell pidof "$PACKAGE_NAME")"
|
||||
|
||||
Reference in New Issue
Block a user