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:
parent
dd2390f8d5
commit
84ccd83ada
10 changed files with 248 additions and 383 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
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,7 +106,22 @@ class SetupViewModel(
|
|||
|
||||
_uiState.update { it.copy(loading = true, error = null) }
|
||||
|
||||
val body = buildJsonObject {
|
||||
try {
|
||||
// 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))
|
||||
|
|
@ -174,32 +137,13 @@ class SetupViewModel(
|
|||
})
|
||||
})
|
||||
}
|
||||
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)) }
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
setupApiClient.complete(completeBody)
|
||||
|
||||
try {
|
||||
setupApiClient.complete(body)
|
||||
_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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ class WebSocketClient(
|
|||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _setupRequired = MutableStateFlow(false)
|
||||
val setupRequired: StateFlow<Boolean> = _setupRequired.asStateFlow()
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<WsOutgoing>(extraBufferCapacity = 64)
|
||||
val incomingMessages: SharedFlow<WsOutgoing> = _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<WsOutgoing>(text)
|
||||
if (msg.type == "setup_required") {
|
||||
_setupRequired.value = true
|
||||
}
|
||||
_incomingMessages.emit(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse WebSocket message", e)
|
||||
|
|
|
|||
|
|
@ -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,11 +496,77 @@ func gatewayCmd() {
|
|||
fmt.Println("⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on 127.0.0.1:%d\n", cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
// Try to create LLM provider — if it fails, run in degraded mode
|
||||
// (Gateway + Channels available, but no AgentLoop).
|
||||
provider, providerErr := providers.CreateProvider(cfg)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
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)
|
||||
|
|
@ -561,11 +578,11 @@ func gatewayCmd() {
|
|||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
if err := channelManager.StartAll(ctx); err != nil {
|
||||
fmt.Printf("Error starting channels: %v\n", err)
|
||||
go agentLoop.Run(ctx)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
if heartbeatService != nil {
|
||||
heartbeatService.Stop()
|
||||
}
|
||||
if cronService != nil {
|
||||
cronService.Stop()
|
||||
}
|
||||
if agentLoop != nil {
|
||||
agentLoop.Stop()
|
||||
}
|
||||
channelManager.StopAll(shutdownCtx)
|
||||
fmt.Println("✓ Gateway stopped")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue