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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-28 13:41:03 +09:00
parent dd2390f8d5
commit 84ccd83ada
10 changed files with 248 additions and 383 deletions

View file

@ -45,10 +45,10 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController() val navController = rememberNavController()
val wsClient: WebSocketClient = koinInject() val wsClient: WebSocketClient = koinInject()
// Observe setup_required messages from server // Observe setup_required state from server
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
wsClient.incomingMessages.collect { msg -> wsClient.setupRequired.collect { required ->
if (msg.type == "setup_required") { if (required) {
val current = navController.currentDestination?.route val current = navController.currentDestination?.route
if (current != NavRoutes.SETUP) { if (current != NavRoutes.SETUP) {
navController.navigate(NavRoutes.SETUP) { navController.navigate(NavRoutes.SETUP) {

View file

@ -37,7 +37,7 @@ fun SetupCompleteScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, 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)) Spacer(Modifier.height(16.dp))

View file

@ -13,7 +13,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -51,7 +49,7 @@ fun SetupStep1GatewayScreen(viewModel: SetupViewModel) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Text( Text(
"Step 1 of 5", "Step 1 of 4",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = TextSecondary, color = TextSecondary,
) )
@ -120,14 +118,10 @@ fun SetupStep1GatewayScreen(viewModel: SetupViewModel) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) { ) {
if (uiState.loading) {
CircularProgressIndicator(color = NeonCyan, modifier = Modifier.padding(end = 16.dp))
}
Button( Button(
onClick = viewModel::submitInit, onClick = viewModel::submitInit,
enabled = uiState.canProceedStep1 && !uiState.loading, enabled = uiState.canProceedStep1,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = NeonCyan, containerColor = NeonCyan,
contentColor = DeepBlack, contentColor = DeepBlack,

View file

@ -45,7 +45,7 @@ fun SetupStep2LlmScreen(viewModel: SetupViewModel) {
) { ) {
Spacer(Modifier.height(32.dp)) 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("LLM Settings", style = MaterialTheme.typography.headlineMedium, color = TextPrimary)
Text( Text(
"Configure the language model used by the agent.", "Configure the language model used by the agent.",

View file

@ -1,5 +1,8 @@
package io.clawdroid.setup 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -20,6 +27,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.clawdroid.core.ui.theme.DeepBlack import io.clawdroid.core.ui.theme.DeepBlack
import io.clawdroid.core.ui.theme.NeonCyan import io.clawdroid.core.ui.theme.NeonCyan
@ -30,6 +38,18 @@ import io.clawdroid.core.ui.theme.TextSecondary
fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) { fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) {
val uiState by viewModel.uiState.collectAsState() 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -39,7 +59,7 @@ fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) {
) { ) {
Spacer(Modifier.height(32.dp)) 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("Workspace & Data", style = MaterialTheme.typography.headlineMedium, color = TextPrimary)
Text( Text(
"Set the workspace and data directories used by the agent.", "Set the workspace and data directories used by the agent.",
@ -49,24 +69,20 @@ fun SetupStep3WorkspaceScreen(viewModel: SetupViewModel) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( DirectoryField(
value = uiState.workspace, value = uiState.workspace,
onValueChange = viewModel::onWorkspaceChange, onValueChange = viewModel::onWorkspaceChange,
label = { Text("Workspace", color = TextSecondary) }, label = "Workspace",
placeholder = { Text("~/.clawdroid/workspace", color = TextSecondary.copy(alpha = 0.5f)) }, placeholder = "~/.clawdroid/workspace",
singleLine = true, onBrowse = { workspacePicker.launch(null) },
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField( DirectoryField(
value = uiState.dataDir, value = uiState.dataDir,
onValueChange = viewModel::onDataDirChange, onValueChange = viewModel::onDataDirChange,
label = { Text("Data Directory", color = TextSecondary) }, label = "Data Directory",
placeholder = { Text("~/.clawdroid/data", color = TextSecondary.copy(alpha = 0.5f)) }, placeholder = "~/.clawdroid/data",
singleLine = true, onBrowse = { dataDirPicker.launch(null) },
colors = setupFieldColors(),
modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.weight(1f)) 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()
}
}

View file

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

View file

@ -30,16 +30,6 @@ data class SetupUiState(
val workspace: String = "", val workspace: String = "",
val dataDir: String = "", val dataDir: String = "",
val step3Skipped: Boolean = false, 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? val gatewayPortError: String?
get() { get() {
@ -81,59 +71,12 @@ class SetupViewModel(
fun onWorkspaceChange(value: String) = _uiState.update { it.copy(workspace = value) } fun onWorkspaceChange(value: String) = _uiState.update { it.copy(workspace = value) }
fun onDataDirChange(value: String) = _uiState.update { it.copy(dataDir = 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() { fun submitInit() {
viewModelScope.launch { val state = _uiState.value
val state = _uiState.value if (!state.canProceedStep1) return
if (!state.canProceedStep1 || state.loading) return@launch _uiState.update { it.copy(step1Done = true, currentStep = 1) }
_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") }
}
}
} }
fun skipStep(step: Int) { fun skipStep(step: Int) {
@ -141,7 +84,6 @@ class SetupViewModel(
when (step) { when (step) {
2 -> it.copy(step2Skipped = true, currentStep = 2) 2 -> it.copy(step2Skipped = true, currentStep = 2)
3 -> it.copy(step3Skipped = true, currentStep = 3) 3 -> it.copy(step3Skipped = true, currentStep = 3)
4 -> it.copy(step4Skipped = true, currentStep = 4)
else -> it else -> it
} }
} }
@ -151,6 +93,12 @@ class SetupViewModel(
_uiState.update { it.copy(currentStep = step) } _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) { fun submitComplete(onComplete: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val state = _uiState.value val state = _uiState.value
@ -158,48 +106,44 @@ class SetupViewModel(
_uiState.update { it.copy(loading = true, error = null) } _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 { 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) } _uiState.update { it.copy(loading = false) }
onComplete() onComplete()
} catch (e: Exception) { } 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") }
} }
} }
} }

View file

@ -1,5 +1,6 @@
package io.clawdroid.setup package io.clawdroid.setup
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
@ -27,6 +28,15 @@ fun SetupWizardScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -57,7 +67,11 @@ fun SetupWizardScreen(
AnimatedContent( AnimatedContent(
targetState = uiState.currentStep, targetState = uiState.currentStep,
transitionSpec = { transitionSpec = {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it } if (targetState > initialState) {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
} else {
slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
}
}, },
label = "setup_step", label = "setup_step",
) { step -> ) { step ->
@ -65,8 +79,7 @@ fun SetupWizardScreen(
0 -> SetupStep1GatewayScreen(viewModel) 0 -> SetupStep1GatewayScreen(viewModel)
1 -> SetupStep2LlmScreen(viewModel) 1 -> SetupStep2LlmScreen(viewModel)
2 -> SetupStep3WorkspaceScreen(viewModel) 2 -> SetupStep3WorkspaceScreen(viewModel)
3 -> SetupStep4ChatScreen(viewModel) 3 -> SetupCompleteScreen(viewModel, onSetupComplete)
4 -> SetupCompleteScreen(viewModel, onSetupComplete)
} }
} }
} }

View file

@ -34,6 +34,9 @@ class WebSocketClient(
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _setupRequired = MutableStateFlow(false)
val setupRequired: StateFlow<Boolean> = _setupRequired.asStateFlow()
private val _incomingMessages = MutableSharedFlow<WsOutgoing>(extraBufferCapacity = 64) private val _incomingMessages = MutableSharedFlow<WsOutgoing>(extraBufferCapacity = 64)
val incomingMessages: SharedFlow<WsOutgoing> = _incomingMessages.asSharedFlow() val incomingMessages: SharedFlow<WsOutgoing> = _incomingMessages.asSharedFlow()
@ -50,6 +53,7 @@ class WebSocketClient(
while (isActive) { while (isActive) {
try { try {
_connectionState.value = ConnectionState.CONNECTING _connectionState.value = ConnectionState.CONNECTING
_setupRequired.value = false
val currentWsUrl = wsUrl val currentWsUrl = wsUrl
val separator = if ('?' in currentWsUrl) '&' else '?' val separator = if ('?' in currentWsUrl) '&' else '?'
val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType" val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType"
@ -62,6 +66,9 @@ class WebSocketClient(
val text = frame.readText() val text = frame.readText()
try { try {
val msg = json.decodeFromString<WsOutgoing>(text) val msg = json.decodeFromString<WsOutgoing>(text)
if (msg.type == "setup_required") {
_setupRequired.value = true
}
_incomingMessages.emit(msg) _incomingMessages.emit(msg)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to parse WebSocket message", e) Log.w(TAG, "Failed to parse WebSocket message", e)

View file

@ -457,37 +457,13 @@ func gatewayCmd() {
return return
} }
provider, err := providers.CreateProvider(cfg)
if err != nil {
fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1)
}
msgBus := bus.NewMessageBus() 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 // Restart channel for config-triggered restarts
restartCh := make(chan struct{}, 1) 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() { gwServer := gateway.NewServer(cfg, configPath, func() {
select { select {
case restartCh <- struct{}{}: case restartCh <- struct{}{}:
@ -500,43 +476,18 @@ func gatewayCmd() {
} }
fmt.Printf("✓ Config API started on 127.0.0.1:%d\n", cfg.Gateway.Port) 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) channelManager, err := channels.NewManager(cfg, msgBus, configPath)
if err != nil { if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err) fmt.Printf("Error creating channel manager: %v\n", err)
os.Exit(1) os.Exit(1)
} }
// Inject channel manager into agent loop for command handling ctx, cancel := context.WithCancel(context.Background())
agentLoop.SetChannelManager(channelManager) defer cancel()
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
enabledChannels := channelManager.GetEnabledChannels() enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
@ -545,28 +496,94 @@ func gatewayCmd() {
fmt.Println("⚠ Warning: No channels enabled") 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.Printf("✓ Gateway started on 127.0.0.1:%d\n", cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop") 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) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt) signal.Notify(sigChan, os.Interrupt)
@ -584,9 +601,15 @@ func gatewayCmd() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel() defer shutdownCancel()
gwServer.Stop(shutdownCtx) gwServer.Stop(shutdownCtx)
heartbeatService.Stop() if heartbeatService != nil {
cronService.Stop() heartbeatService.Stop()
agentLoop.Stop() }
if cronService != nil {
cronService.Stop()
}
if agentLoop != nil {
agentLoop.Stop()
}
channelManager.StopAll(shutdownCtx) channelManager.StopAll(shutdownCtx)
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")