feat: add device-targeted android control flow
This commit is contained in:
@@ -9,6 +9,7 @@ data class AppStatePayload(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val tasks: List<TaskItem> = emptyList(),
|
||||
val workers: List<WorkerNode> = emptyList(),
|
||||
val deviceBindings: List<DeviceBinding> = emptyList(),
|
||||
val approvals: List<ApprovalRequest> = emptyList(),
|
||||
val events: List<BossEvent> = emptyList(),
|
||||
)
|
||||
@@ -27,6 +28,7 @@ data class Session(
|
||||
val status: String,
|
||||
val activeObjective: String = "",
|
||||
val lastPlannerSummary: String = "",
|
||||
val activeWorkerId: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
@@ -55,6 +57,7 @@ data class TaskItem(
|
||||
val requiredCapabilities: List<String> = emptyList(),
|
||||
val dependencyIds: List<String> = emptyList(),
|
||||
val assignedWorkerId: String? = null,
|
||||
val preferredWorkerId: String? = null,
|
||||
val approvalStatus: String = "not_required",
|
||||
val progressPercent: Int = 0,
|
||||
val summary: String = "",
|
||||
@@ -103,3 +106,27 @@ data class BossEvent(
|
||||
val timestamp: String,
|
||||
val payload: Map<String, JsonElement> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceBinding(
|
||||
val id: String,
|
||||
val token: String,
|
||||
val name: String,
|
||||
val os: String,
|
||||
val capabilities: List<String> = emptyList(),
|
||||
val executor: String,
|
||||
val workspaceHint: String = "",
|
||||
val status: String,
|
||||
val claimedWorkerId: String? = null,
|
||||
val claimedAt: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceBindingLaunchPayload(
|
||||
val binding: DeviceBinding,
|
||||
val launcherUrl: String,
|
||||
val command: String,
|
||||
val platformLabel: String,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import site.hyzq.bossandroid.model.AppStatePayload
|
||||
import site.hyzq.bossandroid.model.ApprovalRequest
|
||||
import site.hyzq.bossandroid.model.DeviceBindingLaunchPayload
|
||||
import site.hyzq.bossandroid.model.HealthPayload
|
||||
import site.hyzq.bossandroid.model.Session
|
||||
import site.hyzq.bossandroid.model.TaskItem
|
||||
@@ -42,13 +43,21 @@ class BossApi(
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun addMessage(baseUrl: String, sessionId: String, content: String): AppStatePayload {
|
||||
suspend fun addMessage(
|
||||
baseUrl: String,
|
||||
sessionId: String,
|
||||
content: String,
|
||||
targetWorkerId: String?,
|
||||
): AppStatePayload {
|
||||
post<UnitPayload>(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/sessions/$sessionId/messages",
|
||||
body = buildJsonObject {
|
||||
put("content", JsonPrimitive(content))
|
||||
put("channel", JsonPrimitive("android"))
|
||||
if (!targetWorkerId.isNullOrBlank()) {
|
||||
put("targetWorkerId", JsonPrimitive(targetWorkerId))
|
||||
}
|
||||
},
|
||||
)
|
||||
return getBootstrap(baseUrl)
|
||||
@@ -81,6 +90,27 @@ class BossApi(
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun createDeviceBinding(
|
||||
baseUrl: String,
|
||||
name: String,
|
||||
os: String,
|
||||
capabilities: List<String>,
|
||||
executor: String,
|
||||
workspaceHint: String,
|
||||
): DeviceBindingLaunchPayload = post(
|
||||
baseUrl = baseUrl,
|
||||
path = "/api/device-bindings",
|
||||
body = buildJsonObject {
|
||||
put("name", JsonPrimitive(name))
|
||||
put("os", JsonPrimitive(os))
|
||||
put("executor", JsonPrimitive(executor))
|
||||
put("workspaceHint", JsonPrimitive(workspaceHint))
|
||||
putJsonArray("capabilities") {
|
||||
capabilities.forEach { add(JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun markWorkerOffline(baseUrl: String, workerId: String): AppStatePayload {
|
||||
post<UnitPayload>(baseUrl, "/api/workers/$workerId/offline")
|
||||
return getBootstrap(baseUrl)
|
||||
|
||||
@@ -73,6 +73,7 @@ import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import site.hyzq.bossandroid.model.ApprovalRequest
|
||||
import site.hyzq.bossandroid.model.BossEvent
|
||||
import site.hyzq.bossandroid.model.DeviceBinding
|
||||
import site.hyzq.bossandroid.model.Message
|
||||
import site.hyzq.bossandroid.model.Session
|
||||
import site.hyzq.bossandroid.model.TaskItem
|
||||
@@ -182,7 +183,7 @@ fun BossApp(
|
||||
MainSection.DEVICES -> DevicesScreen(
|
||||
uiState = uiState,
|
||||
clipboard = clipboard,
|
||||
onBindWorker = viewModel::registerWorker,
|
||||
onBindWorker = viewModel::createDeviceBinding,
|
||||
onSelectWorker = viewModel::selectWorker,
|
||||
onMarkOffline = viewModel::markWorkerOffline,
|
||||
onClearGeneratedCommand = viewModel::clearGeneratedCommand,
|
||||
@@ -226,7 +227,9 @@ private fun ConversationsScreen(
|
||||
event.sessionId == selectedSession?.id || event.sessionId == null
|
||||
}.take(8)
|
||||
val filteredTasks = sessionTasks.filter { task ->
|
||||
uiState.selectedWorkerId == null || task.assignedWorkerId == uiState.selectedWorkerId
|
||||
uiState.selectedWorkerId == null ||
|
||||
task.assignedWorkerId == uiState.selectedWorkerId ||
|
||||
task.preferredWorkerId == uiState.selectedWorkerId
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -244,10 +247,25 @@ private fun ConversationsScreen(
|
||||
onSelectSession = onSelectSession,
|
||||
)
|
||||
|
||||
if (uiState.workers.isNotEmpty()) {
|
||||
DeviceConversationSelector(
|
||||
workers = uiState.workers,
|
||||
selectedWorkerId = uiState.selectedWorkerId,
|
||||
onSelectWorker = onSelectWorker,
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedWorker != null) {
|
||||
FocusBanner(
|
||||
title = "当前设备视角",
|
||||
body = "已切换到 ${selectedWorker.name},任务列表会按这个设备过滤。",
|
||||
body = "已切换到 ${selectedWorker.name},接下来发送的需求会定向给这台设备,任务列表也会按它过滤。",
|
||||
actionLabel = "清除",
|
||||
onAction = onClearWorkerFocus,
|
||||
)
|
||||
} else if (uiState.workers.isNotEmpty()) {
|
||||
FocusBanner(
|
||||
title = "先选择设备再对话",
|
||||
body = "这版安卓主控会按设备维度投递需求。先选一台设备,再继续当前项目。",
|
||||
actionLabel = "清除",
|
||||
onAction = onClearWorkerFocus,
|
||||
)
|
||||
@@ -299,6 +317,8 @@ private fun ConversationsScreen(
|
||||
ChatTab(
|
||||
sessionMessages = sessionMessages,
|
||||
sessionEvents = sessionEvents,
|
||||
selectedWorker = selectedWorker,
|
||||
requiresDeviceSelection = uiState.workers.isNotEmpty(),
|
||||
onSendMessage = onSendMessage,
|
||||
)
|
||||
}
|
||||
@@ -346,6 +366,8 @@ private fun DevicesScreen(
|
||||
onClearGeneratedCommand: () -> Unit,
|
||||
) {
|
||||
val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId }
|
||||
val pendingBindings = uiState.deviceBindings.filter { it.status == "pending" }
|
||||
val claimedBindings = uiState.deviceBindings.filter { it.status == "claimed" }
|
||||
val relatedTasks = remember(uiState.tasks) {
|
||||
uiState.tasks.associateBy { it.id }
|
||||
}
|
||||
@@ -368,6 +390,13 @@ private fun DevicesScreen(
|
||||
onDismissCommand = onClearGeneratedCommand,
|
||||
)
|
||||
|
||||
if (pendingBindings.isNotEmpty()) {
|
||||
SectionHeading("待完成绑定")
|
||||
pendingBindings.forEach { binding ->
|
||||
DeviceBindingCard(binding = binding)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedWorker != null) {
|
||||
FocusBanner(
|
||||
title = "当前设备焦点",
|
||||
@@ -397,6 +426,13 @@ private fun DevicesScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (claimedBindings.isNotEmpty()) {
|
||||
SectionHeading("最近完成绑定")
|
||||
claimedBindings.take(3).forEach { binding ->
|
||||
DeviceBindingCard(binding = binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,6 +812,8 @@ private fun SessionSummaryCard(
|
||||
private fun ChatTab(
|
||||
sessionMessages: List<Message>,
|
||||
sessionEvents: List<BossEvent>,
|
||||
selectedWorker: WorkerNode?,
|
||||
requiresDeviceSelection: Boolean,
|
||||
onSendMessage: (String) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
@@ -787,6 +825,14 @@ private fun ChatTab(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("和 Boss 对话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
text = when {
|
||||
selectedWorker != null -> "当前发送目标:${selectedWorker.name} · ${selectedWorker.os}"
|
||||
requiresDeviceSelection -> "先选一台设备,Boss 会把你的需求投递到对应客户端。"
|
||||
else -> "当前还没有绑定设备,先用手机整理需求也可以。"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
@@ -801,8 +847,15 @@ private fun ChatTab(
|
||||
onSendMessage(message)
|
||||
message = ""
|
||||
},
|
||||
enabled = !requiresDeviceSelection || selectedWorker != null,
|
||||
) {
|
||||
Text("发送给 Boss")
|
||||
Text(
|
||||
if (selectedWorker != null) {
|
||||
"发送到 ${selectedWorker.name}"
|
||||
} else {
|
||||
"发送给 Boss"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,7 +970,7 @@ private fun BindDeviceCard(
|
||||
) {
|
||||
Text("绑定新设备", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"在这里登记设备身份,Boss 会直接生成对应启动命令。适合把 Windows、Mac 和 Linux 全部挂到同一个主控下。",
|
||||
"在这里登记设备身份,Boss 会生成对应平台的超链接和终端指令。你只需要把它拿到目标电脑上执行,就能完成绑定。",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
@@ -950,9 +1003,8 @@ private fun BindDeviceCard(
|
||||
)
|
||||
Button(onClick = {
|
||||
onBindWorker(name, os, capabilities, executor, workspace)
|
||||
name = ""
|
||||
}) {
|
||||
Text("绑定并生成命令")
|
||||
Text("生成绑定链接")
|
||||
}
|
||||
|
||||
if (generatedCommand != null) {
|
||||
@@ -966,14 +1018,38 @@ private fun BindDeviceCard(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"${generatedCommand.workerName} 已就绪",
|
||||
"${generatedCommand.deviceName} 绑定指令已生成",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"把下面这条命令贴到对应设备的 ${generatedCommand.shellLabel} 里即可启动 worker。",
|
||||
"先把链接或命令发到目标设备,再在那台 ${generatedCommand.platformLabel} 电脑的终端里运行。",
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Text(
|
||||
"一键链接",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = generatedCommand.launcherUrl,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"终端命令",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -990,6 +1066,11 @@ private fun BindDeviceCard(
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = {
|
||||
clipboard.setText(AnnotatedString(generatedCommand.launcherUrl))
|
||||
}) {
|
||||
Text("复制链接")
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
clipboard.setText(AnnotatedString(generatedCommand.command))
|
||||
}) {
|
||||
Text("复制命令")
|
||||
@@ -1005,6 +1086,94 @@ private fun BindDeviceCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceConversationSelector(
|
||||
workers: List<WorkerNode>,
|
||||
selectedWorkerId: String?,
|
||||
onSelectWorker: (String?) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SectionHeading("对话设备")
|
||||
Text(
|
||||
"先切换设备,再继续当前项目。Boss 会把你的消息投递给选中的客户端。",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
workers.forEach { worker ->
|
||||
FilterChip(
|
||||
selected = worker.id == selectedWorkerId,
|
||||
onClick = { onSelectWorker(worker.id) },
|
||||
label = {
|
||||
Text(
|
||||
"${worker.name} · ${worker.os}",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (selectedWorkerId != null) {
|
||||
TextButton(onClick = { onSelectWorker(null) }) {
|
||||
Text("清除设备")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceBindingCard(
|
||||
binding: DeviceBinding,
|
||||
) {
|
||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
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(binding.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"${binding.os} · ${binding.executor}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
StatusChip(
|
||||
label = if (binding.status == "claimed") "已完成" else "待执行",
|
||||
tone = if (binding.status == "claimed") "completed" else "pending",
|
||||
)
|
||||
}
|
||||
if (binding.capabilities.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
binding.capabilities.forEach { capability ->
|
||||
AssistChip(onClick = {}, label = { Text(capability) })
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (binding.status == "claimed") {
|
||||
"已完成绑定,Worker ID:${binding.claimedWorkerId ?: "未知"}"
|
||||
} else {
|
||||
"等待目标设备运行绑定命令。"
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"最近更新 ${formatRelative(binding.updatedAt)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkerCard(
|
||||
worker: WorkerNode,
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import site.hyzq.bossandroid.model.AppStatePayload
|
||||
import site.hyzq.bossandroid.model.ApprovalRequest
|
||||
import site.hyzq.bossandroid.model.BossEvent
|
||||
import site.hyzq.bossandroid.model.DeviceBinding
|
||||
import site.hyzq.bossandroid.model.HealthPayload
|
||||
import site.hyzq.bossandroid.model.Message
|
||||
import site.hyzq.bossandroid.model.Session
|
||||
@@ -46,8 +47,9 @@ data class UiNotice(
|
||||
)
|
||||
|
||||
data class GeneratedWorkerCommand(
|
||||
val workerName: String,
|
||||
val shellLabel: String,
|
||||
val deviceName: String,
|
||||
val launcherUrl: String,
|
||||
val platformLabel: String,
|
||||
val command: String,
|
||||
)
|
||||
|
||||
@@ -60,6 +62,7 @@ data class BossUiState(
|
||||
val tasks: List<TaskItem> = emptyList(),
|
||||
val approvals: List<ApprovalRequest> = emptyList(),
|
||||
val workers: List<WorkerNode> = emptyList(),
|
||||
val deviceBindings: List<DeviceBinding> = emptyList(),
|
||||
val events: List<BossEvent> = emptyList(),
|
||||
val selectedSessionId: String? = null,
|
||||
val selectedWorkerId: String? = null,
|
||||
@@ -106,8 +109,13 @@ class BossViewModel(
|
||||
|
||||
fun selectSession(sessionId: String) {
|
||||
_uiState.update { current ->
|
||||
val session = current.sessions.firstOrNull { it.id == sessionId }
|
||||
val nextWorkerId = session?.activeWorkerId
|
||||
?.takeIf { candidate -> current.workers.any { it.id == candidate } }
|
||||
?: current.selectedWorkerId
|
||||
current.copy(
|
||||
selectedSessionId = sessionId,
|
||||
selectedWorkerId = nextWorkerId,
|
||||
section = MainSection.CONVERSATIONS,
|
||||
)
|
||||
}
|
||||
@@ -115,10 +123,7 @@ class BossViewModel(
|
||||
|
||||
fun selectWorker(workerId: String?) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
selectedWorkerId = workerId,
|
||||
section = if (workerId == null) current.section else MainSection.DEVICES,
|
||||
)
|
||||
current.copy(selectedWorkerId = workerId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,10 +164,24 @@ class BossViewModel(
|
||||
publishNotice("消息不能为空。")
|
||||
return
|
||||
}
|
||||
if (uiState.value.workers.isNotEmpty() && uiState.value.selectedWorkerId == null) {
|
||||
publishNotice("请先切换到一个设备,再发送需求。")
|
||||
return
|
||||
}
|
||||
|
||||
runMutation(successMessage = "需求已发送给 Boss。") {
|
||||
val snapshot = api.addMessage(currentBaseUrl(), sessionId, message)
|
||||
applySnapshot(snapshot, preferredSessionId = sessionId)
|
||||
val targetWorkerId = uiState.value.selectedWorkerId
|
||||
val snapshot = api.addMessage(
|
||||
baseUrl = currentBaseUrl(),
|
||||
sessionId = sessionId,
|
||||
content = message,
|
||||
targetWorkerId = targetWorkerId,
|
||||
)
|
||||
applySnapshot(
|
||||
snapshot = snapshot,
|
||||
preferredSessionId = sessionId,
|
||||
preferredWorkerId = targetWorkerId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +201,7 @@ class BossViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun registerWorker(
|
||||
fun createDeviceBinding(
|
||||
name: String,
|
||||
os: String,
|
||||
capabilitiesInput: String,
|
||||
@@ -201,22 +220,24 @@ class BossViewModel(
|
||||
.filter { it.isNotEmpty() }
|
||||
.ifEmpty { listOf("terminal") }
|
||||
|
||||
runMutation(successMessage = "设备已绑定到 Boss。") {
|
||||
val worker = api.registerWorker(
|
||||
runMutation(successMessage = "已生成绑定链接和启动指令。") {
|
||||
val bindingLaunch = api.createDeviceBinding(
|
||||
baseUrl = currentBaseUrl(),
|
||||
name = normalizedName,
|
||||
os = os,
|
||||
capabilities = capabilities,
|
||||
executor = executorType,
|
||||
workspaceHint = workspaceInput.trim(),
|
||||
)
|
||||
val snapshot = api.getBootstrap(currentBaseUrl())
|
||||
applySnapshot(snapshot, preferredWorkerId = worker.id)
|
||||
applySnapshot(snapshot)
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
generatedCommand = buildWorkerCommand(
|
||||
baseUrl = current.baseUrl,
|
||||
worker = worker,
|
||||
executorType = executorType,
|
||||
workspaceInput = workspaceInput,
|
||||
generatedCommand = GeneratedWorkerCommand(
|
||||
deviceName = bindingLaunch.binding.name,
|
||||
launcherUrl = bindingLaunch.launcherUrl,
|
||||
platformLabel = bindingLaunch.platformLabel,
|
||||
command = bindingLaunch.command,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -339,6 +360,7 @@ class BossViewModel(
|
||||
val tasks = snapshot.tasks.sortedByDescending { it.updatedAt }
|
||||
val approvals = snapshot.approvals.sortedByDescending { it.updatedAt }
|
||||
val workers = snapshot.workers.sortedBy { it.name.lowercase() }
|
||||
val deviceBindings = snapshot.deviceBindings.sortedByDescending { it.updatedAt }
|
||||
val events = snapshot.events.sortedByDescending { it.timestamp }
|
||||
|
||||
val selectedSessionId = preferredSessionId
|
||||
@@ -346,8 +368,12 @@ class BossViewModel(
|
||||
?: sessions.firstOrNull { it.status == "active" }?.id
|
||||
?: sessions.firstOrNull()?.id
|
||||
|
||||
val sessionWorkerId = sessions.firstOrNull { it.id == selectedSessionId }?.activeWorkerId
|
||||
?.takeIf { candidate -> workers.any { it.id == candidate } }
|
||||
val selectedWorkerId = preferredWorkerId
|
||||
?.takeIf { candidate -> workers.any { it.id == candidate } }
|
||||
?: sessionWorkerId
|
||||
?: uiState.value.selectedWorkerId?.takeIf { candidate -> workers.any { it.id == candidate } }
|
||||
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
@@ -356,6 +382,7 @@ class BossViewModel(
|
||||
tasks = tasks,
|
||||
approvals = approvals,
|
||||
workers = workers,
|
||||
deviceBindings = deviceBindings,
|
||||
events = events,
|
||||
health = HealthPayload(
|
||||
status = "ok",
|
||||
@@ -456,82 +483,6 @@ class BossViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildWorkerCommand(
|
||||
baseUrl: String,
|
||||
worker: WorkerNode,
|
||||
executorType: String,
|
||||
workspaceInput: String,
|
||||
): GeneratedWorkerCommand {
|
||||
val capabilityFlags = worker.capabilities.joinToString(" ") { capability ->
|
||||
"--capability ${quoteForShell(capability, worker.os)}"
|
||||
}
|
||||
|
||||
val workspace = workspaceInput.trim().ifBlank {
|
||||
if (worker.os == "windows") {
|
||||
"C:\\path\\to\\project"
|
||||
} else {
|
||||
"/path/to/project"
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedBaseUrl = sanitizeBaseUrl(baseUrl)
|
||||
return if (worker.os == "windows") {
|
||||
val executor = when (executorType) {
|
||||
"claude" -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\claude_executor.ps1"
|
||||
else -> "powershell -ExecutionPolicy Bypass -File .\\scripts\\codex_executor.ps1"
|
||||
}
|
||||
GeneratedWorkerCommand(
|
||||
workerName = worker.name,
|
||||
shellLabel = "PowerShell",
|
||||
command = buildString {
|
||||
append("npm run worker -- --name ")
|
||||
append(quoteForShell(worker.name, worker.os))
|
||||
append(" --os ")
|
||||
append(worker.os)
|
||||
append(' ')
|
||||
append(capabilityFlags)
|
||||
append(" --mode command --workspace ")
|
||||
append(quoteForShell(workspace, worker.os))
|
||||
append(" --executor ")
|
||||
append(quoteForShell(executor, worker.os))
|
||||
append(" --server ")
|
||||
append(quoteForShell(normalizedBaseUrl, worker.os))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
val executor = when (executorType) {
|
||||
"claude" -> "./scripts/claude_executor.sh"
|
||||
else -> "./scripts/codex_executor.sh"
|
||||
}
|
||||
GeneratedWorkerCommand(
|
||||
workerName = worker.name,
|
||||
shellLabel = "Terminal",
|
||||
command = buildString {
|
||||
append("npm run worker -- --name ")
|
||||
append(quoteForShell(worker.name, worker.os))
|
||||
append(" --os ")
|
||||
append(worker.os)
|
||||
append(' ')
|
||||
append(capabilityFlags)
|
||||
append(" --mode command --workspace ")
|
||||
append(quoteForShell(workspace, worker.os))
|
||||
append(" --executor ")
|
||||
append(quoteForShell(executor, worker.os))
|
||||
append(" --server ")
|
||||
append(quoteForShell(normalizedBaseUrl, worker.os))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun quoteForShell(value: String, os: String): String {
|
||||
return if (os == "windows") {
|
||||
"\"${value.replace("\"", "`\"")}\""
|
||||
} else {
|
||||
"'${value.replace("'", "'\"'\"'")}'"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_BASE_URL = "boss_base_url"
|
||||
private const val MAX_EVENT_CACHE = 300
|
||||
|
||||
Reference in New Issue
Block a user