From 4a899b92c2af92427a811b09b012c74845ba2e8f Mon Sep 17 00:00:00 2001 From: Kohei Date: Sat, 28 Feb 2026 11:01:18 +0900 Subject: [PATCH] feat: add setup wizard for first-time config initialization When config.json does not exist, the WebSocket channel sends a setup_required message to connected clients. The Android app navigates to a multi-step setup wizard that creates the initial config via POST /api/setup/init (unauthenticated, guarded by file-existence check) and merges remaining settings via PUT /api/setup/complete (authenticated). Co-Authored-By: Claude Opus 4.6 --- .../main/java/io/clawdroid/MainActivity.kt | 29 +++ .../main/java/io/clawdroid/di/AppModule.kt | 6 + .../java/io/clawdroid/navigation/NavRoutes.kt | 1 + .../java/io/clawdroid/setup/SetupApiClient.kt | 64 ++++++ .../io/clawdroid/setup/SetupCompleteScreen.kt | 84 +++++++ .../io/clawdroid/setup/SetupFieldColors.kt | 19 ++ .../setup/SetupStep1GatewayScreen.kt | 140 ++++++++++++ .../io/clawdroid/setup/SetupStep2LlmScreen.kt | 117 ++++++++++ .../setup/SetupStep3WorkspaceScreen.kt | 92 ++++++++ .../clawdroid/setup/SetupStep4ChatScreen.kt | 175 +++++++++++++++ .../java/io/clawdroid/setup/SetupViewModel.kt | 206 ++++++++++++++++++ .../io/clawdroid/setup/SetupWizardScreen.kt | 73 +++++++ .../data/repository/ChatRepositoryImpl.kt | 2 +- cmd/clawdroid/main.go | 2 +- pkg/channels/manager.go | 12 +- pkg/channels/websocket.go | 23 +- pkg/gateway/server.go | 2 + pkg/gateway/setup.go | 142 ++++++++++++ 18 files changed, 1181 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupApiClient.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupFieldColors.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt create mode 100644 android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt create mode 100644 pkg/gateway/setup.go diff --git a/android/app/src/main/java/io/clawdroid/MainActivity.kt b/android/app/src/main/java/io/clawdroid/MainActivity.kt index 5052b7754..a82047294 100644 --- a/android/app/src/main/java/io/clawdroid/MainActivity.kt +++ b/android/app/src/main/java/io/clawdroid/MainActivity.kt @@ -9,6 +9,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -19,12 +20,15 @@ import androidx.navigation.navArgument import io.clawdroid.backend.config.ConfigSectionDetailScreen import io.clawdroid.backend.config.ConfigSectionListScreen import io.clawdroid.backend.config.ConfigViewModel +import io.clawdroid.core.data.remote.WebSocketClient import io.clawdroid.core.ui.theme.ClawDroidTheme import io.clawdroid.feature.chat.screen.ChatScreen import io.clawdroid.feature.chat.screen.SettingsScreen import io.clawdroid.navigation.NavRoutes import io.clawdroid.settings.AppSettingsScreen +import io.clawdroid.setup.SetupWizardScreen import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject class MainActivity : ComponentActivity() { @@ -39,6 +43,22 @@ class MainActivity : ComponentActivity() { setContent { ClawDroidTheme { val navController = rememberNavController() + val wsClient: WebSocketClient = koinInject() + + // Observe setup_required messages from server + LaunchedEffect(Unit) { + wsClient.incomingMessages.collect { msg -> + if (msg.type == "setup_required") { + val current = navController.currentDestination?.route + if (current != NavRoutes.SETUP) { + navController.navigate(NavRoutes.SETUP) { + launchSingleTop = true + } + } + } + } + } + NavHost(navController = navController, startDestination = NavRoutes.CHAT) { composable(NavRoutes.CHAT) { ChatScreen( @@ -89,6 +109,15 @@ class MainActivity : ComponentActivity() { onNavigateBack = { navController.popBackStack() }, ) } + composable(NavRoutes.SETUP) { + SetupWizardScreen( + onSetupComplete = { + navController.navigate(NavRoutes.CHAT) { + popUpTo(NavRoutes.CHAT) { inclusive = true } + } + }, + ) + } } } } diff --git a/android/app/src/main/java/io/clawdroid/di/AppModule.kt b/android/app/src/main/java/io/clawdroid/di/AppModule.kt index 1b971384e..d2ee5c88d 100644 --- a/android/app/src/main/java/io/clawdroid/di/AppModule.kt +++ b/android/app/src/main/java/io/clawdroid/di/AppModule.kt @@ -33,6 +33,8 @@ import io.clawdroid.feature.chat.voice.CameraCaptureManager import io.clawdroid.feature.chat.voice.VoiceModeManager import io.clawdroid.settings.AppSettingsViewModel import io.clawdroid.settings.GatewaySettingsStoreImpl +import io.clawdroid.setup.SetupApiClient +import io.clawdroid.setup.SetupViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -127,8 +129,12 @@ val appModule = module { single { CameraCaptureManager(androidContext()) } single { VoiceModeManager(get(), get(), get(), get(), get(), get()) } + // Setup + single { SetupApiClient(get()) } + // ViewModel viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) } viewModel { AppSettingsViewModel(get(), get()) } + viewModel { SetupViewModel(get(), get()) } } diff --git a/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt b/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt index 7bec62978..fcc94f48b 100644 --- a/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt +++ b/android/app/src/main/java/io/clawdroid/navigation/NavRoutes.kt @@ -7,4 +7,5 @@ object NavRoutes { const val BACKEND_SETTINGS_LIST = "backend_settings_list" const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" const val APP_SETTINGS = "app_settings" + const val SETUP = "setup" } diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupApiClient.kt b/android/app/src/main/java/io/clawdroid/setup/SetupApiClient.kt new file mode 100644 index 000000000..3b93d7c9f --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupApiClient.kt @@ -0,0 +1,64 @@ +package io.clawdroid.setup + +import io.clawdroid.backend.api.GatewaySettingsStore +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.Closeable +import java.io.IOException + +class SetupApiClient(private val settingsStore: GatewaySettingsStore) : Closeable { + + private val json = Json { ignoreUnknownKeys = true } + private val client = HttpClient(OkHttp) + + private val baseUrl: String get() = settingsStore.settings.value.httpBaseUrl + private val apiKey: String get() = settingsStore.settings.value.apiKey + + suspend fun init(body: JsonObject) { + val response = client.post("$baseUrl/api/setup/init") { + contentType(ContentType.Application.Json) + setBody(body.toString()) + } + if (!response.status.isSuccess()) { + val errorMsg = parseError(response.bodyAsText()) + throw IOException("HTTP ${response.status.value}: $errorMsg") + } + } + + suspend fun complete(body: JsonObject) { + val response = client.put("$baseUrl/api/setup/complete") { + contentType(ContentType.Application.Json) + setBody(body.toString()) + if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey") + } + if (!response.status.isSuccess()) { + val errorMsg = parseError(response.bodyAsText()) + throw IOException("HTTP ${response.status.value}: $errorMsg") + } + } + + override fun close() { + client.close() + } + + private fun parseError(responseBody: String): String { + return try { + json.parseToJsonElement(responseBody).jsonObject["error"]?.jsonPrimitive?.content + ?: "request failed" + } catch (_: Exception) { + "request failed" + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt new file mode 100644 index 000000000..56b1b4b41 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupCompleteScreen.kt @@ -0,0 +1,84 @@ +package io.clawdroid.setup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 SetupCompleteScreen( + viewModel: SetupViewModel, + onSetupComplete: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Step 5 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) + + Spacer(Modifier.height(16.dp)) + + Text( + "Setup Complete!", + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + ) + + Spacer(Modifier.height(12.dp)) + + Text( + "You can change these settings later from the Settings screen.", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + + uiState.error?.let { error -> + Spacer(Modifier.height(12.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.height(32.dp)) + + if (uiState.loading) { + CircularProgressIndicator(color = NeonCyan) + } else { + Button( + onClick = { viewModel.submitComplete(onSetupComplete) }, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + modifier = Modifier.fillMaxWidth(0.6f), + ) { + Text("Done") + } + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupFieldColors.kt b/android/app/src/main/java/io/clawdroid/setup/SetupFieldColors.kt new file mode 100644 index 000000000..99ba8facb --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupFieldColors.kt @@ -0,0 +1,19 @@ +package io.clawdroid.setup + +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import io.clawdroid.core.ui.theme.GlassBorder +import io.clawdroid.core.ui.theme.GlassWhite +import io.clawdroid.core.ui.theme.NeonCyan +import io.clawdroid.core.ui.theme.TextPrimary + +@Composable +fun setupFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NeonCyan.copy(alpha = 0.5f), + unfocusedBorderColor = GlassBorder, + focusedContainerColor = GlassWhite, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, +) diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt new file mode 100644 index 000000000..a6121e6a6 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep1GatewayScreen.kt @@ -0,0 +1,140 @@ +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.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.Alignment +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 SetupStep1GatewayScreen(viewModel: SetupViewModel) { + val uiState by viewModel.uiState.collectAsState() + var apiKeyHidden by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(Modifier.height(32.dp)) + + Text( + "Step 1 of 5", + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + ) + Text( + "Gateway Connection", + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + ) + Text( + "Configure the HTTP gateway that connects this app to the backend.", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.gatewayPort, + onValueChange = viewModel::onGatewayPortChange, + label = { Text("Port", color = TextSecondary) }, + placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + isError = uiState.gatewayPortError != null, + supportingText = uiState.gatewayPortError?.let { err -> { Text(err) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.gatewayApiKey, + onValueChange = viewModel::onGatewayApiKeyChange, + label = { Text("API Key", color = TextSecondary) }, + singleLine = true, + visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, + trailingIcon = { + TextButton(onClick = { apiKeyHidden = !apiKeyHidden }) { + Text( + if (apiKeyHidden) "Show" else "Hide", + color = NeonCyan, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedButton( + onClick = viewModel::generateApiKey, + colors = ButtonDefaults.outlinedButtonColors(contentColor = NeonCyan), + ) { + Text("Generate API Key") + } + + uiState.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(Modifier.weight(1f)) + + 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, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + ) { + Text("Next") + } + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt new file mode 100644 index 000000000..81a19ec63 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep2LlmScreen.kt @@ -0,0 +1,117 @@ +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.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.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 SetupStep2LlmScreen(viewModel: SetupViewModel) { + val uiState by viewModel.uiState.collectAsState() + var apiKeyHidden by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(Modifier.height(32.dp)) + + Text("Step 2 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) + Text("LLM Settings", style = MaterialTheme.typography.headlineMedium, color = TextPrimary) + Text( + "Configure the language model used by the agent.", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.llmModel, + onValueChange = viewModel::onLlmModelChange, + label = { Text("Model", color = TextSecondary) }, + placeholder = { Text("e.g. openai/gpt-4o", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.llmApiKey, + onValueChange = viewModel::onLlmApiKeyChange, + label = { Text("API Key", color = TextSecondary) }, + singleLine = true, + visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, + trailingIcon = { + TextButton(onClick = { apiKeyHidden = !apiKeyHidden }) { + Text( + if (apiKeyHidden) "Show" else "Hide", + color = NeonCyan, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.llmBaseUrl, + onValueChange = viewModel::onLlmBaseUrlChange, + label = { Text("Base URL", color = TextSecondary) }, + placeholder = { Text("https://openrouter.ai/api/v1", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = { viewModel.skipStep(2) }) { + Text("Set up later", color = TextSecondary) + } + Button( + onClick = { viewModel.nextStep(2) }, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + ) { + Text("Next") + } + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt new file mode 100644 index 000000000..daffb0439 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep3WorkspaceScreen.kt @@ -0,0 +1,92 @@ +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.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.ui.Modifier +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 SetupStep3WorkspaceScreen(viewModel: SetupViewModel) { + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(Modifier.height(32.dp)) + + Text("Step 3 of 5", 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.", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + 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(), + ) + + OutlinedTextField( + 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(), + ) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = { viewModel.skipStep(3) }) { + Text("Set up later", color = TextSecondary) + } + Button( + onClick = { viewModel.nextStep(3) }, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + ) { + Text("Next") + } + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt new file mode 100644 index 000000000..b74e536c0 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupStep4ChatScreen.kt @@ -0,0 +1,175 @@ +package io.clawdroid.setup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import io.clawdroid.core.ui.theme.DeepBlack +import io.clawdroid.core.ui.theme.NeonCyan +import io.clawdroid.core.ui.theme.TextPrimary +import io.clawdroid.core.ui.theme.TextSecondary + +@Composable +fun SetupStep4ChatScreen(viewModel: SetupViewModel) { + val uiState by viewModel.uiState.collectAsState() + var wsApiKeyHidden by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(32.dp)) + + Text("Step 4 of 5", style = MaterialTheme.typography.labelMedium, color = TextSecondary) + Text("WebSocket & Agent", style = MaterialTheme.typography.headlineMedium, color = TextPrimary) + Text( + "Configure the WebSocket channel and agent parameters.", + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + + Spacer(Modifier.height(4.dp)) + + // WebSocket section + Text("WebSocket", style = MaterialTheme.typography.titleSmall, color = NeonCyan) + + OutlinedTextField( + value = uiState.wsHost, + onValueChange = viewModel::onWsHostChange, + label = { Text("Host", color = TextSecondary) }, + singleLine = true, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.wsPort, + onValueChange = viewModel::onWsPortChange, + label = { Text("Port", color = TextSecondary) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.wsPath, + onValueChange = viewModel::onWsPathChange, + label = { Text("Path", color = TextSecondary) }, + singleLine = true, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.wsApiKey, + onValueChange = viewModel::onWsApiKeyChange, + label = { Text("WS API Key", color = TextSecondary) }, + singleLine = true, + visualTransformation = if (wsApiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None, + trailingIcon = { + TextButton(onClick = { wsApiKeyHidden = !wsApiKeyHidden }) { + Text( + if (wsApiKeyHidden) "Show" else "Hide", + color = NeonCyan, + style = MaterialTheme.typography.labelSmall, + ) + } + }, + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + // Agent section + Text("Agent Defaults", style = MaterialTheme.typography.titleSmall, color = NeonCyan) + + OutlinedTextField( + value = uiState.maxTokens, + onValueChange = viewModel::onMaxTokensChange, + label = { Text("Max Tokens", color = TextSecondary) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.contextWindow, + onValueChange = viewModel::onContextWindowChange, + label = { Text("Context Window", color = TextSecondary) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.temperature, + onValueChange = viewModel::onTemperatureChange, + label = { Text("Temperature", color = TextSecondary) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.maxToolIterations, + onValueChange = viewModel::onMaxToolIterationsChange, + label = { Text("Max Tool Iterations", color = TextSecondary) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + colors = setupFieldColors(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton(onClick = { viewModel.skipStep(4) }) { + Text("Set up later", color = TextSecondary) + } + Button( + onClick = { viewModel.nextStep(4) }, + colors = ButtonDefaults.buttonColors( + containerColor = NeonCyan, + contentColor = DeepBlack, + ), + ) { + Text("Next") + } + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt b/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt new file mode 100644 index 000000000..372b42b05 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt @@ -0,0 +1,206 @@ +package io.clawdroid.setup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.clawdroid.backend.api.GatewaySettings +import io.clawdroid.backend.api.GatewaySettingsStore +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import java.util.UUID + +data class SetupUiState( + val currentStep: Int = 0, + val loading: Boolean = false, + val error: String? = null, + // Step 1: Gateway + val gatewayPort: String = "18790", + val gatewayApiKey: String = "", + val step1Done: Boolean = false, + // Step 2: LLM + val llmModel: String = "", + val llmApiKey: String = "", + val llmBaseUrl: String = "", + val step2Skipped: Boolean = false, + // Step 3: Workspace + 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() { + if (gatewayPort.isEmpty()) return null + val port = gatewayPort.toIntOrNull() ?: return "Invalid number" + return if (port !in 1..65535) "1-65535" else null + } + + val canProceedStep1: Boolean + get() = gatewayPort.isNotEmpty() && gatewayPortError == null && gatewayApiKey.isNotEmpty() +} + +class SetupViewModel( + private val setupApiClient: SetupApiClient, + private val settingsStore: GatewaySettingsStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SetupUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onGatewayPortChange(value: String) { + if (value.isEmpty() || value.toIntOrNull() != null) { + _uiState.update { it.copy(gatewayPort = value, error = null) } + } + } + + fun onGatewayApiKeyChange(value: String) { + _uiState.update { it.copy(gatewayApiKey = value, error = null) } + } + + fun generateApiKey() { + _uiState.update { it.copy(gatewayApiKey = UUID.randomUUID().toString(), error = null) } + } + + fun onLlmModelChange(value: String) = _uiState.update { it.copy(llmModel = value) } + fun onLlmApiKeyChange(value: String) = _uiState.update { it.copy(llmApiKey = value) } + fun onLlmBaseUrlChange(value: String) = _uiState.update { it.copy(llmBaseUrl = value) } + + 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") } + } + } + } + + fun skipStep(step: Int) { + _uiState.update { + 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 + } + } + } + + fun nextStep(step: Int) { + _uiState.update { it.copy(currentStep = step) } + } + + fun submitComplete(onComplete: () -> Unit) { + viewModelScope.launch { + val state = _uiState.value + if (state.loading) return@launch + + _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) + _uiState.update { it.copy(loading = false) } + onComplete() + } catch (e: Exception) { + _uiState.update { it.copy(loading = false, error = e.message ?: "Complete failed") } + } + } + } +} diff --git a/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt b/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt new file mode 100644 index 000000000..3264a6888 --- /dev/null +++ b/android/app/src/main/java/io/clawdroid/setup/SetupWizardScreen.kt @@ -0,0 +1,73 @@ +package io.clawdroid.setup + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import io.clawdroid.core.ui.theme.DeepBlack +import io.clawdroid.core.ui.theme.GradientCyan +import io.clawdroid.core.ui.theme.GradientPurple +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun SetupWizardScreen( + onSetupComplete: () -> Unit, + viewModel: SetupViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + Box( + modifier = Modifier + .fillMaxSize() + .background(DeepBlack) + .drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientCyan.copy(alpha = 0.07f), + Color.Transparent, + ), + center = Offset(size.width * 0.15f, size.height * 0.1f), + radius = size.width * 0.8f, + ), + ) + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + GradientPurple.copy(alpha = 0.07f), + Color.Transparent, + ), + center = Offset(size.width * 0.85f, size.height * 0.9f), + radius = size.width * 0.7f, + ), + ) + }, + ) { + AnimatedContent( + targetState = uiState.currentStep, + transitionSpec = { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it } + }, + label = "setup_step", + ) { step -> + when (step) { + 0 -> SetupStep1GatewayScreen(viewModel) + 1 -> SetupStep2LlmScreen(viewModel) + 2 -> SetupStep3WorkspaceScreen(viewModel) + 3 -> SetupStep4ChatScreen(viewModel) + 4 -> SetupCompleteScreen(viewModel, onSetupComplete) + } + } + } +} diff --git a/android/core/data/src/main/java/io/clawdroid/core/data/repository/ChatRepositoryImpl.kt b/android/core/data/src/main/java/io/clawdroid/core/data/repository/ChatRepositoryImpl.kt index a39c992cc..2bb55ced4 100644 --- a/android/core/data/src/main/java/io/clawdroid/core/data/repository/ChatRepositoryImpl.kt +++ b/android/core/data/src/main/java/io/clawdroid/core/data/repository/ChatRepositoryImpl.kt @@ -56,7 +56,7 @@ class ChatRepositoryImpl( "status" -> _statusLabel.value = dto.content "status_end" -> _statusLabel.value = null "tool_request" -> handleToolRequest(dto.content) - "exit" -> { /* ignored in chat mode */ } + "exit", "setup_required" -> { /* ignored in chat mode */ } else -> { _statusLabel.value = null val entity = MessageMapper.toEntity(dto) diff --git a/cmd/clawdroid/main.go b/cmd/clawdroid/main.go index 37f1b5665..6dc7ca97a 100644 --- a/cmd/clawdroid/main.go +++ b/cmd/clawdroid/main.go @@ -523,7 +523,7 @@ func gatewayCmd() { return tools.SilentResult(response) }) - channelManager, err := channels.NewManager(cfg, msgBus) + channelManager, err := channels.NewManager(cfg, msgBus, configPath) if err != nil { fmt.Printf("Error creating channel manager: %v\n", err) os.Exit(1) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index a4f44ddfe..c60e90bfd 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -21,6 +21,7 @@ type Manager struct { channels map[string]Channel bus *bus.MessageBus config *config.Config + configPath string dispatchTask *asyncTask mu sync.RWMutex } @@ -29,11 +30,12 @@ type asyncTask struct { cancel context.CancelFunc } -func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) { +func NewManager(cfg *config.Config, messageBus *bus.MessageBus, configPath string) (*Manager, error) { m := &Manager{ - channels: make(map[string]Channel), - bus: messageBus, - config: cfg, + channels: make(map[string]Channel), + bus: messageBus, + config: cfg, + configPath: configPath, } if err := m.initChannels(); err != nil { @@ -113,7 +115,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.WebSocket.Enabled { logger.DebugC("channels", "Attempting to initialize WebSocket channel") - ws, err := NewWebSocketChannel(m.config.Channels.WebSocket, m.bus) + ws, err := NewWebSocketChannel(m.config.Channels.WebSocket, m.bus, m.configPath) if err != nil { logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{ "error": err.Error(), diff --git a/pkg/channels/websocket.go b/pkg/channels/websocket.go index 7190a6e26..b7d0656b0 100644 --- a/pkg/channels/websocket.go +++ b/pkg/channels/websocket.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "sync" "github.com/KarakuriAgent/clawdroid/pkg/broadcast" @@ -38,6 +39,7 @@ type wsOutgoing struct { type WebSocketChannel struct { *BaseChannel config config.WebSocketConfig + configPath string server *http.Server upgrader websocket.Upgrader clients map[*websocket.Conn]string // conn → clientID @@ -48,12 +50,13 @@ type WebSocketChannel struct { cancel context.CancelFunc } -func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus) (*WebSocketChannel, error) { +func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus, configPath string) (*WebSocketChannel, error) { base := NewBaseChannel("websocket", cfg, msgBus, cfg.AllowFrom) return &WebSocketChannel{ BaseChannel: base, config: cfg, + configPath: configPath, upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }, @@ -242,6 +245,24 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) { c.clientTypes[chatID] = clientType c.mu.Unlock() + // Send setup_required if config.json does not exist + if c.configPath != "" { + if _, err := os.Stat(c.configPath); os.IsNotExist(err) { + setupMsg := wsOutgoing{ + Content: "Configuration required", + Type: "setup_required", + } + if data, err := json.Marshal(setupMsg); err == nil { + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + logger.ErrorCF("websocket", "Failed to send setup_required", map[string]interface{}{ + "client_id": clientID, + "error": err.Error(), + }) + } + } + } + } + go c.readPump(conn, clientID, chatID, clientType) } diff --git a/pkg/gateway/server.go b/pkg/gateway/server.go index 54afed39d..412017aca 100644 --- a/pkg/gateway/server.go +++ b/pkg/gateway/server.go @@ -32,6 +32,8 @@ func (s *Server) Start() error { mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema)) mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig)) mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig)) + mux.HandleFunc("POST /api/setup/init", s.handleSetupInit) + mux.HandleFunc("PUT /api/setup/complete", s.authMiddleware(s.handleSetupComplete)) addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port) s.server = &http.Server{ diff --git a/pkg/gateway/setup.go b/pkg/gateway/setup.go new file mode 100644 index 000000000..42575c06d --- /dev/null +++ b/pkg/gateway/setup.go @@ -0,0 +1,142 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "os" + "time" + + "github.com/KarakuriAgent/clawdroid/pkg/config" + "github.com/KarakuriAgent/clawdroid/pkg/logger" +) + +type gatewaySetupRequest struct { + Port int `json:"port"` + APIKey string `json:"api_key"` +} + +type setupInitRequest struct { + Gateway gatewaySetupRequest `json:"gateway"` +} + +// handleSetupInit creates config.json from scratch when it does not yet exist. +// POST /api/setup/init — no authentication required. +func (s *Server) handleSetupInit(w http.ResponseWriter, r *http.Request) { + // Only allowed when config.json does not exist + if _, err := os.Stat(s.configPath); err == nil { + writeJSONError(w, http.StatusConflict, "config already exists") + return + } + + var req setupInitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + cfg := config.DefaultConfig() + + if req.Gateway.Port > 0 { + cfg.Gateway.Port = req.Gateway.Port + } + cfg.Gateway.APIKey = req.Gateway.APIKey + + if err := config.SaveConfig(s.configPath, cfg); err != nil { + logger.ErrorCF("gateway", "Failed to save initial config", map[string]interface{}{ + "error": err.Error(), + }) + writeJSONError(w, http.StatusInternalServerError, "failed to save config: "+err.Error()) + return + } + + logger.InfoC("gateway", "Initial config created via setup wizard") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + + if s.onRestart != nil { + go func() { + time.Sleep(100 * time.Millisecond) + s.onRestart() + }() + } +} + +// handleSetupComplete merges additional settings into an existing config.json. +// PUT /api/setup/complete — authentication required. +func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { + s.cfg.RLock() + currentData, err := json.Marshal(s.cfg) + s.cfg.RUnlock() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to read current config") + return + } + + var incoming map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + // The "agents_extra" key allows patching agents.defaults without overwriting + // the entire agents block. Deep-merge it into the "agents" key so that + // e.g. agents_extra.defaults.max_tokens merges alongside agents.defaults.workspace. + if extra, ok := incoming["agents_extra"]; ok { + agents, _ := incoming["agents"].(map[string]interface{}) + if agents == nil { + agents = make(map[string]interface{}) + } + if extraMap, ok := extra.(map[string]interface{}); ok { + for k, v := range extraMap { + // If both sides have a map for this key, merge them deeply + existingMap, existingOk := agents[k].(map[string]interface{}) + newMap, newOk := v.(map[string]interface{}) + if existingOk && newOk { + for nk, nv := range newMap { + existingMap[nk] = nv + } + } else { + agents[k] = v + } + } + } + incoming["agents"] = agents + delete(incoming, "agents_extra") + } + + mergedData, err := json.Marshal(incoming) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to prepare config") + return + } + + var newCfg config.Config + if err := json.Unmarshal(currentData, &newCfg); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to copy config") + return + } + if err := json.Unmarshal(mergedData, &newCfg); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid config: "+err.Error()) + return + } + + s.cfg.Lock() + err = config.SaveConfigLocked(s.configPath, &newCfg) + if err == nil { + s.cfg.CopyFrom(&newCfg) + } + s.cfg.Unlock() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to save config: "+err.Error()) + return + } + + logger.InfoC("gateway", "Setup wizard completed, config updated") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + + if s.onRestart != nil { + go func() { + time.Sleep(100 * time.Millisecond) + s.onRestart() + }() + } +}