From 84ccd83adaa00945f010098b9ddb5a7bdb25d420 Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 28 Feb 2026 13:41:03 +0900 Subject: [PATCH] refactor: simplify setup wizard to 4 steps and add degraded gateway mode Remove Step 4 (WS+Agent settings) from the setup wizard, consolidating init() and complete() API calls into a single atomic operation at the final step. Reorder gateway startup so HTTP and Channels start before the LLM provider, allowing degraded mode when LLM config is missing. Add setupRequired StateFlow to WebSocketClient for reliable detection, directory picker to workspace step, and back navigation with animations. Co-Authored-By: Claude Opus 4.6 --- .../main/java/io/clawdroid/MainActivity.kt | 6 +- .../io/clawdroid/setup/SetupCompleteScreen.kt | 2 +- .../setup/SetupStep1GatewayScreen.kt | 10 +- .../io/clawdroid/setup/SetupStep2LlmScreen.kt | 2 +- .../setup/SetupStep3WorkspaceScreen.kt | 85 ++++++-- .../clawdroid/setup/SetupStep4ChatScreen.kt | 175 ----------------- .../java/io/clawdroid/setup/SetupViewModel.kt | 144 +++++--------- .../io/clawdroid/setup/SetupWizardScreen.kt | 19 +- .../core/data/remote/WebSocketClient.kt | 7 + cmd/clawdroid/main.go | 181 ++++++++++-------- 10 files changed, 248 insertions(+), 383 deletions(-) delete mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt diff --git a/android/app/src/main/java/io/clawdroid/MainActivity.kt b/android/app/src/main/java/io/clawdroid/MainActivity.kt index a82047294..806822d50 100644 --- a/android/app/src/main/java/io/clawdroid/MainActivity.kt +++ b/android/app/src/main/java/io/clawdroid/MainActivity.kt @@ -45,10 +45,10 @@ class MainActivity : ComponentActivity() { val navController = rememberNavController() val wsClient: WebSocketClient = koinInject() - // Observe setup_required messages from server + // Observe setup_required state from server LaunchedEffect(Unit) { - wsClient.incomingMessages.collect { msg -> - if (msg.type == "setup_required") { + wsClient.setupRequired.collect { required -> + if (required) { val current = navController.currentDestination?.route if (current != NavRoutes.SETUP) { navController.navigate(NavRoutes.SETUP) { diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt index 56b1b4b41..b2392502c 100644 --- a/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt +++ b/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt @@ -37,7 +37,7 @@ fun SetupCompleteScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text("Step 5 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) + Text("Step 4 of 4", style = MaterialTheme.typography.labelMedium, color = TextSecondary) Spacer(Modifier.height(16.dp)) diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt index a6121e6a6..87a5d5652 100644 --- a/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -51,7 +49,7 @@ fun SetupStep1GatewayScreen(viewModel: SetupViewModel) { Spacer(Modifier.height(32.dp)) Text( - "Step 1 of 5", + "Step 1 of 4", style = MaterialTheme.typography.labelMedium, color = TextSecondary, ) @@ -120,14 +118,10 @@ fun SetupStep1GatewayScreen(viewModel: SetupViewModel) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, ) { - if (uiState.loading) { - CircularProgressIndicator(color = NeonCyan, modifier = Modifier.padding(end = 16.dp)) - } Button( onClick = viewModel::submitInit, - enabled = uiState.canProceedStep1 && !uiState.loading, + enabled = uiState.canProceedStep1, colors = ButtonDefaults.buttonColors( containerColor = NeonCyan, contentColor = DeepBlack, diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt index 81a19ec63..019a84414 100644 --- a/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt @@ -45,7 +45,7 @@ fun SetupStep2LlmScreen(viewModel: SetupViewModel) { ) { Spacer(Modifier.height(32.dp)) - Text("Step 2 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) + Text("Step 2 of 4", style = MaterialTheme.typography.labelMedium, color = TextSecondary) Text("LLM Settings", style = MaterialTheme.typography.headlineMedium, color = TextPrimary) Text( "Configure the language model used by the agent.", diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt index daffb0439..e9fee72ed 100644 --- a/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt @@ -1,5 +1,8 @@ package io.clawdroid.setup +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,10 +11,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -20,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import io.clawdroid.core.ui.theme.DeepBlack import io.clawdroid.core.ui.theme.NeonCyan @@ -30,6 +38,18 @@ import io.clawdroid.core.ui.theme.TextSecondary fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) { val uiState by viewModel.uiState.collectAsState() + val workspacePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri: Uri? -> + uri?.let { viewModel.onWorkspaceChange(uriToPath(it)) } + } + + val dataDirPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + ) { uri: Uri? -> + uri?.let { viewModel.onDataDirChange(uriToPath(it)) } + } + Column( modifier = Modifier .fillMaxSize() @@ -39,7 +59,7 @@ fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) { ) { Spacer(Modifier.height(32.dp)) - Text("Step 3 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) + Text("Step 3 of 4", style = MaterialTheme.typography.labelMedium, color = TextSecondary) Text("Workspace & Data", style = MaterialTheme.typography.headlineMedium, color = TextPrimary) Text( "Set the workspace and data directories used by the agent.", @@ -49,24 +69,20 @@ fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) { Spacer(Modifier.height(8.dp)) - OutlinedTextField( + DirectoryField( value = uiState.workspace, onValueChange = viewModel::onWorkspaceChange, - label = { Text("Workspace", color = TextSecondary) }, - placeholder = { Text("~/.clawdroid/workspace", color = TextSecondary.copy(alpha = 0.5f)) }, - singleLine = true, - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), + label = "Workspace", + placeholder = "~/.clawdroid/workspace", + onBrowse = { workspacePicker.launch(null) }, ) - OutlinedTextField( + DirectoryField( value = uiState.dataDir, onValueChange = viewModel::onDataDirChange, - label = { Text("Data Directory", color = TextSecondary) }, - placeholder = { Text("~/.clawdroid/data", color = TextSecondary.copy(alpha = 0.5f)) }, - singleLine = true, - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), + label = "Data Directory", + placeholder = "~/.clawdroid/data", + onBrowse = { dataDirPicker.launch(null) }, ) Spacer(Modifier.weight(1f)) @@ -90,3 +106,46 @@ fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) { } } } + +@Composable +private fun DirectoryField( + value: String, + onValueChange: (String) -> Unit, + label: String, + placeholder: String, + onBrowse: () -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label, color = TextSecondary) }, + placeholder = { Text(placeholder, color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = onBrowse, + colors = IconButtonDefaults.iconButtonColors(contentColor = NeonCyan), + ) { + Icon( + painter = painterResource(android.R.drawable.ic_menu_agenda), + contentDescription = "Browse", + modifier = Modifier.size(20.dp), + ) + } + }, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) +} + +private fun uriToPath(uri: Uri): String { + // content://com.android.externalstorage.documents/tree/primary%3ADocuments + // → /storage/emulated/0/Documents + val docId = uri.lastPathSegment ?: return uri.toString() + val parts = docId.split(":") + return if (parts.size == 2 && parts[0] == "primary") { + "/storage/emulated/0/${parts[1]}" + } else { + uri.path ?: uri.toString() + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt deleted file mode 100644 index b74e536c0..000000000 --- a/android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt +++ /dev/null @@ -1,175 +0,0 @@ -package io.clawdroid.setup - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import io.clawdroid.core.ui.theme.DeepBlack -import io.clawdroid.core.ui.theme.NeonCyan -import io.clawdroid.core.ui.theme.TextPrimary -import io.clawdroid.core.ui.theme.TextSecondary - -@Composable -fun SetupStep4ChatScreen(viewModel: SetupViewModel) { - val uiState by viewModel.uiState.collectAsState() - var wsApiKeyHidden by remember { mutableStateOf(true) } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Spacer(Modifier.height(32.dp)) - - Text("Step 4 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) - Text("WebSocket & Agent", style = MaterialTheme.typography.headlineMedium, color = TextPrimary) - Text( - "Configure the WebSocket channel and agent parameters.", - style = MaterialTheme.typography.bodyMedium, - color = TextSecondary, - ) - - Spacer(Modifier.height(4.dp)) - - // WebSocket section - Text("WebSocket", style = MaterialTheme.typography.titleSmall, color = NeonCyan) - - OutlinedTextField( - value = uiState.wsHost, - onValueChange = viewModel::onWsHostChange, - label = { Text("Host", color = TextSecondary) }, - singleLine = true, - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.wsPort, - onValueChange = viewModel::onWsPortChange, - label = { Text("Port", color = TextSecondary) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.wsPath, - onValueChange = viewModel::onWsPathChange, - label = { Text("Path", color = TextSecondary) }, - singleLine = true, - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.wsApiKey, - onValueChange = viewModel::onWsApiKeyChange, - label = { Text("WS API Key", color = TextSecondary) }, - singleLine = true, - visualTransformation = if (wsApiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, - trailingIcon = { - TextButton(onClick = { wsApiKeyHidden = !wsApiKeyHidden }) { - Text( - if (wsApiKeyHidden) "Show" else "Hide", - color = NeonCyan, - style = MaterialTheme.typography.labelSmall, - ) - } - }, - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(Modifier.height(8.dp)) - - // Agent section - Text("Agent Defaults", style = MaterialTheme.typography.titleSmall, color = NeonCyan) - - OutlinedTextField( - value = uiState.maxTokens, - onValueChange = viewModel::onMaxTokensChange, - label = { Text("Max Tokens", color = TextSecondary) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.contextWindow, - onValueChange = viewModel::onContextWindowChange, - label = { Text("Context Window", color = TextSecondary) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.temperature, - onValueChange = viewModel::onTemperatureChange, - label = { Text("Temperature", color = TextSecondary) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = uiState.maxToolIterations, - onValueChange = viewModel::onMaxToolIterationsChange, - label = { Text("Max Tool Iterations", color = TextSecondary) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = setupFieldColors(), - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(Modifier.weight(1f)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextButton(onClick = { viewModel.skipStep(4) }) { - Text("Set up later", color = TextSecondary) - } - Button( - onClick = { viewModel.nextStep(4) }, - colors = ButtonDefaults.buttonColors( - containerColor = NeonCyan, - contentColor = DeepBlack, - ), - ) { - Text("Next") - } - } - } -} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt b/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt index 372b42b05..e751835a6 100644 --- a/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt +++ b/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt @@ -30,16 +30,6 @@ data class SetupUiState( val workspace: String = "", val dataDir: String = "", val step3Skipped: Boolean = false, - // Step 4: WS + Agent - val wsHost: String = "127.0.0.1", - val wsPort: String = "18793", - val wsPath: String = "/ws", - val wsApiKey: String = "", - val maxTokens: String = "8192", - val contextWindow: String = "128000", - val temperature: String = "0", - val maxToolIterations: String = "10", - val step4Skipped: Boolean = false, ) { val gatewayPortError: String? get() { @@ -81,59 +71,12 @@ class SetupViewModel( fun onWorkspaceChange(value: String) = _uiState.update { it.copy(workspace = value) } fun onDataDirChange(value: String) = _uiState.update { it.copy(dataDir = value) } - fun onWsHostChange(value: String) = _uiState.update { it.copy(wsHost = value) } - fun onWsPortChange(value: String) { - if (value.isEmpty() || value.toIntOrNull() != null) { - _uiState.update { it.copy(wsPort = value) } - } - } - fun onWsPathChange(value: String) = _uiState.update { it.copy(wsPath = value) } - fun onWsApiKeyChange(value: String) = _uiState.update { it.copy(wsApiKey = value) } - fun onMaxTokensChange(value: String) { - if (value.isEmpty() || value.toIntOrNull() != null) { - _uiState.update { it.copy(maxTokens = value) } - } - } - fun onContextWindowChange(value: String) { - if (value.isEmpty() || value.toIntOrNull() != null) { - _uiState.update { it.copy(contextWindow = value) } - } - } - fun onTemperatureChange(value: String) { - if (value.isEmpty() || value.toDoubleOrNull() != null) { - _uiState.update { it.copy(temperature = value) } - } - } - fun onMaxToolIterationsChange(value: String) { - if (value.isEmpty() || value.toIntOrNull() != null) { - _uiState.update { it.copy(maxToolIterations = value) } - } - } + fun submitInit() { - viewModelScope.launch { - val state = _uiState.value - if (!state.canProceedStep1 || state.loading) return@launch - - _uiState.update { it.copy(loading = true, error = null) } - - val port = state.gatewayPort.toIntOrNull() ?: 18790 - val body = buildJsonObject { - put("gateway", buildJsonObject { - put("port", JsonPrimitive(port)) - put("api_key", JsonPrimitive(state.gatewayApiKey)) - }) - } - - try { - setupApiClient.init(body) - // Persist gateway settings locally - settingsStore.update(GatewaySettings(httpPort = port, apiKey = state.gatewayApiKey)) - _uiState.update { it.copy(loading = false, step1Done = true, currentStep = 1) } - } catch (e: Exception) { - _uiState.update { it.copy(loading = false, error = e.message ?: "Init failed") } - } - } + val state = _uiState.value + if (!state.canProceedStep1) return + _uiState.update { it.copy(step1Done = true, currentStep = 1) } } fun skipStep(step: Int) { @@ -141,7 +84,6 @@ class SetupViewModel( when (step) { 2 -> it.copy(step2Skipped = true, currentStep = 2) 3 -> it.copy(step3Skipped = true, currentStep = 3) - 4 -> it.copy(step4Skipped = true, currentStep = 4) else -> it } } @@ -151,6 +93,12 @@ class SetupViewModel( _uiState.update { it.copy(currentStep = step) } } + fun previousStep() { + _uiState.update { + if (it.currentStep > 0) it.copy(currentStep = it.currentStep - 1) else it + } + } + fun submitComplete(onComplete: () -> Unit) { viewModelScope.launch { val state = _uiState.value @@ -158,48 +106,44 @@ class SetupViewModel( _uiState.update { it.copy(loading = true, error = null) } - val body = buildJsonObject { - if (!state.step2Skipped) { - put("llm", buildJsonObject { - if (state.llmModel.isNotBlank()) put("model", JsonPrimitive(state.llmModel)) - if (state.llmApiKey.isNotBlank()) put("api_key", JsonPrimitive(state.llmApiKey)) - if (state.llmBaseUrl.isNotBlank()) put("base_url", JsonPrimitive(state.llmBaseUrl)) - }) - } - if (!state.step3Skipped) { - put("agents", buildJsonObject { - put("defaults", buildJsonObject { - if (state.workspace.isNotBlank()) put("workspace", JsonPrimitive(state.workspace)) - if (state.dataDir.isNotBlank()) put("data_dir", JsonPrimitive(state.dataDir)) - }) - }) - } - if (!state.step4Skipped) { - put("channels", buildJsonObject { - put("websocket", buildJsonObject { - if (state.wsHost.isNotBlank()) put("host", JsonPrimitive(state.wsHost)) - state.wsPort.toIntOrNull()?.let { put("port", JsonPrimitive(it)) } - if (state.wsPath.isNotBlank()) put("path", JsonPrimitive(state.wsPath)) - if (state.wsApiKey.isNotBlank()) put("api_key", JsonPrimitive(state.wsApiKey)) - }) - }) - put("agents_extra", buildJsonObject { - put("defaults", buildJsonObject { - state.maxTokens.toIntOrNull()?.let { put("max_tokens", JsonPrimitive(it)) } - state.contextWindow.toIntOrNull()?.let { put("context_window", JsonPrimitive(it)) } - state.temperature.toDoubleOrNull()?.let { put("temperature", JsonPrimitive(it)) } - state.maxToolIterations.toIntOrNull()?.let { put("max_tool_iterations", JsonPrimitive(it)) } - }) - }) - } - } - try { - setupApiClient.complete(body) + // 1. Create config.json with gateway settings + val port = state.gatewayPort.toIntOrNull() ?: 18790 + val initBody = buildJsonObject { + put("gateway", buildJsonObject { + put("port", JsonPrimitive(port)) + put("api_key", JsonPrimitive(state.gatewayApiKey)) + }) + } + setupApiClient.init(initBody) + + // 2. Persist gateway settings locally so complete() can authenticate + settingsStore.update(GatewaySettings(httpPort = port, apiKey = state.gatewayApiKey)) + + // 3. Merge remaining settings into config.json + val completeBody = buildJsonObject { + if (!state.step2Skipped) { + put("llm", buildJsonObject { + if (state.llmModel.isNotBlank()) put("model", JsonPrimitive(state.llmModel)) + if (state.llmApiKey.isNotBlank()) put("api_key", JsonPrimitive(state.llmApiKey)) + if (state.llmBaseUrl.isNotBlank()) put("base_url", JsonPrimitive(state.llmBaseUrl)) + }) + } + if (!state.step3Skipped) { + put("agents", buildJsonObject { + put("defaults", buildJsonObject { + if (state.workspace.isNotBlank()) put("workspace", JsonPrimitive(state.workspace)) + if (state.dataDir.isNotBlank()) put("data_dir", JsonPrimitive(state.dataDir)) + }) + }) + } + } + setupApiClient.complete(completeBody) + _uiState.update { it.copy(loading = false) } onComplete() } catch (e: Exception) { - _uiState.update { it.copy(loading = false, error = e.message ?: "Complete failed") } + _uiState.update { it.copy(loading = false, error = e.message ?: "Setup failed") } } } } diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt index 3264a6888..dd5435db9 100644 --- a/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt +++ b/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt @@ -1,5 +1,6 @@ package io.clawdroid.setup +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally @@ -27,6 +28,15 @@ fun SetupWizardScreen( ) { val uiState by viewModel.uiState.collectAsState() + // Step 0 (Gateway/auth): suppress back entirely + // Step 1+: go to previous step + BackHandler(enabled = true) { + if (uiState.currentStep > 0) { + viewModel.previousStep() + } + // Step 0: do nothing (suppress back) + } + Box( modifier = Modifier .fillMaxSize() @@ -57,7 +67,11 @@ fun SetupWizardScreen( AnimatedContent( targetState = uiState.currentStep, transitionSpec = { - slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + if (targetState > initialState) { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + } else { + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + } }, label = "setup_step", ) { step -> @@ -65,8 +79,7 @@ fun SetupWizardScreen( 0 -> SetupStep1GatewayScreen(viewModel) 1 -> SetupStep2LlmScreen(viewModel) 2 -> SetupStep3WorkspaceScreen(viewModel) - 3 -> SetupStep4ChatScreen(viewModel) - 4 -> SetupCompleteScreen(viewModel, onSetupComplete) + 3 -> SetupCompleteScreen(viewModel, onSetupComplete) } } } diff --git a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt index 0eb0a4cee..ef287b0dc 100644 --- a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt +++ b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt @@ -34,6 +34,9 @@ class WebSocketClient( private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) val connectionState: StateFlow = _connectionState.asStateFlow() + private val _setupRequired = MutableStateFlow(false) + val setupRequired: StateFlow = _setupRequired.asStateFlow() + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() @@ -50,6 +53,7 @@ class WebSocketClient( while (isActive) { try { _connectionState.value = ConnectionState.CONNECTING + _setupRequired.value = false val currentWsUrl = wsUrl val separator = if ('?' in currentWsUrl) '&' else '?' val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType" @@ -62,6 +66,9 @@ class WebSocketClient( val text = frame.readText() try { val msg = json.decodeFromString(text) + if (msg.type == "setup_required") { + _setupRequired.value = true + } _incomingMessages.emit(msg) } catch (e: Exception) { Log.w(TAG, "Failed to parse WebSocket message", e) diff --git a/cmd/clawdroid/main.go b/cmd/clawdroid/main.go index d894c0169..65f9e3857 100644 --- a/cmd/clawdroid/main.go +++ b/cmd/clawdroid/main.go @@ -457,37 +457,13 @@ func gatewayCmd() { return } - provider, err := providers.CreateProvider(cfg) - if err != nil { - fmt.Printf("Error creating provider: %v\n", err) - os.Exit(1) - } - msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - - // Print agent startup info - fmt.Println("\nšŸ“¦ Agent Status:") - startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]interface{}) - skillsInfo := startupInfo["skills"].(map[string]interface{}) - fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) - fmt.Printf(" • Skills: %d/%d available\n", - skillsInfo["available"], - skillsInfo["total"]) - - // Log to file as well - logger.InfoCF("agent", "Agent initialized", - map[string]interface{}{ - "tools_count": toolsInfo["count"], - "skills_total": skillsInfo["total"], - "skills_available": skillsInfo["available"], - }) // Restart channel for config-triggered restarts restartCh := make(chan struct{}, 1) - // Start Gateway HTTP server (Config API) + // Start Gateway HTTP server (Config API) first — must be available + // even when LLM provider fails, so the user can fix config via API. gwServer := gateway.NewServer(cfg, configPath, func() { select { case restartCh <- struct{}{}: @@ -500,43 +476,18 @@ func gatewayCmd() { } fmt.Printf("āœ“ Config API started on 127.0.0.1:%d\n", cfg.Gateway.Port) - // Setup cron tool and service - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled) - - heartbeatService := heartbeat.NewHeartbeatService( - cfg.WorkspacePath(), - cfg.DataPath(), - cfg.Heartbeat.Interval, - cfg.Heartbeat.Enabled, - agentLoop.StateManager(), - ) - heartbeatService.SetBus(msgBus) - heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - // Use cli:direct as fallback if no valid channel - if channel == "" || chatID == "" { - channel, chatID = "cli", "direct" - } - // Use ProcessHeartbeat - no session history, each heartbeat is independent - response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) - if err != nil { - return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) - } - if response == "HEARTBEAT_OK" { - return tools.SilentResult("Heartbeat OK") - } - // For heartbeat, always return silent - the subagent result will be - // sent to user via processSystemMessage when the async task completes - return tools.SilentResult(response) - }) - channelManager, err := channels.NewManager(cfg, msgBus, configPath) if err != nil { fmt.Printf("Error creating channel manager: %v\n", err) os.Exit(1) } - // Inject channel manager into agent loop for command handling - agentLoop.SetChannelManager(channelManager) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := channelManager.StartAll(ctx); err != nil { + fmt.Printf("Error starting channels: %v\n", err) + } enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { @@ -545,28 +496,94 @@ func gatewayCmd() { fmt.Println("⚠ Warning: No channels enabled") } + // Try to create LLM provider — if it fails, run in degraded mode + // (Gateway + Channels available, but no AgentLoop). + provider, providerErr := providers.CreateProvider(cfg) + + var agentLoop *agent.AgentLoop + var cronService *cron.CronService + var heartbeatService *heartbeat.HeartbeatService + + if providerErr != nil { + fmt.Printf("⚠ LLM provider not available: %v\n", providerErr) + fmt.Println(" → Running in degraded mode. Fix LLM settings via Config API, then restart.") + + // Drain inbound messages and reply with an error + go func() { + for { + msg, ok := msgBus.ConsumeInbound(ctx) + if !ok { + return + } + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: "⚠ LLM is not configured. Please set your model and API key in Settings, then restart the gateway.", + }) + } + }() + } else { + agentLoop = agent.NewAgentLoop(cfg, msgBus, provider) + + // Print agent startup info + fmt.Println("\nšŸ“¦ Agent Status:") + startupInfo := agentLoop.GetStartupInfo() + toolsInfo := startupInfo["tools"].(map[string]interface{}) + skillsInfo := startupInfo["skills"].(map[string]interface{}) + fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) + fmt.Printf(" • Skills: %d/%d available\n", + skillsInfo["available"], + skillsInfo["total"]) + + logger.InfoCF("agent", "Agent initialized", + map[string]interface{}{ + "tools_count": toolsInfo["count"], + "skills_total": skillsInfo["total"], + "skills_available": skillsInfo["available"], + }) + + agentLoop.SetChannelManager(channelManager) + + cronService = setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled) + + heartbeatService = heartbeat.NewHeartbeatService( + cfg.WorkspacePath(), + cfg.DataPath(), + cfg.Heartbeat.Interval, + cfg.Heartbeat.Enabled, + agentLoop.StateManager(), + ) + heartbeatService.SetBus(msgBus) + heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + if channel == "" || chatID == "" { + channel, chatID = "cli", "direct" + } + response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + if err != nil { + return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) + } + if response == "HEARTBEAT_OK" { + return tools.SilentResult("Heartbeat OK") + } + return tools.SilentResult(response) + }) + + if err := cronService.Start(); err != nil { + fmt.Printf("Error starting cron service: %v\n", err) + } + fmt.Println("āœ“ Cron service started") + + if err := heartbeatService.Start(); err != nil { + fmt.Printf("Error starting heartbeat service: %v\n", err) + } + fmt.Println("āœ“ Heartbeat service started") + + go agentLoop.Run(ctx) + } + fmt.Printf("āœ“ Gateway started on 127.0.0.1:%d\n", cfg.Gateway.Port) fmt.Println("Press Ctrl+C to stop") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := cronService.Start(); err != nil { - fmt.Printf("Error starting cron service: %v\n", err) - } - fmt.Println("āœ“ Cron service started") - - if err := heartbeatService.Start(); err != nil { - fmt.Printf("Error starting heartbeat service: %v\n", err) - } - fmt.Println("āœ“ Heartbeat service started") - - if err := channelManager.StartAll(ctx); err != nil { - fmt.Printf("Error starting channels: %v\n", err) - } - - go agentLoop.Run(ctx) - sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt) @@ -584,9 +601,15 @@ func gatewayCmd() { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() gwServer.Stop(shutdownCtx) - heartbeatService.Stop() - cronService.Stop() - agentLoop.Stop() + if heartbeatService != nil { + heartbeatService.Stop() + } + if cronService != nil { + cronService.Stop() + } + if agentLoop != nil { + agentLoop.Stop() + } channelManager.StopAll(shutdownCtx) fmt.Println("āœ“ Gateway stopped")