diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt index f4bcb94b..1798b671 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/settings/AppearanceSettingsSection.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -170,7 +169,6 @@ private val dropdownCompactMaxWidth = 240.dp private val dropdownRegularMinWidth = 180.dp private val dropdownRegularMaxWidth = 320.dp -@OptIn(ExperimentalLayoutApi::class) @Composable fun appearanceSettingsSection() { val currentThemeMode by ThemePreferences.themeMode.collectAsState() diff --git a/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt b/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt index 4c08e518..ecea8716 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/settings/AIProviderSettingsSection.kt @@ -4,6 +4,9 @@ */ package io.askimo.desktop.settings +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,6 +26,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Info @@ -66,9 +70,11 @@ import io.askimo.ui.common.theme.ThemePreferences import io.askimo.ui.common.ui.clickableCard import io.askimo.ui.common.ui.themedTooltip import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import java.awt.Desktop import java.net.URI +import kotlin.time.Duration.Companion.milliseconds @Composable fun aiProviderSettingsSection(viewModel: AIProviderViewModel) { @@ -139,13 +145,13 @@ fun aiProviderSettingsSection(viewModel: AIProviderViewModel) { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.small)) { val active = viewModel.activeInstance if (active != null) { - secondaryButton(onClick = { viewModel.openEditProviderWizard(active) }) { + primaryButton(onClick = { viewModel.openEditProviderWizard(active) }) { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(Spacing.extraSmall)) Text(stringResource("settings.change.button")) } } - secondaryButton(onClick = { viewModel.openAddProviderWizard() }) { + primaryButton(onClick = { viewModel.openAddProviderWizard() }) { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(Spacing.extraSmall)) Text(stringResource("provider.add.new")) @@ -619,6 +625,15 @@ private fun providerConfigurableField( field: SettingField, onValueChange: (String) -> Unit, ) { + var showSavedIndicator by remember { mutableStateOf(false) } + + LaunchedEffect(showSavedIndicator) { + if (showSavedIndicator) { + delay(2000.milliseconds) + showSavedIndicator = false + } + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -647,14 +662,25 @@ private fun providerConfigurableField( value = text, onValueChange = { newVal -> text = newVal - newVal.toIntOrNull()?.let { onValueChange(it.toString()) } + newVal.toIntOrNull()?.let { + onValueChange(it.toString()) + showSavedIndicator = true + } }, modifier = Modifier.widthIn(min = 100.dp, max = 160.dp), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - colors = AppComponents.outlinedTextFieldColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + trailingIcon = { + AnimatedVisibility(visible = showSavedIndicator, enter = fadeIn(), exit = fadeOut()) { + Icon( + Icons.Default.Check, + contentDescription = "Saved", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + }, + colors = AppComponents.outlinedTextFieldColors(), ) } @@ -665,12 +691,21 @@ private fun providerConfigurableField( onValueChange = { newVal -> text = newVal onValueChange(newVal) + showSavedIndicator = true }, modifier = Modifier.widthIn(min = 100.dp, max = 200.dp), singleLine = true, - colors = AppComponents.outlinedTextFieldColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + trailingIcon = { + AnimatedVisibility(visible = showSavedIndicator, enter = fadeIn(), exit = fadeOut()) { + Icon( + Icons.Default.Check, + contentDescription = "Saved", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + }, + colors = AppComponents.outlinedTextFieldColors(), ) } diff --git a/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt b/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt index 7f4cde04..69391edc 100644 --- a/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt +++ b/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt @@ -42,21 +42,18 @@ import kotlin.io.path.isRegularFile private object AppConfigObject private val log = logger() -// TODO: Remove @JsonAlias camelCase aliases in v1.2.30 — kept for backward compatibility with pre-snake_case config files data class EmbeddingConfig( - @field:JsonAlias("maxCharsPerChunk") val maxCharsPerChunk: Int = 3000, - @field:JsonAlias("chunkOverlap") val chunkOverlap: Int = 100, + val maxCharsPerChunk: Int = 3000, + val chunkOverlap: Int = 100, ) -// TODO: Remove @JsonAlias camelCase aliases in v1.2.30 — kept for backward compatibility with pre-snake_case config files data class RetryConfig( val attempts: Int = 4, - @field:JsonAlias("baseDelayMs") val baseDelayMs: Long = 150, + val baseDelayMs: Long = 150, ) -// TODO: Remove @JsonAlias camelCase aliases in v1.2.30 — kept for backward compatibility with pre-snake_case config files data class ThrottleConfig( - @field:JsonAlias("perRequestSleepMs") val perRequestSleepMs: Long = 30, + val perRequestSleepMs: Long = 30, ) data class ProjectType( @@ -103,19 +100,16 @@ private class CommaSeparatedSetDeserializer : StdDeserializer>(Set:: // TODO: Remove @JsonAlias camelCase aliases in v1.2.30 — kept for backward compatibility with pre-snake_case config files data class IndexingConfig( - @field:JsonAlias("maxFileBytes") val maxFileBytes: Long = 5_000_000, - @field:JsonAlias("concurrentIndexingThreads") val concurrentIndexingThreads: Int = 3, - @field:JsonAlias("embeddingBatchSize") val embeddingBatchSize: Int = 50, + val maxFileBytes: Long = 5_000_000, + val concurrentIndexingThreads: Int = 3, + val embeddingBatchSize: Int = 50, val filters: FilterConfig = FilterConfig(), val customExcludes: Set = emptySet(), @field:JsonDeserialize(using = CommaSeparatedSetDeserializer::class) - @field:JsonAlias("supportedExtensions") val supportedExtensions: Set = setOf(), @field:JsonDeserialize(using = CommaSeparatedSetDeserializer::class) - @field:JsonAlias("binaryExtensions") val binaryExtensions: Set = setOf(), @field:JsonDeserialize(using = CommaSeparatedSetDeserializer::class) - @field:JsonAlias("excludeFileNames") val excludeFileNames: Set = setOf(), val projectTypes: List = listOf( ProjectType( @@ -246,40 +240,37 @@ data class ProxyConfig( } } -// TODO: Remove @field:JsonAlias camelCase aliases in v1.2.30 - kept for backward compatibility with pre-snake_case config files data class ChatConfig( - @field:JsonAlias("maxTokens") val maxTokens: Int = 8000, - @field:JsonAlias("summarizationThreshold") val summarizationThreshold: Double = 0.75, - @field:JsonAlias("enableAsyncSummarization") val enableAsyncSummarization: Boolean = true, - @field:JsonAlias("summarizationTimeoutSeconds") val summarizationTimeoutSeconds: Long = 300, - @field:JsonAlias("defaultResponseAILocale") val defaultResponseAILocale: String? = null, + val maxTokens: Int = 8000, + val summarizationThreshold: Double = 0.75, + val enableAsyncSummarization: Boolean = true, + val summarizationTimeoutSeconds: Long = 300, + val defaultResponseAILocale: String? = null, ) /** * RAG (Retrieval-Augmented Generation) configuration. * Controls how relevant documents are retrieved from the knowledge base. */ -// TODO: Remove @field:JsonAlias camelCase aliases in v1.2.30 - kept for backward compatibility with pre-snake_case config files data class RagConfig( /** Maximum number of documents to retrieve from vector search */ - @field:JsonAlias("vectorSearchMaxResults") val vectorSearchMaxResults: Int = 20, + val vectorSearchMaxResults: Int = 20, /** Minimum similarity score for vector search results (0.0 to 1.0) */ - @field:JsonAlias("vectorSearchMinScore") val vectorSearchMinScore: Double = 0.3, + val vectorSearchMinScore: Double = 0.3, /** Maximum number of final documents to return after hybrid fusion */ - @field:JsonAlias("hybridMaxResults") val hybridMaxResults: Int = 15, + val hybridMaxResults: Int = 15, /** RRF constant for rank fusion algorithm (standard value is 60) */ - @field:JsonAlias("rankFusionConstant") val rankFusionConstant: Int = 60, + val rankFusionConstant: Int = 60, /** Use absolute file paths in citations (true) or relative filenames (false) */ - @field:JsonAlias("useAbsolutePathInCitations") val useAbsolutePathInCitations: Boolean = true, + val useAbsolutePathInCitations: Boolean = true, ) -// TODO: Remove @field:JsonAlias camelCase aliases in v1.2.30 - kept for backward compatibility with pre-snake_case config files data class ProviderModelConfig( - @field:JsonAlias("defaultModel") val defaultModel: String = "", - @field:JsonAlias("utilityModel") val utilityModel: String = "", - @field:JsonAlias("embeddingModel") val embeddingModel: String = "", - @field:JsonAlias("visionModel") val visionModel: String = "", - @field:JsonAlias("imageModel") val imageModel: String = "", + val defaultModel: String = "", + val utilityModel: String = "", + val embeddingModel: String = "", + val visionModel: String = "", + val imageModel: String = "", ) /** @@ -291,8 +282,8 @@ data class ProviderModelConfig( * accommodate slow local models and cloud reasoning models with extended thinking. */ data class ModelTimeoutsConfig( - @field:JsonAlias("utilityModelTimeoutSeconds") val utilityModelTimeoutSeconds: Long = 600, - @field:JsonAlias("defaultModelTimeoutSeconds") val defaultModelTimeoutSeconds: Long = 600, + val utilityModelTimeoutSeconds: Long = 600, + val defaultModelTimeoutSeconds: Long = 600, ) data class ModelsConfig( @@ -1289,13 +1280,13 @@ object AppConfig { if (WebSearchConfig.isActualKey(key)) { val result = WebSearchConfig.setSecureBraveKey(key) when (result.method) { - SecureKeyManager.StorageMethod.KEYCHAIN -> + StorageMethod.KEYCHAIN -> log.debug("Brave Search API key stored securely in keychain") - SecureKeyManager.StorageMethod.ENCRYPTED -> + StorageMethod.ENCRYPTED -> log.warn("Brave Search API key stored with encryption ({})", result.warningMessage) - SecureKeyManager.StorageMethod.INSECURE_FALLBACK -> + StorageMethod.INSECURE_FALLBACK -> log.warn("⚠️ Brave Search API key storage: {}", result.warningMessage) } config.copy(braveApiKey = WebSearchConfig.getKeyPlaceholder()) @@ -1309,13 +1300,13 @@ object AppConfig { if (WebSearchConfig.isActualKey(key)) { val result = WebSearchConfig.setSecureTavilyKey(key) when (result.method) { - SecureKeyManager.StorageMethod.KEYCHAIN -> + StorageMethod.KEYCHAIN -> log.debug("Tavily API key stored securely in keychain") - SecureKeyManager.StorageMethod.ENCRYPTED -> + StorageMethod.ENCRYPTED -> log.warn("Tavily API key stored with encryption ({})", result.warningMessage) - SecureKeyManager.StorageMethod.INSECURE_FALLBACK -> + StorageMethod.INSECURE_FALLBACK -> log.warn("⚠️ Tavily API key storage: {}", result.warningMessage) } config.copy(tavilyApiKey = WebSearchConfig.getKeyPlaceholder())