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")