diff --git a/.gitignore b/.gitignore index 198d467..96a03be 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist .boss-session .playwright-cli npm-debug.log* +android-app/.gradle +android-app/build +android-app/app/build +android-app/local.properties diff --git a/README.md b/README.md index e4fbd24..c682ade 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Boss 是一个面向多设备开发协作的 agent control plane。 - 文件持久化状态存储 - SSE 实时事件流 - Web 控制台 +- Android 主控 APP(Jetpack Compose) - `boss-worker` 模拟执行器 - `boss-worker` 外部命令执行模式,可接本地 Codex / Claude / 自定义脚本 - AI Glasses 云服务器一键部署脚本 @@ -132,6 +133,8 @@ npm run worker -- \ - `./scripts/codex_executor.sh` - `./scripts/claude_executor.sh` +- `./scripts/codex_executor.ps1` +- `./scripts/claude_executor.ps1` 例如: @@ -170,6 +173,40 @@ BOSS_SERVER_URL=http://111.231.132.51/boss ./scripts/boss_chat.sh status 这条 CLI 入口后面也很容易改造成 Telegram / Slack / 企业微信 webhook。 +3. Android 主控 APP +仓库现在已经带了一个原生安卓端,适合把“对话、切换设备、绑定设备、审批、看任务”统一放到手机里完成。 + +首次构建前,先在 `android-app` 目录满足其中一个条件: + +- 设置 `ANDROID_HOME` 或 `ANDROID_SDK_ROOT` +- 或手工创建 `android-app/local.properties`,内容类似: + +```properties +sdk.dir=/Users/yourname/Library/Android/sdk +``` + +然后构建 debug 包: + +```bash +cd android-app +./gradlew assembleDebug +``` + +输出 APK: + +```bash +android-app/app/build/outputs/apk/debug/app-debug.apk +``` + +安卓端当前包含: + +- 会话创建、切换、持续对话 +- 任务分组查看、暂停、恢复、取消、重排 +- 审批查看与批准/拒绝 +- 设备列表、设备聚焦切换、设备下线 +- 绑定新设备并生成启动命令 +- 云端 Boss 地址切换与重排入口 + 一键本地 demo: ```bash diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts new file mode 100644 index 0000000..4609879 --- /dev/null +++ b/android-app/app/build.gradle.kts @@ -0,0 +1,73 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "site.hyzq.bossandroid" + compileSdk = 34 + + defaultConfig { + applicationId = "site.hyzq.bossandroid" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.compose.foundation:foundation:1.7.5") + implementation("androidx.compose.material3:material3:1.3.1") + implementation("androidx.compose.material:material-icons-extended:1.7.5") + implementation("androidx.compose.ui:ui:1.7.5") + implementation("androidx.compose.ui:ui-tooling-preview:1.7.5") + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("com.google.android.material:material:1.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + debugImplementation("androidx.compose.ui:ui-tooling:1.7.5") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.5") +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..b5e8e8d --- /dev/null +++ b/android-app/app/proguard-rules.pro @@ -0,0 +1 @@ +# Boss Android v1 does not need additional release rules yet. diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5ed859 --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/MainActivity.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/MainActivity.kt new file mode 100644 index 0000000..cd28652 --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/MainActivity.kt @@ -0,0 +1,23 @@ +package site.hyzq.bossandroid + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.viewmodel.compose.viewModel +import site.hyzq.bossandroid.ui.BossApp +import site.hyzq.bossandroid.ui.BossViewModel +import site.hyzq.bossandroid.ui.theme.BossAndroidTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + BossAndroidTheme { + val bossViewModel: BossViewModel = viewModel() + BossApp(viewModel = bossViewModel) + } + } + } +} diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt new file mode 100644 index 0000000..9040e81 --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/model/Models.kt @@ -0,0 +1,105 @@ +package site.hyzq.bossandroid.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class AppStatePayload( + val sessions: List = emptyList(), + val messages: List = emptyList(), + val tasks: List = emptyList(), + val workers: List = emptyList(), + val approvals: List = emptyList(), + val events: List = emptyList(), +) + +@Serializable +data class HealthPayload( + val status: String = "unknown", + val sessions: Int = 0, + val workers: Int = 0, +) + +@Serializable +data class Session( + val id: String, + val title: String, + val status: String, + val activeObjective: String = "", + val lastPlannerSummary: String = "", + val createdAt: String, + val updatedAt: String, +) + +@Serializable +data class Message( + val id: String, + val sessionId: String, + val role: String, + val channel: String, + val content: String, + val createdAt: String, +) + +@Serializable +data class TaskItem( + val id: String, + val sessionId: String, + val parentTaskId: String? = null, + val title: String, + val description: String, + val kind: String, + val status: String, + val priority: String, + val requiredOs: String, + val requiredCapabilities: List = emptyList(), + val dependencyIds: List = emptyList(), + val assignedWorkerId: String? = null, + val approvalStatus: String = "not_required", + val progressPercent: Int = 0, + val summary: String = "", + val currentStep: String = "", + val nextStep: String = "", + val createdAt: String, + val updatedAt: String, +) + +@Serializable +data class WorkerNode( + val id: String, + val name: String, + val os: String, + val capabilities: List = emptyList(), + val status: String, + val currentTaskId: String? = null, + val load: Int = 0, + val lastSeenAt: String, + val createdAt: String, + val updatedAt: String, +) + +@Serializable +data class ApprovalRequest( + val id: String, + val sessionId: String, + val taskId: String, + val kind: String, + val summary: String, + val riskLevel: String, + val status: String, + val requester: String, + val responder: String? = null, + val createdAt: String, + val updatedAt: String, +) + +@Serializable +data class BossEvent( + val id: String, + val sessionId: String? = null, + val taskId: String? = null, + val source: String, + val type: String, + val timestamp: String, + val payload: Map = emptyMap(), +) diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt new file mode 100644 index 0000000..9910150 --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/network/BossApi.kt @@ -0,0 +1,215 @@ +package site.hyzq.bossandroid.network + +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +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.HealthPayload +import site.hyzq.bossandroid.model.Session +import site.hyzq.bossandroid.model.TaskItem +import site.hyzq.bossandroid.model.WorkerNode +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class BossApi( + private val client: OkHttpClient = OkHttpClient(), + private val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + }, +) { + suspend fun getHealth(baseUrl: String): HealthPayload = get(baseUrl, "/api/health") + + suspend fun getBootstrap(baseUrl: String): AppStatePayload = get(baseUrl, "/api/bootstrap") + + suspend fun createSession(baseUrl: String, title: String): Session = post( + baseUrl = baseUrl, + path = "/api/sessions", + body = buildJsonObject { + put("title", JsonPrimitive(title)) + }, + ) + + suspend fun addMessage(baseUrl: String, sessionId: String, content: String): AppStatePayload { + post( + baseUrl = baseUrl, + path = "/api/sessions/$sessionId/messages", + body = buildJsonObject { + put("content", JsonPrimitive(content)) + put("channel", JsonPrimitive("android")) + }, + ) + return getBootstrap(baseUrl) + } + + suspend fun archiveSession(baseUrl: String, sessionId: String): AppStatePayload { + post(baseUrl, "/api/sessions/$sessionId/archive") + return getBootstrap(baseUrl) + } + + suspend fun restoreSession(baseUrl: String, sessionId: String): AppStatePayload { + post(baseUrl, "/api/sessions/$sessionId/restore") + return getBootstrap(baseUrl) + } + + suspend fun registerWorker( + baseUrl: String, + name: String, + os: String, + capabilities: List, + ): WorkerNode = post( + baseUrl = baseUrl, + path = "/api/workers/register", + body = buildJsonObject { + put("name", JsonPrimitive(name)) + put("os", JsonPrimitive(os)) + putJsonArray("capabilities") { + capabilities.forEach { add(JsonPrimitive(it)) } + } + }, + ) + + suspend fun markWorkerOffline(baseUrl: String, workerId: String): AppStatePayload { + post(baseUrl, "/api/workers/$workerId/offline") + return getBootstrap(baseUrl) + } + + suspend fun pauseTask(baseUrl: String, taskId: String): AppStatePayload { + post(baseUrl, "/api/tasks/$taskId/pause") + return getBootstrap(baseUrl) + } + + suspend fun cancelTask(baseUrl: String, taskId: String): AppStatePayload { + post(baseUrl, "/api/tasks/$taskId/cancel") + return getBootstrap(baseUrl) + } + + suspend fun resumeTask(baseUrl: String, taskId: String): AppStatePayload { + post(baseUrl, "/api/tasks/$taskId/resume") + return getBootstrap(baseUrl) + } + + suspend fun requeueTask(baseUrl: String, taskId: String): AppStatePayload { + post(baseUrl, "/api/tasks/$taskId/requeue") + return getBootstrap(baseUrl) + } + + suspend fun respondApproval( + baseUrl: String, + approvalId: String, + approved: Boolean, + ): AppStatePayload { + post( + baseUrl = baseUrl, + path = "/api/approvals/$approvalId/respond", + body = buildJsonObject { + put("approved", JsonPrimitive(approved)) + put("responder", JsonPrimitive("android-app")) + }, + ) + return getBootstrap(baseUrl) + } + + suspend fun reconcile(baseUrl: String): AppStatePayload { + post(baseUrl, "/api/reconcile") + return getBootstrap(baseUrl) + } + + private suspend inline fun get(baseUrl: String, path: String): T = request( + method = "GET", + baseUrl = baseUrl, + path = path, + body = null, + ) + + private suspend inline fun post( + baseUrl: String, + path: String, + body: JsonElement? = null, + ): T = request( + method = "POST", + baseUrl = baseUrl, + path = path, + body = body, + ) + + private suspend inline fun request( + method: String, + baseUrl: String, + path: String, + body: JsonElement?, + ): T = withContext(Dispatchers.IO) { + val normalizedBaseUrl = normalizeBaseUrl(baseUrl) + val requestBuilder = Request.Builder() + .url("$normalizedBaseUrl$path") + + if (method == "POST") { + val payload = json.encodeToString(body ?: JsonObject(emptyMap())) + requestBuilder.post(payload.toRequestBody(JSON_MEDIA_TYPE)) + } + + if (method == "GET") { + requestBuilder.get() + } + + client.newCall(requestBuilder.build()).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw IOException(parseErrorMessage(response.code, responseBody)) + } + if (responseBody.isBlank()) { + return@withContext json.decodeFromString("{}") + } + return@withContext json.decodeFromString(responseBody) + } + } + + private fun normalizeBaseUrl(input: String): String { + val trimmed = input.trim().trimEnd('/') + if (trimmed.isBlank()) { + return DEFAULT_BASE_URL + } + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "http://$trimmed" + } + } + + private fun parseErrorMessage(status: Int, body: String): String { + return try { + val payload = json.decodeFromString(body) + payload.message.ifBlank { "$status request failed" } + } catch (_: Exception) { + body.ifBlank { "$status request failed" } + } + } + + @Serializable + private data class ErrorPayload( + val error: String = "", + val message: String = "", + ) + + @Serializable + private data class UnitPayload( + val ok: Boolean = true, + ) + + companion object { + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + const val DEFAULT_BASE_URL = "http://111.231.132.51/boss" + } +} 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 new file mode 100644 index 0000000..939ade6 --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossApp.kt @@ -0,0 +1,1273 @@ +package site.hyzq.bossandroid.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Approval +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Computer +import androidx.compose.material.icons.outlined.Devices +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SyncAlt +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.time.Duration +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.Message +import site.hyzq.bossandroid.model.Session +import site.hyzq.bossandroid.model.TaskItem +import site.hyzq.bossandroid.model.WorkerNode + +private val TaskGroups = listOf( + "进行中" to listOf("assigned", "running"), + "等待处理" to listOf("planning", "queued"), + "等待审批或阻塞" to listOf("waiting_approval", "blocked", "paused"), + "已完成" to listOf("completed"), + "已结束" to listOf("failed", "cancelled"), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BossApp( + viewModel: BossViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(uiState.notice?.id) { + val notice = uiState.notice ?: return@LaunchedEffect + snackbarHostState.showSnackbar(notice.message) + viewModel.dismissNotice() + } + + val selectedSession = uiState.sessions.firstOrNull { it.id == uiState.selectedSessionId } + val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId } + + Scaffold( + contentWindowInsets = WindowInsets.navigationBars, + topBar = { + CenterAlignedTopAppBar( + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Boss 主控") + Text( + text = topBarSubtitle(uiState, selectedSession, selectedWorker), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + IconButton(onClick = { viewModel.refresh(showSpinner = true) }) { + Icon(Icons.Outlined.Refresh, contentDescription = "刷新") + } + }, + ) + }, + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = uiState.section == MainSection.CONVERSATIONS, + onClick = { viewModel.selectSection(MainSection.CONVERSATIONS) }, + icon = { Icon(Icons.Outlined.ChatBubbleOutline, contentDescription = null) }, + label = { Text("会话") }, + ) + NavigationBarItem( + selected = uiState.section == MainSection.DEVICES, + onClick = { viewModel.selectSection(MainSection.DEVICES) }, + icon = { Icon(Icons.Outlined.Devices, contentDescription = null) }, + label = { Text("设备") }, + ) + NavigationBarItem( + selected = uiState.section == MainSection.SETTINGS, + onClick = { viewModel.selectSection(MainSection.SETTINGS) }, + icon = { Icon(Icons.Outlined.Settings, contentDescription = null) }, + label = { Text("设置") }, + ) + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { padding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(padding), + color = MaterialTheme.colorScheme.background, + ) { + when (uiState.section) { + MainSection.CONVERSATIONS -> ConversationsScreen( + uiState = uiState, + selectedSession = selectedSession, + selectedWorker = selectedWorker, + onSelectSession = viewModel::selectSession, + onSelectTab = viewModel::selectConversationTab, + onCreateSession = viewModel::createSession, + onSendMessage = viewModel::sendMessage, + onArchiveSession = viewModel::archiveSelectedSession, + onRestoreSession = viewModel::restoreSelectedSession, + onPauseTask = viewModel::pauseTask, + onResumeTask = viewModel::resumeTask, + onCancelTask = viewModel::cancelTask, + onRequeueTask = viewModel::requeueTask, + onApprove = { viewModel.respondApproval(it, true) }, + onReject = { viewModel.respondApproval(it, false) }, + onClearWorkerFocus = { viewModel.selectWorker(null) }, + ) + + MainSection.DEVICES -> DevicesScreen( + uiState = uiState, + clipboard = clipboard, + onBindWorker = viewModel::registerWorker, + onSelectWorker = viewModel::selectWorker, + onMarkOffline = viewModel::markWorkerOffline, + onClearGeneratedCommand = viewModel::clearGeneratedCommand, + ) + + MainSection.SETTINGS -> SettingsScreen( + uiState = uiState, + onSaveBaseUrl = viewModel::saveBaseUrl, + onRefresh = { viewModel.refresh(showSpinner = true) }, + onReconcile = viewModel::reconcile, + ) + } + } + } +} + +@Composable +private fun ConversationsScreen( + uiState: BossUiState, + selectedSession: Session?, + selectedWorker: WorkerNode?, + onSelectSession: (String) -> Unit, + onSelectTab: (ConversationTab) -> Unit, + onCreateSession: (String) -> Unit, + onSendMessage: (String) -> Unit, + onArchiveSession: () -> Unit, + onRestoreSession: () -> Unit, + onPauseTask: (String) -> Unit, + onResumeTask: (String) -> Unit, + onCancelTask: (String) -> Unit, + onRequeueTask: (String) -> Unit, + onApprove: (String) -> Unit, + onReject: (String) -> Unit, + onClearWorkerFocus: () -> Unit, +) { + val sessionMessages = uiState.messages.filter { it.sessionId == selectedSession?.id } + val sessionTasks = uiState.tasks.filter { it.sessionId == selectedSession?.id } + val sessionApprovals = uiState.approvals.filter { it.sessionId == selectedSession?.id } + val sessionEvents = uiState.events.filter { event -> + event.sessionId == selectedSession?.id || event.sessionId == null + }.take(8) + val filteredTasks = sessionTasks.filter { task -> + uiState.selectedWorkerId == null || task.assignedWorkerId == uiState.selectedWorkerId + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CreateSessionCard(onCreateSession = onCreateSession) + SessionSelector( + sessions = uiState.sessions, + selectedSessionId = uiState.selectedSessionId, + onSelectSession = onSelectSession, + ) + + if (selectedWorker != null) { + FocusBanner( + title = "当前设备视角", + body = "已切换到 ${selectedWorker.name},任务列表会按这个设备过滤。", + actionLabel = "清除", + onAction = onClearWorkerFocus, + ) + } + + if (selectedSession == null) { + EmptyStateCard( + title = "还没有可用会话", + body = "先创建一个项目会话,然后在这里持续对话、改需求、审批和看进度。", + ) + return@Column + } + + SessionSummaryCard( + session = selectedSession, + workerCount = uiState.workers.size, + onArchive = onArchiveSession, + onRestore = onRestoreSession, + ) + + TabRow(selectedTabIndex = uiState.conversationTab.ordinal) { + ConversationTab.entries.forEach { tab -> + Tab( + selected = uiState.conversationTab == tab, + onClick = { onSelectTab(tab) }, + text = { + Text( + text = when (tab) { + ConversationTab.CHAT -> "对话" + ConversationTab.TASKS -> "任务" + ConversationTab.APPROVALS -> "审批" + }, + ) + }, + ) + } + } + + when (uiState.conversationTab) { + ConversationTab.CHAT -> ChatTab( + sessionMessages = sessionMessages, + sessionEvents = sessionEvents, + onSendMessage = onSendMessage, + ) + + ConversationTab.TASKS -> TasksTab( + tasks = filteredTasks, + workers = uiState.workers, + onPauseTask = onPauseTask, + onResumeTask = onResumeTask, + onCancelTask = onCancelTask, + onRequeueTask = onRequeueTask, + ) + + ConversationTab.APPROVALS -> ApprovalsTab( + approvals = sessionApprovals, + tasks = sessionTasks, + onApprove = onApprove, + onReject = onReject, + ) + } + } +} + +@Composable +private fun DevicesScreen( + uiState: BossUiState, + clipboard: androidx.compose.ui.platform.ClipboardManager, + onBindWorker: (String, String, String, String, String) -> Unit, + onSelectWorker: (String?) -> Unit, + onMarkOffline: (String) -> Unit, + onClearGeneratedCommand: () -> Unit, +) { + val selectedWorker = uiState.workers.firstOrNull { it.id == uiState.selectedWorkerId } + val relatedTasks = remember(uiState.tasks) { + uiState.tasks.associateBy { it.id } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BindDeviceCard( + baseUrl = uiState.baseUrl, + generatedCommand = uiState.generatedCommand, + clipboard = clipboard, + onBindWorker = onBindWorker, + onDismissCommand = onClearGeneratedCommand, + ) + + if (selectedWorker != null) { + FocusBanner( + title = "当前设备焦点", + body = "任务页已经切换到 ${selectedWorker.name} 的执行视角。", + actionLabel = "取消聚焦", + onAction = { onSelectWorker(null) }, + ) + } + + if (uiState.workers.isEmpty()) { + EmptyStateCard( + title = "还没有绑定设备", + body = "先在上面填写设备名称和操作系统,Boss 会生成对应的启动命令给你的 Windows 或 Mac。", + ) + } else { + SectionHeading("已绑定设备") + uiState.workers.forEach { worker -> + val task = worker.currentTaskId?.let { relatedTasks[it] } + WorkerCard( + worker = worker, + currentTask = task, + selected = worker.id == uiState.selectedWorkerId, + onSelect = { onSelectWorker(worker.id) }, + onMarkOffline = { onMarkOffline(worker.id) }, + ) + } + } + } +} + +@Composable +private fun SettingsScreen( + uiState: BossUiState, + onSaveBaseUrl: (String) -> Unit, + onRefresh: () -> Unit, + onReconcile: () -> Unit, +) { + var baseUrl by remember(uiState.baseUrl) { mutableStateOf(uiState.baseUrl) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("主控地址", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "默认已经指向云端 Boss。你也可以改成自己的内网地址或其它部署环境。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Server URL") }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { onSaveBaseUrl(baseUrl) }) { + Text("保存地址") + } + OutlinedButton(onClick = onRefresh) { + Text("立即刷新") + } + } + } + } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("系统状态", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + MetricRow("连接状态", if (uiState.isRefreshing) "同步中" else "在线") + MetricRow("会话数", uiState.health?.sessions?.toString() ?: uiState.sessions.size.toString()) + MetricRow("设备数", uiState.health?.workers?.toString() ?: uiState.workers.size.toString()) + MetricRow("最后同步", uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onReconcile) { + Icon(Icons.Outlined.SyncAlt, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("强制重排") + } + } + } + } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("APP 范围", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "这个安卓版本已经覆盖了主控对话、设备绑定、设备切换、任务查看、审批处理和主控地址切换。下一步可以继续补推送通知、SSE 实时流和扫码绑定。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun CreateSessionCard( + onCreateSession: (String) -> Unit, +) { + var title by rememberSaveable { mutableStateOf("") } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("新建会话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "先建立一个项目会话,然后所有需求变更、追进度和审批都从这个上下文继续。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("会话标题") }, + placeholder = { Text("例如:安卓端接入 Boss 主控") }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + Button( + onClick = { + onCreateSession(title) + title = "" + }, + ) { + Text("创建会话") + } + } + } +} + +@Composable +private fun SessionSelector( + sessions: List, + selectedSessionId: String?, + onSelectSession: (String) -> Unit, +) { + if (sessions.isEmpty()) { + return + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionHeading("项目会话") + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + sessions.forEach { session -> + FilterChip( + selected = session.id == selectedSessionId, + onClick = { onSelectSession(session.id) }, + label = { + Text( + text = session.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + } +} + +@Composable +private fun SessionSummaryCard( + session: Session, + workerCount: Int, + onArchive: () -> Unit, + onRestore: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column( + modifier = Modifier.padding(18.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.titleLarge, fontWeight = FontWeight.Bold) + Text( + "最近更新 ${formatRelative(session.updatedAt)}", + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + StatusChip(label = if (session.status == "archived") "已归档" else "活跃", tone = session.status) + } + + if (session.activeObjective.isNotBlank()) { + Text("当前目标:${session.activeObjective}") + } + Text( + text = if (session.lastPlannerSummary.isBlank()) { + "Boss 还没有产出新的规划摘要。先发一条消息,系统就会开始拆任务。" + } else { + session.lastPlannerSummary + }, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.86f), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AssistChip( + onClick = {}, + label = { Text("$workerCount 台设备在线管理") }, + leadingIcon = { Icon(Icons.Outlined.Computer, contentDescription = null) }, + ) + if (session.status == "archived") { + OutlinedButton(onClick = onRestore) { + Text("恢复会话") + } + } else { + OutlinedButton(onClick = onArchive) { + Text("归档会话") + } + } + } + } + } +} + +@Composable +private fun ChatTab( + sessionMessages: List, + sessionEvents: List, + onSendMessage: (String) -> Unit, +) { + var message by rememberSaveable { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("和 Boss 对话", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = message, + onValueChange = { message = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + label = { Text("需求变化 / 新任务 / 追问进度") }, + placeholder = { Text("例如:先别做 A,改成优先修登录问题。") }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + Button( + onClick = { + onSendMessage(message) + message = "" + }, + ) { + Text("发送给 Boss") + } + } + } + + SectionHeading("对话记录") + if (sessionMessages.isEmpty()) { + EmptyStateCard( + title = "还没有消息", + body = "发第一条需求,Boss 就会开始拆任务并持续回传进度。", + ) + } else { + sessionMessages.forEach { messageItem -> + MessageCard(messageItem) + } + } + + SectionHeading("最近事件") + if (sessionEvents.isEmpty()) { + EmptyStateCard(title = "暂无事件", body = "等待 Manager 或设备 worker 产生活动。") + } else { + sessionEvents.forEach { event -> + EventCard(event) + } + } + } +} + +@Composable +private fun TasksTab( + tasks: List, + workers: List, + onPauseTask: (String) -> Unit, + onResumeTask: (String) -> Unit, + onCancelTask: (String) -> Unit, + onRequeueTask: (String) -> Unit, +) { + if (tasks.isEmpty()) { + EmptyStateCard( + title = "还没有任务", + body = "Boss 收到你的消息后,会自动生成任务树并调度到设备。", + ) + return + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + TaskGroups.forEach { (title, statuses) -> + val groupTasks = tasks.filter { it.status in statuses } + if (groupTasks.isEmpty()) { + return@forEach + } + + SectionHeading(title) + groupTasks.forEach { task -> + TaskCard( + task = task, + worker = workers.firstOrNull { it.id == task.assignedWorkerId }, + onPauseTask = onPauseTask, + onResumeTask = onResumeTask, + onCancelTask = onCancelTask, + onRequeueTask = onRequeueTask, + ) + } + } + } +} + +@Composable +private fun ApprovalsTab( + approvals: List, + tasks: List, + onApprove: (String) -> Unit, + onReject: (String) -> Unit, +) { + if (approvals.isEmpty()) { + EmptyStateCard( + title = "暂无审批", + body = "高风险动作会自动出现在这里,比如危险命令、重写关键文件或合并策略变化。", + ) + return + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + approvals.forEach { approval -> + ApprovalCard( + approval = approval, + taskTitle = tasks.firstOrNull { it.id == approval.taskId }?.title, + onApprove = onApprove, + onReject = onReject, + ) + } + } +} + +@Composable +private fun BindDeviceCard( + baseUrl: String, + generatedCommand: GeneratedWorkerCommand?, + clipboard: androidx.compose.ui.platform.ClipboardManager, + onBindWorker: (String, String, String, String, String) -> Unit, + onDismissCommand: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + var os by rememberSaveable { mutableStateOf("macos") } + var capabilities by rememberSaveable { mutableStateOf("terminal,test") } + var workspace by rememberSaveable { mutableStateOf("") } + var executor by rememberSaveable { mutableStateOf("codex") } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("绑定新设备", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + "在这里登记设备身份,Boss 会直接生成对应启动命令。适合把 Windows、Mac 和 Linux 全部挂到同一个主控下。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("设备名称") }, + placeholder = { Text("例如:win-codex-01") }, + ) + OsSelector(selectedOs = os, onSelect = { os = it }) + ExecutorSelector(selectedExecutor = executor, onSelect = { executor = it }) + OutlinedTextField( + value = capabilities, + onValueChange = { capabilities = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("能力标签") }, + placeholder = { Text("terminal,test,browser") }, + ) + OutlinedTextField( + value = workspace, + onValueChange = { workspace = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("项目目录(可选)") }, + placeholder = { Text(if (os == "windows") "C:\\repo\\boss" else "/Users/you/repo/boss") }, + ) + Text( + "当前主控:$baseUrl", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = { + onBindWorker(name, os, capabilities, executor, workspace) + name = "" + }) { + Text("绑定并生成命令") + } + + if (generatedCommand != null) { + Spacer(Modifier.height(4.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + "${generatedCommand.workerName} 已就绪", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "把下面这条命令贴到对应设备的 ${generatedCommand.shellLabel} 里即可启动 worker。", + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(16.dp), + ) + .padding(12.dp), + ) { + Text( + text = generatedCommand.command, + style = MaterialTheme.typography.bodySmall, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { + clipboard.setText(AnnotatedString(generatedCommand.command)) + }) { + Text("复制命令") + } + TextButton(onClick = onDismissCommand) { + Text("收起") + } + } + } + } + } + } + } +} + +@Composable +private fun WorkerCard( + worker: WorkerNode, + currentTask: TaskItem?, + selected: Boolean, + onSelect: () -> Unit, + onMarkOffline: () -> Unit, +) { + val health = workerHealth(worker) + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.outlinedCardColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) + } 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(worker.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text("${worker.os} · 负载 ${worker.load}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + StatusChip(label = health.label, tone = health.tone) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + worker.capabilities.forEach { capability -> + AssistChip(onClick = {}, label = { Text(capability) }) + } + } + + Text( + text = currentTask?.let { + "当前任务:${it.title}" + } ?: "当前任务:空闲", + ) + Text( + "最近心跳 ${formatRelative(worker.lastSeenAt)}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onSelect) { + Text(if (selected) "已聚焦" else "切换到此设备") + } + OutlinedButton(onClick = onMarkOffline) { + Text("下线设备") + } + } + } + } +} + +@Composable +private fun MessageCard( + message: Message, +) { + val containerColor = when (message.role) { + "user" -> MaterialTheme.colorScheme.primaryContainer + "manager" -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = containerColor), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(roleLabel(message.role), fontWeight = FontWeight.SemiBold) + Text(formatClock(message.createdAt), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(message.content) + Text( + "入口:${message.channel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EventCard( + event: BossEvent, +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(event.type, fontWeight = FontWeight.SemiBold) + Text(formatClock(event.timestamp), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text("${event.source} · ${formatRelative(event.timestamp)}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun TaskCard( + task: TaskItem, + worker: WorkerNode?, + onPauseTask: (String) -> Unit, + onResumeTask: (String) -> Unit, + onCancelTask: (String) -> Unit, + onRequeueTask: (String) -> Unit, +) { + 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(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(task.kind, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + StatusChip(label = statusLabel(task.status), tone = task.status) + } + + LinearProgressIndicator( + progress = { task.progressPercent.coerceIn(0, 100) / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + + MetricRow("执行设备", worker?.name ?: "待分配") + MetricRow("当前步骤", task.currentStep.ifBlank { "等待开始" }) + MetricRow("下一步", task.nextStep.ifBlank { "等待计划" }) + if (task.summary.isNotBlank()) { + Text(task.summary) + } + if (task.description.isNotBlank()) { + Text( + task.description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + + 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("暂停") } + } + + 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("重排") + } + } + } + } + } +} + +@Composable +private fun ApprovalCard( + approval: ApprovalRequest, + taskTitle: String?, + onApprove: (String) -> Unit, + onReject: (String) -> Unit, +) { + 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(taskTitle ?: "未命名任务", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(approval.kind, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + StatusChip(label = riskLabel(approval.riskLevel), tone = approval.riskLevel) + } + Text(approval.summary) + Text( + "状态:${statusLabel(approval.status)} · ${formatRelative(approval.updatedAt)}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (approval.status == "pending") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { onApprove(approval.id) }) { + Icon(Icons.Outlined.Approval, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("批准") + } + OutlinedButton(onClick = { onReject(approval.id) }) { + Text("拒绝") + } + } + } + } + } +} + +@Composable +private fun FocusBanner( + title: String, + body: String, + actionLabel: String, + onAction: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.16f)), + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(body, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TextButton(onClick = onAction) { + Text(actionLabel) + } + } + } +} + +@Composable +private fun EmptyStateCard( + title: String, + body: String, +) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(body, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun OsSelector( + selectedOs: String, + onSelect: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("设备系统", fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("windows", "macos", "linux").forEach { os -> + FilterChip( + selected = selectedOs == os, + onClick = { onSelect(os) }, + label = { Text(os) }, + ) + } + } + } +} + +@Composable +private fun ExecutorSelector( + selectedExecutor: String, + onSelect: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("执行器", fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("codex", "claude").forEach { executor -> + FilterChip( + selected = selectedExecutor == executor, + onClick = { onSelect(executor) }, + label = { Text(executor) }, + ) + } + } + } +} + +@Composable +private fun StatusChip( + label: String, + tone: String, +) { + val colors = when (tone) { + "running", "assigned", "live", "approved" -> Pair(Color(0xFF0F766E), Color(0xFFE6FFFB)) + "queued", "planning", "idle", "medium" -> Pair(Color(0xFF9A6700), Color(0xFFFFF2CF)) + "paused", "blocked", "waiting_approval", "pending", "lagging" -> Pair(Color(0xFF92400E), Color(0xFFFFE7C2)) + "failed", "cancelled", "rejected", "offline", "stale", "high" -> Pair(Color(0xFFB42318), Color(0xFFFFE2E0)) + "archived" -> Pair(Color(0xFF475467), Color(0xFFF2F4F7)) + else -> Pair(Color(0xFF475467), Color(0xFFECEFF3)) + } + + Box( + modifier = Modifier + .background(colors.second, RoundedCornerShape(999.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + text = label, + color = colors.first, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun SectionHeading(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +private fun MetricRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, fontWeight = FontWeight.SemiBold) + } +} + +private fun topBarSubtitle( + uiState: BossUiState, + selectedSession: Session?, + selectedWorker: WorkerNode?, +): String { + return when (uiState.section) { + MainSection.CONVERSATIONS -> selectedSession?.title ?: "在手机上直接和 Boss 对话" + MainSection.DEVICES -> selectedWorker?.let { "设备焦点:${it.name}" } ?: "绑定、切换和检查设备" + MainSection.SETTINGS -> "云端地址、健康状态与重排" + } +} + +private fun roleLabel(role: String): String { + return when (role) { + "user" -> "你" + "manager" -> "Boss" + else -> "系统" + } +} + +private fun statusLabel(status: String): String { + return when (status) { + "planning" -> "规划中" + "queued" -> "排队中" + "assigned" -> "已分配" + "running" -> "执行中" + "blocked" -> "阻塞" + "paused" -> "已暂停" + "waiting_approval" -> "待审批" + "completed" -> "已完成" + "failed" -> "失败" + "cancelled" -> "已取消" + "pending" -> "待处理" + "approved" -> "已批准" + "rejected" -> "已拒绝" + else -> status + } +} + +private fun riskLabel(risk: String): String { + return when (risk) { + "high" -> "高风险" + "medium" -> "中风险" + else -> "低风险" + } +} + +private data class WorkerHealth( + val tone: String, + val label: String, +) + +private fun workerHealth(worker: WorkerNode): WorkerHealth { + if (worker.status == "offline") { + return WorkerHealth(tone = "offline", label = "离线") + } + + val ageSeconds = runCatching { + Duration.between(OffsetDateTime.parse(worker.lastSeenAt), OffsetDateTime.now()).seconds + }.getOrDefault(0) + + return when { + ageSeconds > 30 -> WorkerHealth(tone = "stale", label = "疑似掉线") + ageSeconds > 10 -> WorkerHealth(tone = "lagging", label = "连接抖动") + worker.status == "busy" -> WorkerHealth(tone = "running", label = "执行中") + else -> WorkerHealth(tone = "idle", label = "在线空闲") + } +} + +private fun formatClock(raw: String): String { + return runCatching { + OffsetDateTime.parse(raw).format(DateTimeFormatter.ofPattern("HH:mm")) + }.getOrDefault(raw) +} + +private fun formatRelative(raw: String): String { + val seconds = runCatching { + Duration.between(OffsetDateTime.parse(raw), OffsetDateTime.now()).seconds + }.getOrDefault(0) + + return when { + seconds < 10 -> "刚刚" + seconds < 60 -> "${seconds} 秒前" + seconds < 3600 -> "${seconds / 60} 分钟前" + seconds < 86400 -> "${seconds / 3600} 小时前" + else -> "${seconds / 86400} 天前" + } +} + +private fun formatLocalTimestamp(timestamp: Long): String { + return runCatching { + java.time.Instant.ofEpochMilli(timestamp) + .atZone(java.time.ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("MM-dd HH:mm")) + }.getOrDefault(timestamp.toString()) +} 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 new file mode 100644 index 0000000..c49b017 --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/BossViewModel.kt @@ -0,0 +1,471 @@ +package site.hyzq.bossandroid.ui + +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +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.HealthPayload +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.BossApi + +enum class MainSection { + CONVERSATIONS, + DEVICES, + SETTINGS, +} + +enum class ConversationTab { + CHAT, + TASKS, + APPROVALS, +} + +data class UiNotice( + val id: Long, + val message: String, +) + +data class GeneratedWorkerCommand( + val workerName: String, + val shellLabel: String, + val command: String, +) + +data class BossUiState( + val baseUrl: String = BossApi.DEFAULT_BASE_URL, + val section: MainSection = MainSection.CONVERSATIONS, + val conversationTab: ConversationTab = ConversationTab.CHAT, + val sessions: List = emptyList(), + val messages: List = emptyList(), + val tasks: List = emptyList(), + val approvals: List = emptyList(), + val workers: List = emptyList(), + val events: List = emptyList(), + val selectedSessionId: String? = null, + val selectedWorkerId: String? = null, + val health: HealthPayload? = null, + val isRefreshing: Boolean = false, + val generatedCommand: GeneratedWorkerCommand? = null, + val notice: UiNotice? = null, + val lastSyncedAt: Long? = null, +) + +class BossViewModel( + application: Application, +) : AndroidViewModel(application) { + private val api = BossApi() + private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val refreshMutex = Mutex() + private val _uiState = MutableStateFlow( + BossUiState( + baseUrl = sanitizeBaseUrl(prefs.getString(KEY_BASE_URL, BossApi.DEFAULT_BASE_URL).orEmpty()), + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var pollingJob: Job? = null + + init { + refresh(showSpinner = true) + startPolling() + } + + fun selectSection(section: MainSection) { + _uiState.update { it.copy(section = section) } + } + + fun selectConversationTab(tab: ConversationTab) { + _uiState.update { it.copy(conversationTab = tab) } + } + + fun selectSession(sessionId: String) { + _uiState.update { current -> + current.copy( + selectedSessionId = sessionId, + section = MainSection.CONVERSATIONS, + ) + } + } + + fun selectWorker(workerId: String?) { + _uiState.update { current -> + current.copy( + selectedWorkerId = workerId, + section = if (workerId == null) current.section else MainSection.DEVICES, + ) + } + } + + fun saveBaseUrl(input: String) { + val sanitized = sanitizeBaseUrl(input) + prefs.edit().putString(KEY_BASE_URL, sanitized).apply() + _uiState.update { it.copy(baseUrl = sanitized) } + refresh(showSpinner = true) + } + + fun dismissNotice() { + _uiState.update { it.copy(notice = null) } + } + + fun clearGeneratedCommand() { + _uiState.update { it.copy(generatedCommand = null) } + } + + fun refresh(showSpinner: Boolean = false) { + viewModelScope.launch { + refreshSnapshot(showSpinner = showSpinner) + } + } + + fun createSession(title: String) { + runMutation(successMessage = "已创建新会话。") { + val session = api.createSession(currentBaseUrl(), title.trim().ifBlank { "未命名项目" }) + val snapshot = api.getBootstrap(currentBaseUrl()) + applySnapshot(snapshot, preferredSessionId = session.id) + } + } + + fun sendMessage(content: String) { + val sessionId = uiState.value.selectedSessionId ?: return + val message = content.trim() + if (message.isBlank()) { + publishNotice("消息不能为空。") + return + } + + runMutation(successMessage = "需求已发送给 Boss。") { + val snapshot = api.addMessage(currentBaseUrl(), sessionId, message) + applySnapshot(snapshot, preferredSessionId = sessionId) + } + } + + fun archiveSelectedSession() { + val sessionId = uiState.value.selectedSessionId ?: return + runMutation(successMessage = "会话已归档。") { + val snapshot = api.archiveSession(currentBaseUrl(), sessionId) + applySnapshot(snapshot, preferredSessionId = sessionId) + } + } + + fun restoreSelectedSession() { + val sessionId = uiState.value.selectedSessionId ?: return + runMutation(successMessage = "会话已恢复。") { + val snapshot = api.restoreSession(currentBaseUrl(), sessionId) + applySnapshot(snapshot, preferredSessionId = sessionId) + } + } + + fun registerWorker( + name: String, + os: String, + capabilitiesInput: String, + executorType: String, + workspaceInput: String, + ) { + val normalizedName = name.trim() + if (normalizedName.isBlank()) { + publishNotice("设备名称不能为空。") + return + } + + val capabilities = capabilitiesInput + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .ifEmpty { listOf("terminal") } + + runMutation(successMessage = "设备已绑定到 Boss。") { + val worker = api.registerWorker( + baseUrl = currentBaseUrl(), + name = normalizedName, + os = os, + capabilities = capabilities, + ) + val snapshot = api.getBootstrap(currentBaseUrl()) + applySnapshot(snapshot, preferredWorkerId = worker.id) + _uiState.update { current -> + current.copy( + generatedCommand = buildWorkerCommand( + baseUrl = current.baseUrl, + worker = worker, + executorType = executorType, + workspaceInput = workspaceInput, + ), + ) + } + } + } + + fun markWorkerOffline(workerId: String) { + runMutation(successMessage = "设备已下线。") { + val snapshot = api.markWorkerOffline(currentBaseUrl(), workerId) + applySnapshot(snapshot) + } + } + + fun pauseTask(taskId: String) { + runTaskMutation(taskId, "任务已暂停。") { + api.pauseTask(currentBaseUrl(), taskId) + } + } + + fun resumeTask(taskId: String) { + runTaskMutation(taskId, "任务已恢复并重新排队。") { + api.resumeTask(currentBaseUrl(), taskId) + } + } + + fun cancelTask(taskId: String) { + runTaskMutation(taskId, "任务已取消。") { + api.cancelTask(currentBaseUrl(), taskId) + } + } + + fun requeueTask(taskId: String) { + runTaskMutation(taskId, "任务已重新排队。") { + api.requeueTask(currentBaseUrl(), taskId) + } + } + + fun respondApproval(approvalId: String, approved: Boolean) { + val feedback = if (approved) "审批已通过。" else "审批已拒绝。" + runMutation(successMessage = feedback) { + val snapshot = api.respondApproval(currentBaseUrl(), approvalId, approved) + applySnapshot(snapshot) + } + } + + fun reconcile() { + runMutation(successMessage = "系统已触发一次重排。") { + val snapshot = api.reconcile(currentBaseUrl()) + applySnapshot(snapshot) + } + } + + private fun runTaskMutation( + taskId: String, + successMessage: String, + operation: suspend () -> AppStatePayload, + ) { + val sessionId = uiState.value.tasks.firstOrNull { it.id == taskId }?.sessionId + runMutation(successMessage = successMessage) { + val snapshot = operation() + applySnapshot(snapshot, preferredSessionId = sessionId) + } + } + + private fun runMutation( + successMessage: String, + operation: suspend () -> Unit, + ) { + viewModelScope.launch { + try { + _uiState.update { it.copy(isRefreshing = true) } + operation() + publishNotice(successMessage) + } catch (error: Throwable) { + if (error is CancellationException) { + throw error + } + publishNotice(error.message ?: "请求失败。") + } finally { + _uiState.update { it.copy(isRefreshing = false) } + } + } + } + + private suspend fun refreshSnapshot(showSpinner: Boolean) { + refreshMutex.withLock { + try { + if (showSpinner) { + _uiState.update { it.copy(isRefreshing = true) } + } + val snapshot = api.getBootstrap(currentBaseUrl()) + applySnapshot(snapshot) + } catch (error: Throwable) { + if (error is CancellationException) { + throw error + } + if (showSpinner) { + publishNotice(error.message ?: "无法连接 Boss 控制面。") + } + } finally { + _uiState.update { it.copy(isRefreshing = false) } + } + } + } + + private fun applySnapshot( + snapshot: AppStatePayload, + preferredSessionId: String? = uiState.value.selectedSessionId, + preferredWorkerId: String? = uiState.value.selectedWorkerId, + ) { + val sessions = snapshot.sessions.sortedByDescending { it.updatedAt } + val messages = snapshot.messages.sortedBy { it.createdAt } + val tasks = snapshot.tasks.sortedByDescending { it.updatedAt } + val approvals = snapshot.approvals.sortedByDescending { it.updatedAt } + val workers = snapshot.workers.sortedBy { it.name.lowercase() } + val events = snapshot.events.sortedByDescending { it.timestamp } + + val selectedSessionId = preferredSessionId + ?.takeIf { candidate -> sessions.any { it.id == candidate } } + ?: sessions.firstOrNull { it.status == "active" }?.id + ?: sessions.firstOrNull()?.id + + val selectedWorkerId = preferredWorkerId + ?.takeIf { candidate -> workers.any { it.id == candidate } } + + _uiState.update { current -> + current.copy( + sessions = sessions, + messages = messages, + tasks = tasks, + approvals = approvals, + workers = workers, + events = events, + health = HealthPayload( + status = "ok", + sessions = sessions.size, + workers = workers.size, + ), + selectedSessionId = selectedSessionId, + selectedWorkerId = selectedWorkerId, + lastSyncedAt = System.currentTimeMillis(), + ) + } + } + + private fun publishNotice(message: String) { + _uiState.update { current -> + current.copy( + notice = UiNotice( + id = System.currentTimeMillis(), + message = message, + ), + ) + } + } + + private fun currentBaseUrl(): String = uiState.value.baseUrl + + private fun startPolling() { + pollingJob?.cancel() + pollingJob = viewModelScope.launch { + while (isActive) { + delay(POLL_INTERVAL_MS) + refreshSnapshot(showSpinner = false) + } + } + } + + private fun sanitizeBaseUrl(input: String): String { + val trimmed = input.trim().trimEnd('/') + if (trimmed.isBlank()) { + return BossApi.DEFAULT_BASE_URL + } + return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "http://$trimmed" + } + } + + 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 POLL_INTERVAL_MS = 4_500L + private const val PREFS_NAME = "boss_android" + } +} diff --git a/android-app/app/src/main/java/site/hyzq/bossandroid/ui/theme/Theme.kt b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/theme/Theme.kt new file mode 100644 index 0000000..69a9651 --- /dev/null +++ b/android-app/app/src/main/java/site/hyzq/bossandroid/ui/theme/Theme.kt @@ -0,0 +1,55 @@ +package site.hyzq.bossandroid.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val LightPalette = lightColorScheme( + primary = Color(0xFFB7791F), + onPrimary = Color(0xFFFFFBF5), + primaryContainer = Color(0xFFF4D9AA), + onPrimaryContainer = Color(0xFF2A1800), + secondary = Color(0xFF23575A), + onSecondary = Color(0xFFF2FFFE), + secondaryContainer = Color(0xFFC3ECEB), + onSecondaryContainer = Color(0xFF082022), + tertiary = Color(0xFF394B7A), + background = Color(0xFFF7F3EA), + surface = Color(0xFFFFFBF5), + surfaceVariant = Color(0xFFE9E0CF), + onSurface = Color(0xFF151515), + onSurfaceVariant = Color(0xFF4F4638), + error = Color(0xFFB3261E), +) + +private val DarkPalette = darkColorScheme( + primary = Color(0xFFF4B740), + onPrimary = Color(0xFF3F2800), + primaryContainer = Color(0xFF6C4800), + onPrimaryContainer = Color(0xFFFFE2A8), + secondary = Color(0xFF8FD4D1), + onSecondary = Color(0xFF003738), + secondaryContainer = Color(0xFF145153), + onSecondaryContainer = Color(0xFFC4F1EE), + tertiary = Color(0xFFBBC7FF), + background = Color(0xFF111318), + surface = Color(0xFF171A20), + surfaceVariant = Color(0xFF2B313B), + onSurface = Color(0xFFEAE2D5), + onSurfaceVariant = Color(0xFFD1C6B4), + error = Color(0xFFFFB4AB), +) + +@Composable +fun BossAndroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkPalette else LightPalette, + content = content, + ) +} diff --git a/android-app/app/src/main/res/drawable/ic_boss_badge.xml b/android-app/app/src/main/res/drawable/ic_boss_badge.xml new file mode 100644 index 0000000..967719b --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_boss_badge.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4bed3aa --- /dev/null +++ b/android-app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Boss + diff --git a/android-app/app/src/main/res/values/styles.xml b/android-app/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..1874453 --- /dev/null +++ b/android-app/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android-app/app/src/main/res/xml/network_security_config.xml b/android-app/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..d4780f8 --- /dev/null +++ b/android-app/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts new file mode 100644 index 0000000..0c51a70 --- /dev/null +++ b/android-app/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false +} diff --git a/android-app/gradle.properties b/android-app/gradle.properties new file mode 100644 index 0000000..af6bf88 --- /dev/null +++ b/android-app/gradle.properties @@ -0,0 +1,4 @@ +android.nonTransitiveRClass=true +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 diff --git a/android-app/gradle/wrapper/gradle-wrapper.jar b/android-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/android-app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/android-app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android-app/gradlew b/android-app/gradlew new file mode 100755 index 0000000..0262dcb --- /dev/null +++ b/android-app/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android-app/gradlew.bat b/android-app/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/android-app/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts new file mode 100644 index 0000000..e5e5987 --- /dev/null +++ b/android-app/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "BossAndroid" +include(":app") diff --git a/scripts/claude_executor.ps1 b/scripts/claude_executor.ps1 new file mode 100644 index 0000000..3ac138f --- /dev/null +++ b/scripts/claude_executor.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = "Stop" + +$workspace = if ($env:BOSS_WORKSPACE) { $env:BOSS_WORKSPACE } else { (Get-Location).Path } +$taskTitle = if ($env:BOSS_TASK_TITLE) { $env:BOSS_TASK_TITLE } else { "Untitled task" } +$taskKind = if ($env:BOSS_TASK_KIND) { $env:BOSS_TASK_KIND } else { "general" } +$taskDescription = if ($env:BOSS_TASK_DESCRIPTION) { $env:BOSS_TASK_DESCRIPTION } else { "No description provided." } + +if (-not (Get-Command claude -ErrorAction SilentlyContinue)) { + Write-Error "claude CLI not found in PATH" +} + +Set-Location $workspace + +$prompt = @" +You are the device-side execution worker for Boss. + +Task title: $taskTitle +Task kind: $taskKind +Task description: +$taskDescription + +Work only inside this workspace: +$workspace + +Expectations: +- Make the smallest correct change that moves the task forward. +- If you modify code, mention validation or remaining risks in the final summary. +- If the task is research-only, summarize findings and next steps instead of forcing edits. +"@ + +$extraFlags = @() +if ($env:BOSS_CLAUDE_FLAGS) { + $extraFlags = $env:BOSS_CLAUDE_FLAGS -split "\s+" +} + +& claude --print @extraFlags $prompt +exit $LASTEXITCODE diff --git a/scripts/codex_executor.ps1 b/scripts/codex_executor.ps1 new file mode 100644 index 0000000..2cb0b2e --- /dev/null +++ b/scripts/codex_executor.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = "Stop" + +$workspace = if ($env:BOSS_WORKSPACE) { $env:BOSS_WORKSPACE } else { (Get-Location).Path } +$taskTitle = if ($env:BOSS_TASK_TITLE) { $env:BOSS_TASK_TITLE } else { "Untitled task" } +$taskKind = if ($env:BOSS_TASK_KIND) { $env:BOSS_TASK_KIND } else { "general" } +$taskDescription = if ($env:BOSS_TASK_DESCRIPTION) { $env:BOSS_TASK_DESCRIPTION } else { "No description provided." } + +if (-not (Get-Command codex -ErrorAction SilentlyContinue)) { + Write-Error "codex CLI not found in PATH" +} + +Set-Location $workspace + +$prompt = @" +You are the device-side execution worker for Boss. + +Task title: $taskTitle +Task kind: $taskKind +Task description: +$taskDescription + +Work only inside this workspace: +$workspace + +Expectations: +- Make the smallest correct change that moves the task forward. +- If you modify code, mention validation or remaining risks in the final summary. +- If the task is research-only, summarize findings and next steps instead of forcing edits. +"@ + +$extraFlags = @() +if ($env:BOSS_CODEX_FLAGS) { + $extraFlags = $env:BOSS_CODEX_FLAGS -split "\s+" +} + +& codex exec @extraFlags $prompt +exit $LASTEXITCODE