diff --git a/README.md b/README.md index 9826f88..9fd42d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossRealtimeSync.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossRealtimeSync.kt new file mode 100644 index 0000000..751f84f --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossRealtimeSync.kt @@ -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() + + while (!source.exhausted()) { + val rawLine = source.readUtf8Line() ?: break + when { + rawLine.startsWith(":") -> Unit + rawLine.isBlank() -> { + if (payloadLines.isNotEmpty()) { + val event = json.decodeFromString(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 + } +} diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt index 939ade6..f4101be 100644 --- a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt @@ -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,21 +253,17 @@ private fun ConversationsScreen( ) } - if (selectedSession == null) { - EmptyStateCard( - title = "还没有可用会话", - body = "先创建一个项目会话,然后在这里持续对话、改需求、审批和看进度。", - ) - return@Column + if (uiState.conversationTab != ConversationTab.OVERVIEW) { + selectedSession?.let { session -> + SessionSummaryCard( + session = session, + workerCount = uiState.workers.size, + onArchive = onArchiveSession, + onRestore = onRestoreSession, + ) + } } - SessionSummaryCard( - session = selectedSession, - workerCount = uiState.workers.size, - onArchive = onArchiveSession, - onRestore = onRestoreSession, - ) - TabRow(selectedTabIndex = uiState.conversationTab.ordinal) { ConversationTab.entries.forEach { tab -> Tab( @@ -271,6 +272,7 @@ private fun ConversationsScreen( text = { Text( text = when (tab) { + ConversationTab.OVERVIEW -> "总览" ConversationTab.CHAT -> "对话" ConversationTab.TASKS -> "任务" ConversationTab.APPROVALS -> "审批" @@ -282,27 +284,54 @@ private fun ConversationsScreen( } when (uiState.conversationTab) { - ConversationTab.CHAT -> ChatTab( - sessionMessages = sessionMessages, - sessionEvents = sessionEvents, - onSendMessage = onSendMessage, + ConversationTab.OVERVIEW -> OverviewTab( + uiState = uiState, + onSelectSession = onSelectSession, + onSelectWorker = onSelectWorker, ) - ConversationTab.TASKS -> TasksTab( - tasks = filteredTasks, - workers = uiState.workers, - onPauseTask = onPauseTask, - onResumeTask = onResumeTask, - onCancelTask = onCancelTask, - onRequeueTask = onRequeueTask, - ) + ConversationTab.CHAT -> if (selectedSession == null) { + EmptyStateCard( + title = "先选择一个项目", + body = "总览里能看到所有客户端的对话;如果要继续某个项目,请先选中它。", + ) + } else { + ChatTab( + sessionMessages = sessionMessages, + sessionEvents = sessionEvents, + onSendMessage = onSendMessage, + ) + } - ConversationTab.APPROVALS -> ApprovalsTab( - approvals = sessionApprovals, - tasks = sessionTasks, - onApprove = onApprove, - onReject = onReject, - ) + ConversationTab.TASKS -> if (selectedSession == null) { + EmptyStateCard( + title = "先选择一个项目", + body = "选中项目后,这里会展示它的任务树和实时进度。", + ) + } else { + TasksTab( + tasks = filteredTasks, + workers = uiState.workers, + onPauseTask = onPauseTask, + onResumeTask = onResumeTask, + onCancelTask = onCancelTask, + onRequeueTask = onRequeueTask, + ) + } + + ConversationTab.APPROVALS -> if (selectedSession == null) { + EmptyStateCard( + title = "先选择一个项目", + body = "选中项目后,这里会展示对应的审批项。", + ) + } else { + ApprovalsTab( + approvals = sessionApprovals, + tasks = sessionTasks, + onApprove = onApprove, + onReject = onReject, + ) + } } } } @@ -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, + 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,24 +1329,26 @@ private fun TaskCard( ) } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - when (task.status) { - "paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") } - "completed", "cancelled", "failed" -> OutlinedButton(onClick = { onRequeueTask(task.id) }) { - Text("重新排队") + if (actionsEnabled) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + when (task.status) { + "paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") } + "completed", "cancelled", "failed" -> OutlinedButton(onClick = { onRequeueTask(task.id) }) { + Text("重新排队") + } + else -> OutlinedButton(onClick = { onPauseTask(task.id) }) { Text("暂停") } } - else -> OutlinedButton(onClick = { onPauseTask(task.id) }) { Text("暂停") } - } - if (task.status !in listOf("completed", "cancelled")) { - TextButton(onClick = { onCancelTask(task.id) }) { - Text("取消") + if (task.status !in listOf("completed", "cancelled")) { + TextButton(onClick = { onCancelTask(task.id) }) { + Text("取消") + } } - } - if (task.status in listOf("running", "assigned", "failed")) { - TextButton(onClick = { onRequeueTask(task.id) }) { - Text("重排") + if (task.status in listOf("running", "assigned", "failed")) { + TextButton(onClick = { onRequeueTask(task.id) }) { + Text("重排") + } } } } @@ -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" -> "你" diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt index c49b017..9525da8 100644 --- a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt @@ -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 = emptyList(), val messages: List = emptyList(), val tasks: List = emptyList(), @@ -60,17 +63,21 @@ data class BossUiState( val events: List = 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 = _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" } } diff --git a/scripts/android_honor_debug.sh b/scripts/android_honor_debug.sh index 4f58965..9713046 100755 --- a/scripts/android_honor_debug.sh +++ b/scripts/android_honor_debug.sh @@ -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")"