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

View file

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

View file

@ -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,

View file

@ -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.",

View file

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

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

View file

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

View file

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

View file

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