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 <noreply@anthropic.com>
This commit is contained in:
parent
38da89539e
commit
4a899b92c2
18 changed files with 1181 additions and 8 deletions
|
|
@ -9,6 +9,7 @@ import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
|
|
@ -19,12 +20,15 @@ import androidx.navigation.navArgument
|
||||||
import io.clawdroid.backend.config.ConfigSectionDetailScreen
|
import io.clawdroid.backend.config.ConfigSectionDetailScreen
|
||||||
import io.clawdroid.backend.config.ConfigSectionListScreen
|
import io.clawdroid.backend.config.ConfigSectionListScreen
|
||||||
import io.clawdroid.backend.config.ConfigViewModel
|
import io.clawdroid.backend.config.ConfigViewModel
|
||||||
|
import io.clawdroid.core.data.remote.WebSocketClient
|
||||||
import io.clawdroid.core.ui.theme.ClawDroidTheme
|
import io.clawdroid.core.ui.theme.ClawDroidTheme
|
||||||
import io.clawdroid.feature.chat.screen.ChatScreen
|
import io.clawdroid.feature.chat.screen.ChatScreen
|
||||||
import io.clawdroid.feature.chat.screen.SettingsScreen
|
import io.clawdroid.feature.chat.screen.SettingsScreen
|
||||||
import io.clawdroid.navigation.NavRoutes
|
import io.clawdroid.navigation.NavRoutes
|
||||||
import io.clawdroid.settings.AppSettingsScreen
|
import io.clawdroid.settings.AppSettingsScreen
|
||||||
|
import io.clawdroid.setup.SetupWizardScreen
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
@ -39,6 +43,22 @@ class MainActivity : ComponentActivity() {
|
||||||
setContent {
|
setContent {
|
||||||
ClawDroidTheme {
|
ClawDroidTheme {
|
||||||
val navController = rememberNavController()
|
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) {
|
NavHost(navController = navController, startDestination = NavRoutes.CHAT) {
|
||||||
composable(NavRoutes.CHAT) {
|
composable(NavRoutes.CHAT) {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
|
|
@ -89,6 +109,15 @@ class MainActivity : ComponentActivity() {
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(NavRoutes.SETUP) {
|
||||||
|
SetupWizardScreen(
|
||||||
|
onSetupComplete = {
|
||||||
|
navController.navigate(NavRoutes.CHAT) {
|
||||||
|
popUpTo(NavRoutes.CHAT) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import io.clawdroid.feature.chat.voice.CameraCaptureManager
|
||||||
import io.clawdroid.feature.chat.voice.VoiceModeManager
|
import io.clawdroid.feature.chat.voice.VoiceModeManager
|
||||||
import io.clawdroid.settings.AppSettingsViewModel
|
import io.clawdroid.settings.AppSettingsViewModel
|
||||||
import io.clawdroid.settings.GatewaySettingsStoreImpl
|
import io.clawdroid.settings.GatewaySettingsStoreImpl
|
||||||
|
import io.clawdroid.setup.SetupApiClient
|
||||||
|
import io.clawdroid.setup.SetupViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
@ -127,8 +129,12 @@ val appModule = module {
|
||||||
single { CameraCaptureManager(androidContext()) }
|
single { CameraCaptureManager(androidContext()) }
|
||||||
single { VoiceModeManager(get(), get(), get(), get(), get(), get()) }
|
single { VoiceModeManager(get(), get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
single { SetupApiClient(get()) }
|
||||||
|
|
||||||
// ViewModel
|
// ViewModel
|
||||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||||
viewModel { AppSettingsViewModel(get(), get()) }
|
viewModel { AppSettingsViewModel(get(), get()) }
|
||||||
|
viewModel { SetupViewModel(get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,5 @@ object NavRoutes {
|
||||||
const val BACKEND_SETTINGS_LIST = "backend_settings_list"
|
const val BACKEND_SETTINGS_LIST = "backend_settings_list"
|
||||||
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
|
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
|
||||||
const val APP_SETTINGS = "app_settings"
|
const val APP_SETTINGS = "app_settings"
|
||||||
|
const val SETUP = "setup"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt
Normal file
206
android/app/src/main/java/io/clawdroid/setup/SetupViewModel.kt
Normal file
|
|
@ -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<SetupUiState> = _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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ class ChatRepositoryImpl(
|
||||||
"status" -> _statusLabel.value = dto.content
|
"status" -> _statusLabel.value = dto.content
|
||||||
"status_end" -> _statusLabel.value = null
|
"status_end" -> _statusLabel.value = null
|
||||||
"tool_request" -> handleToolRequest(dto.content)
|
"tool_request" -> handleToolRequest(dto.content)
|
||||||
"exit" -> { /* ignored in chat mode */ }
|
"exit", "setup_required" -> { /* ignored in chat mode */ }
|
||||||
else -> {
|
else -> {
|
||||||
_statusLabel.value = null
|
_statusLabel.value = null
|
||||||
val entity = MessageMapper.toEntity(dto)
|
val entity = MessageMapper.toEntity(dto)
|
||||||
|
|
|
||||||
|
|
@ -523,7 +523,7 @@ func gatewayCmd() {
|
||||||
return tools.SilentResult(response)
|
return tools.SilentResult(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
channelManager, err := channels.NewManager(cfg, msgBus, configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
fmt.Printf("Error creating channel manager: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type Manager struct {
|
||||||
channels map[string]Channel
|
channels map[string]Channel
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
configPath string
|
||||||
dispatchTask *asyncTask
|
dispatchTask *asyncTask
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
@ -29,11 +30,12 @@ type asyncTask struct {
|
||||||
cancel context.CancelFunc
|
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{
|
m := &Manager{
|
||||||
channels: make(map[string]Channel),
|
channels: make(map[string]Channel),
|
||||||
bus: messageBus,
|
bus: messageBus,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
configPath: configPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initChannels(); err != nil {
|
if err := m.initChannels(); err != nil {
|
||||||
|
|
@ -113,7 +115,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.WebSocket.Enabled {
|
if m.config.Channels.WebSocket.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize WebSocket channel")
|
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 {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/KarakuriAgent/clawdroid/pkg/broadcast"
|
"github.com/KarakuriAgent/clawdroid/pkg/broadcast"
|
||||||
|
|
@ -38,6 +39,7 @@ type wsOutgoing struct {
|
||||||
type WebSocketChannel struct {
|
type WebSocketChannel struct {
|
||||||
*BaseChannel
|
*BaseChannel
|
||||||
config config.WebSocketConfig
|
config config.WebSocketConfig
|
||||||
|
configPath string
|
||||||
server *http.Server
|
server *http.Server
|
||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
clients map[*websocket.Conn]string // conn → clientID
|
clients map[*websocket.Conn]string // conn → clientID
|
||||||
|
|
@ -48,12 +50,13 @@ type WebSocketChannel struct {
|
||||||
cancel context.CancelFunc
|
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)
|
base := NewBaseChannel("websocket", cfg, msgBus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &WebSocketChannel{
|
return &WebSocketChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
configPath: configPath,
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
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.clientTypes[chatID] = clientType
|
||||||
c.mu.Unlock()
|
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)
|
go c.readPump(conn, clientID, chatID, clientType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ func (s *Server) Start() error {
|
||||||
mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema))
|
mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema))
|
||||||
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
|
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
|
||||||
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
|
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)
|
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port)
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
|
|
|
||||||
142
pkg/gateway/setup.go
Normal file
142
pkg/gateway/setup.go
Normal file
|
|
@ -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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue