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.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 }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
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