feat(ui): render custom endpoint iconURL in drawer and conversation list
Render `EndpointConfig.iconURL` in drawer + conversation list rows with per-conversation override via `Conversation.iconURL`. Extract shared `EndpointIcon` composable to `:core:ui`. Eager `/api/config` fetch via `EndpointConfigFetchSessionTask` so cold-start has icons. Clear config cache on logout to prevent cross-user leak. Closes #68.
This commit is contained in:
parent
a7477a9bc7
commit
92192498ca
18 changed files with 393 additions and 123 deletions
|
|
@ -88,6 +88,18 @@ class ConfigCacheDataStore(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
try {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(KEY_STARTUP_CONFIG)
|
||||
prefs.remove(KEY_ENDPOINT_CONFIGS)
|
||||
prefs.remove(KEY_AVAILABLE_MODELS)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "Failed to clear cached config" }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config")
|
||||
private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs")
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import com.garfiec.librechat.core.data.repository.TagRepository
|
|||
import com.garfiec.librechat.core.data.repository.TagRepositoryImpl
|
||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
||||
import com.garfiec.librechat.core.data.repository.UserRepositoryImpl
|
||||
import com.garfiec.librechat.core.data.util.EndpointConfigFetchSessionTask
|
||||
import com.garfiec.librechat.core.data.util.PermissionGate
|
||||
import com.garfiec.librechat.core.data.util.RefreshTagsSessionTask
|
||||
import com.garfiec.librechat.core.data.util.RoleFetchSessionTask
|
||||
|
|
@ -127,6 +128,7 @@ val dataModule = module {
|
|||
singleOf(::RoleFetchSessionTask) bind SessionTask::class
|
||||
singleOf(::RefreshTagsSessionTask) bind SessionTask::class
|
||||
singleOf(::SyncFavoritesSessionTask) bind SessionTask::class
|
||||
singleOf(::EndpointConfigFetchSessionTask) bind SessionTask::class
|
||||
single {
|
||||
SessionTaskRunner(
|
||||
tasks = getAll<SessionTask>(),
|
||||
|
|
|
|||
|
|
@ -47,4 +47,7 @@ interface ConfigRepository {
|
|||
* [VersionCheckResult.isCompatible] = true (fail-open).
|
||||
*/
|
||||
suspend fun checkBackendVersion(): Result<VersionCheckResult>
|
||||
|
||||
/** Clear all cached config state (in-memory + disk). Called on logout. */
|
||||
suspend fun clear()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,4 +186,12 @@ class ConfigRepositoryImpl(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
_endpointConfigs.value = emptyMap()
|
||||
_availableModels.value = emptyMap()
|
||||
_startupConfig.value = null
|
||||
_detectedBackendVersion.value = null
|
||||
configCache.clear()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.garfiec.librechat.core.data.util
|
||||
|
||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||
|
||||
/**
|
||||
* Eagerly fetches `/api/config` endpoint configs when a session starts so the drawer's
|
||||
* first frame after cold-start (or fresh login) can resolve `EndpointConfig.iconURL`
|
||||
* for custom endpoints. Without this task, the drawer races against the chat screen's
|
||||
* lazy fetch — empty configs leak through as missing icons until the user taps a chat.
|
||||
*/
|
||||
class EndpointConfigFetchSessionTask(
|
||||
private val configRepository: ConfigRepository,
|
||||
) : SessionTask {
|
||||
override suspend fun run() {
|
||||
configRepository.fetchEndpoints()
|
||||
}
|
||||
}
|
||||
|
|
@ -37,3 +37,13 @@ data class Conversation(
|
|||
val createdAt: String? = null,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves the icon URL to display for this conversation, preferring the per-conversation
|
||||
* [iconURL] (set by agents/assistants threads) over the per-endpoint URL from
|
||||
* [EndpointConfig.iconURL]. Returns null when no URL is available — callers fall through
|
||||
* to the bundled brand glyph or Material fallback.
|
||||
*/
|
||||
fun Conversation.resolveEndpointIconUrl(
|
||||
endpointConfigs: Map<String, EndpointConfig>,
|
||||
): String? = iconURL ?: endpoint?.let { endpointConfigs[it]?.iconURL }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
package com.garfiec.librechat.core.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Create
|
||||
import androidx.compose.material.icons.outlined.SmartToy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.garfiec.librechat.core.common.EndpointConstants
|
||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
||||
import com.garfiec.librechat.core.ui.resources.Res
|
||||
import com.garfiec.librechat.core.ui.resources.ic_agents
|
||||
|
|
@ -53,3 +74,75 @@ fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) {
|
|||
"openAI", "assistants", "agents" -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* Material icon fallback for endpoint names that don't have a bundled drawable.
|
||||
* Used by both the model picker and the conversation/drawer rows so unknown endpoint
|
||||
* names render consistently across surfaces.
|
||||
*/
|
||||
internal fun endpointFallbackIconVector(endpointName: String?): ImageVector = when (endpointName) {
|
||||
EndpointConstants.AGENTS -> Icons.Outlined.Create
|
||||
"assistants", "azureAssistants" -> Icons.Outlined.AutoAwesome
|
||||
else -> Icons.Outlined.SmartToy
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an endpoint icon, preferring a remote URL when supplied (for custom endpoints
|
||||
* with `iconURL` configured) and falling back to the bundled brand glyph or a Material
|
||||
* icon when no URL or brand drawable is available.
|
||||
*
|
||||
* Mirrors the web frontend's `URLIcon`/`MinimalIcon` precedence (without the red
|
||||
* AlertCircle overlay on load failure — mobile silently falls back to the glyph).
|
||||
*
|
||||
* [glyphTint] colors monochrome bundled glyphs and the unknown-endpoint Material fallback.
|
||||
* Brand-colored painters (Anthropic, Google, Azure, Bedrock) ignore this and render with
|
||||
* their own colors. The size in [modifier] is overridden by the [size] parameter.
|
||||
*/
|
||||
@Composable
|
||||
fun EndpointIcon(
|
||||
endpointName: String?,
|
||||
iconUrl: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 24.dp,
|
||||
contentDescription: String? = null,
|
||||
glyphTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
var loadFailed by remember(iconUrl) { mutableStateOf(false) }
|
||||
if (iconUrl != null && !loadFailed) {
|
||||
AsyncImage(
|
||||
model = iconUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.size(size).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { loadFailed = true },
|
||||
)
|
||||
} else {
|
||||
EndpointGlyph(endpointName, size, contentDescription, glyphTint, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndpointGlyph(
|
||||
endpointName: String?,
|
||||
size: Dp,
|
||||
contentDescription: String?,
|
||||
glyphTint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val painter = endpointIconPainter(endpointName)
|
||||
if (painter != null) {
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.size(size),
|
||||
tint = if (isMonochromeEndpointIcon(endpointName)) glyphTint else Color.Unspecified,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = endpointFallbackIconVector(endpointName),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier.size(size),
|
||||
tint = glyphTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,8 @@ import androidx.compose.material.icons.filled.ExpandLess
|
|||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Create
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.SmartToy
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -43,16 +41,14 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.garfiec.librechat.core.common.EndpointConstants
|
||||
import com.garfiec.librechat.core.model.Agent
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import com.garfiec.librechat.core.ui.components.EndpointIcon
|
||||
import com.garfiec.librechat.core.ui.components.ErrorBanner
|
||||
import com.garfiec.librechat.core.ui.components.endpointIconPainter
|
||||
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
|
||||
import com.garfiec.librechat.feature.chat.resources.*
|
||||
import com.garfiec.librechat.feature.chat.resources.Res
|
||||
import com.garfiec.librechat.feature.chat.util.FuzzyMatch
|
||||
|
|
@ -78,15 +74,6 @@ private fun fuzzyScore(candidate: String, query: String): Int {
|
|||
return FuzzyMatch.partialRatio(query, candidate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Material icon fallback for endpoint names that don't have a bundled drawable.
|
||||
*/
|
||||
private fun endpointFallbackIcon(endpointName: String): ImageVector = when (endpointName) {
|
||||
EndpointConstants.AGENTS -> Icons.Outlined.Create
|
||||
"assistants", "azureAssistants" -> Icons.Outlined.AutoAwesome
|
||||
else -> Icons.Outlined.SmartToy
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ModelSelectorSheet(
|
||||
|
|
@ -322,6 +309,9 @@ private fun EndpointGroupHeader(
|
|||
EndpointIcon(
|
||||
endpointName = endpointName,
|
||||
iconUrl = iconUrl,
|
||||
size = IconSize,
|
||||
contentDescription = "$endpointName icon",
|
||||
glyphTint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
|
|
@ -341,47 +331,6 @@ private fun EndpointGroupHeader(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndpointIcon(
|
||||
endpointName: String,
|
||||
iconUrl: String?,
|
||||
) {
|
||||
// If the endpoint config provides a remote icon URL, use it
|
||||
if (iconUrl != null) {
|
||||
AsyncImage(
|
||||
model = iconUrl,
|
||||
contentDescription = "$endpointName icon",
|
||||
modifier = Modifier
|
||||
.size(IconSize)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
return
|
||||
}
|
||||
// Use the multiplatform endpointIconPainter API
|
||||
val painter = endpointIconPainter(endpointName)
|
||||
if (painter != null) {
|
||||
val tint = if (isMonochromeEndpointIcon(endpointName)) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
Icon(
|
||||
painter = painter,
|
||||
contentDescription = "$endpointName icon",
|
||||
modifier = Modifier.size(IconSize),
|
||||
tint = tint,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = endpointFallbackIcon(endpointName),
|
||||
contentDescription = "$endpointName icon",
|
||||
modifier = Modifier.size(IconSize),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyItemScope.AgentListItem(
|
||||
agent: Agent,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.garfiec.librechat.feature.conversations.viewmodel
|
||||
|
||||
import com.garfiec.librechat.core.common.result.Result
|
||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||
import com.garfiec.librechat.core.data.repository.RoleRepository
|
||||
import com.garfiec.librechat.core.data.repository.SearchRepository
|
||||
|
|
@ -8,6 +9,7 @@ import com.garfiec.librechat.core.data.repository.ShareRepository
|
|||
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.ConversationTag
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
||||
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
|
||||
|
|
@ -42,6 +44,10 @@ class ConversationListViewModelTest {
|
|||
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
|
||||
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
|
||||
private val roleRepository = mockk<RoleRepository>(relaxed = true)
|
||||
private val endpointConfigsFlow = MutableStateFlow<Map<String, EndpointConfig>>(emptyMap())
|
||||
private val configRepository = mockk<ConfigRepository>(relaxed = true) {
|
||||
every { endpointConfigs } returns endpointConfigsFlow
|
||||
}
|
||||
|
||||
private lateinit var viewModel: ConversationListViewModel
|
||||
|
||||
|
|
@ -92,6 +98,7 @@ class ConversationListViewModelTest {
|
|||
conversationExporter = conversationExporter,
|
||||
conversationImporter = conversationImporter,
|
||||
roleRepository = roleRepository,
|
||||
configRepository = configRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -539,4 +546,37 @@ class ConversationListViewModelTest {
|
|||
tagRepository.toggleFavorite(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `endpointConfigs arriving late re-groups rows with iconURL`() = runTest {
|
||||
// Conversation has no per-convo iconURL; relies on the endpoint config fallback.
|
||||
val convo = Conversation(
|
||||
conversationId = "convo-icon-1",
|
||||
title = "OpenRouter chat",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
updatedAt = "2026-02-19T10:00:00.000Z",
|
||||
)
|
||||
every { conversationRepository.observeConversations(any()) } returns flowOf(
|
||||
Result.Success(listOf(convo)),
|
||||
)
|
||||
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Initial frame: configs are empty; endpointIconUrl must be null.
|
||||
val initialFlat = viewModel.uiState.value.groupedConversations.flatMap { it.second }
|
||||
assertThat(initialFlat).hasSize(1)
|
||||
assertThat(initialFlat[0].endpointIconUrl).isNull()
|
||||
|
||||
// Configs arrive after the first emission.
|
||||
endpointConfigsFlow.value = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = "https://openrouter.ai/favicon.ico"),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val updatedFlat = viewModel.uiState.value.groupedConversations.flatMap { it.second }
|
||||
assertThat(updatedFlat).hasSize(1)
|
||||
assertThat(updatedFlat[0].endpointIconUrl).isEqualTo("https://openrouter.ai/favicon.ico")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package com.garfiec.librechat.feature.conversations.components
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||
import com.garfiec.librechat.core.model.resolveEndpointIconUrl
|
||||
|
||||
@Immutable
|
||||
data class ConversationDisplayData(
|
||||
|
|
@ -12,13 +14,17 @@ data class ConversationDisplayData(
|
|||
val model: String?,
|
||||
val updatedAt: String?,
|
||||
val isBookmarked: Boolean,
|
||||
val endpointIconUrl: String? = null,
|
||||
)
|
||||
|
||||
fun Conversation.toDisplayData() = ConversationDisplayData(
|
||||
fun Conversation.toDisplayData(
|
||||
endpointConfigs: Map<String, EndpointConfig>,
|
||||
): ConversationDisplayData = ConversationDisplayData(
|
||||
conversationId = conversationId ?: "",
|
||||
title = title ?: "New Chat",
|
||||
endpoint = endpoint,
|
||||
model = model,
|
||||
updatedAt = updatedAt,
|
||||
isBookmarked = SAVED_TAG in tags,
|
||||
endpointIconUrl = resolveEndpointIconUrl(endpointConfigs),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -20,13 +19,11 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
||||
import com.garfiec.librechat.core.ui.components.endpointIconPainter
|
||||
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
|
||||
import com.garfiec.librechat.core.ui.components.EndpointIcon
|
||||
import com.garfiec.librechat.feature.conversations.resources.*
|
||||
import com.garfiec.librechat.feature.conversations.resources.Res
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
|
@ -51,8 +48,6 @@ fun ConversationItem(
|
|||
data.endpoint?.toDisplayLabel() ?: "Chat"
|
||||
}
|
||||
|
||||
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -60,26 +55,12 @@ fun ConversationItem(
|
|||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (iconPainter != null) {
|
||||
val isMonochrome = isMonochromeEndpointIcon(data.endpoint)
|
||||
Icon(
|
||||
painter = iconPainter,
|
||||
contentDescription = endpointLabel,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = if (isMonochrome) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = endpointLabel,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
EndpointIcon(
|
||||
endpointName = data.endpoint,
|
||||
iconUrl = data.endpointIconUrl,
|
||||
size = 24.dp,
|
||||
contentDescription = endpointLabel,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
|
|
@ -106,7 +87,7 @@ fun ConversationItem(
|
|||
val modelName = data.model
|
||||
if (modelName != null) {
|
||||
Text(
|
||||
text = " \u00B7 ",
|
||||
text = " · ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
|
|
@ -122,7 +103,7 @@ fun ConversationItem(
|
|||
|
||||
if (relativeTime.isNotEmpty()) {
|
||||
Text(
|
||||
text = " \u00B7 ",
|
||||
text = " · ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import co.touchlab.kermit.Logger
|
|||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
|
||||
import com.garfiec.librechat.core.common.result.Result
|
||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||
import com.garfiec.librechat.core.data.repository.RoleRepository
|
||||
import com.garfiec.librechat.core.data.repository.SearchRepository
|
||||
|
|
@ -14,6 +15,7 @@ import com.garfiec.librechat.core.data.repository.ShareRepository
|
|||
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.ConversationTag
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||
import com.garfiec.librechat.core.model.permissions.Permission
|
||||
import com.garfiec.librechat.core.model.permissions.PermissionType
|
||||
|
|
@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Immutable
|
||||
|
|
@ -67,6 +70,7 @@ class ConversationListViewModel(
|
|||
private val conversationExporter: ConversationExporter,
|
||||
private val conversationImporter: ConversationImporter,
|
||||
private val roleRepository: RoleRepository,
|
||||
private val configRepository: ConfigRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ConversationListUiState())
|
||||
|
|
@ -76,7 +80,7 @@ class ConversationListViewModel(
|
|||
val events: SharedFlow<ConversationListEvent> = _events.asSharedFlow()
|
||||
|
||||
/** Raw conversations kept for internal lookups (action sheets, etc.). */
|
||||
private var conversations: List<Conversation> = emptyList()
|
||||
private val _conversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
|
|
@ -84,6 +88,7 @@ class ConversationListViewModel(
|
|||
observeConversations()
|
||||
observeTags()
|
||||
observePermissions()
|
||||
observeGroupedConversations()
|
||||
}
|
||||
|
||||
private fun observePermissions() {
|
||||
|
|
@ -106,8 +111,7 @@ class ConversationListViewModel(
|
|||
is Result.Success -> {
|
||||
// Only update from Room when not in search mode
|
||||
if (_uiState.value.searchQuery.isEmpty()) {
|
||||
conversations = result.data
|
||||
recomputeGroupedConversations()
|
||||
_conversations.value = result.data
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -133,16 +137,21 @@ class ConversationListViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun recomputeGroupedConversations() {
|
||||
val grouped = groupConversationsByDate(conversations)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
groupedConversations = grouped,
|
||||
conversationCount = conversations.size,
|
||||
)
|
||||
private fun observeGroupedConversations() {
|
||||
viewModelScope.launch {
|
||||
combine(_conversations, configRepository.endpointConfigs) { convos, configs ->
|
||||
groupConversationsByDate(convos, configs) to convos.size
|
||||
}.collect { (grouped, count) ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
groupedConversations = grouped,
|
||||
conversationCount = count,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getConversation(conversationId: String): Conversation? =
|
||||
conversations.firstOrNull { it.conversationId == conversationId }
|
||||
_conversations.value.firstOrNull { it.conversationId == conversationId }
|
||||
|
||||
fun toggleFavorite(conversation: Conversation) {
|
||||
val id = conversation.conversationId ?: return
|
||||
|
|
@ -260,8 +269,7 @@ class ConversationListViewModel(
|
|||
_uiState.value = _uiState.value.copy(isSearching = true)
|
||||
when (val result = searchRepository.search(query)) {
|
||||
is Result.Success -> {
|
||||
conversations = result.data
|
||||
recomputeGroupedConversations()
|
||||
_conversations.value = result.data
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isSearching = false,
|
||||
hasMore = false,
|
||||
|
|
@ -452,6 +460,7 @@ class ConversationListViewModel(
|
|||
companion object {
|
||||
private fun groupConversationsByDate(
|
||||
conversations: List<Conversation>,
|
||||
endpointConfigs: Map<String, EndpointConfig>,
|
||||
): List<Pair<String, List<ConversationDisplayData>>> {
|
||||
if (conversations.isEmpty()) return emptyList()
|
||||
return conversations
|
||||
|
|
@ -462,7 +471,7 @@ class ConversationListViewModel(
|
|||
?: "Unknown"
|
||||
}
|
||||
.map { (group, convos) ->
|
||||
group to convos.map { it.toDisplayData() }
|
||||
group to convos.map { it.toDisplayData(endpointConfigs) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
package com.garfiec.librechat.feature.conversations.components
|
||||
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ConversationDisplayDataTest {
|
||||
|
||||
@Test
|
||||
fun iconURLPrefersConversationOverEndpointConfig() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = "https://convo-icon.example.com/icon.png",
|
||||
)
|
||||
val configs = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"),
|
||||
)
|
||||
|
||||
val data = convo.toDisplayData(configs)
|
||||
|
||||
assertEquals("https://convo-icon.example.com/icon.png", data.endpointIconUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconURLFallsBackToEndpointConfig() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
)
|
||||
val configs = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"),
|
||||
)
|
||||
|
||||
val data = convo.toDisplayData(configs)
|
||||
|
||||
assertEquals("https://config-icon.example.com/icon.png", data.endpointIconUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconURLNullWhenNeitherSet() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
)
|
||||
val configs = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = null),
|
||||
)
|
||||
|
||||
val data = convo.toDisplayData(configs)
|
||||
|
||||
assertNull(data.endpointIconUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconURLNullWhenEndpointMissingFromConfig() {
|
||||
// Cold-start race: configs map empty for that key — composable falls back to glyph.
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
)
|
||||
|
||||
val data = convo.toDisplayData(endpointConfigs = emptyMap())
|
||||
|
||||
assertNull(data.endpointIconUrl)
|
||||
}
|
||||
}
|
||||
|
|
@ -45,15 +45,13 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.garfiec.librechat.core.ui.components.endpointIconPainter
|
||||
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
|
||||
import com.garfiec.librechat.core.ui.components.EndpointIcon
|
||||
import com.garfiec.librechat.shared.resources.Res
|
||||
import com.garfiec.librechat.shared.resources.agents
|
||||
import com.garfiec.librechat.shared.resources.bookmark
|
||||
|
|
@ -365,8 +363,6 @@ private fun DrawerConversationItem(
|
|||
onToggleFavorite: () -> Unit = {},
|
||||
showBookmarkToggle: Boolean = true,
|
||||
) {
|
||||
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
|
||||
|
||||
val backgroundColor = if (data.isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
|
|
@ -399,24 +395,12 @@ private fun DrawerConversationItem(
|
|||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else if (iconPainter != null) {
|
||||
val isMonochrome = isMonochromeEndpointIcon(data.endpoint)
|
||||
Icon(
|
||||
painter = iconPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (isMonochrome) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
Color.Unspecified
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SmartToy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (data.isActive) {
|
||||
EndpointIcon(
|
||||
endpointName = data.endpoint,
|
||||
iconUrl = data.endpointIconUrl,
|
||||
size = 18.dp,
|
||||
glyphTint = if (data.isActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package com.garfiec.librechat.shared.navigation
|
|||
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
|
||||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||
import com.garfiec.librechat.core.model.resolveEndpointIconUrl
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
|
@ -18,6 +20,7 @@ internal data class DrawerDataSnapshot(
|
|||
|
||||
internal fun Conversation.toDrawerDisplayData(
|
||||
activeConversationId: String?,
|
||||
endpointConfigs: Map<String, EndpointConfig>,
|
||||
): DrawerConversationDisplayData {
|
||||
val convId = conversationId ?: ""
|
||||
return DrawerConversationDisplayData(
|
||||
|
|
@ -29,6 +32,7 @@ internal fun Conversation.toDrawerDisplayData(
|
|||
isActive = convId == activeConversationId,
|
||||
isFavorite = SAVED_TAG in tags,
|
||||
tags = tags,
|
||||
endpointIconUrl = resolveEndpointIconUrl(endpointConfigs),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ data class DrawerConversationDisplayData(
|
|||
val isActive: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
val tags: List<String>,
|
||||
val endpointIconUrl: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import kotlinx.coroutines.launch
|
|||
class NavHostViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
bannerRepository: BannerRepository,
|
||||
configRepository: ConfigRepository,
|
||||
private val configRepository: ConfigRepository,
|
||||
private val conversationRepository: ConversationRepository,
|
||||
private val roleRepository: RoleRepository,
|
||||
private val sessionTaskRunner: SessionTaskRunner,
|
||||
|
|
@ -117,12 +117,16 @@ class NavHostViewModel(
|
|||
Triple(refreshing, loadingMore, hasMore)
|
||||
},
|
||||
drawerPermissionFlags,
|
||||
) { data, (refreshing, loadingMore, hasMore), perms ->
|
||||
configRepository.endpointConfigs,
|
||||
) { data, refreshState, perms, endpointConfigs ->
|
||||
val (refreshing, loadingMore, hasMore) = refreshState
|
||||
DrawerUiState(
|
||||
groupedConversations = data.grouped.map { (group, convos) ->
|
||||
group to convos.map { it.toDrawerDisplayData(data.activeId) }
|
||||
group to convos.map { it.toDrawerDisplayData(data.activeId, endpointConfigs) }
|
||||
},
|
||||
favoriteConversations = data.favConvos.map {
|
||||
it.toDrawerDisplayData(data.activeId, endpointConfigs)
|
||||
},
|
||||
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId) },
|
||||
searchQuery = data.query,
|
||||
isRefreshing = refreshing,
|
||||
isLoadingMore = loadingMore,
|
||||
|
|
@ -216,6 +220,7 @@ class NavHostViewModel(
|
|||
_isLoggedIn.value = false
|
||||
conversationListStateHolder.reset()
|
||||
tagRepository.clearCache()
|
||||
configRepository.clear()
|
||||
_sidebarMode.value = SidebarMode.Conversations
|
||||
_selectedSettingsCategory.value = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
package com.garfiec.librechat.shared.navigation
|
||||
|
||||
import com.garfiec.librechat.core.model.Conversation
|
||||
import com.garfiec.librechat.core.model.EndpointConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DrawerDisplayMappingTest {
|
||||
|
||||
@Test
|
||||
fun iconURLPrefersConversationOverEndpointConfig() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = "https://convo-icon.example.com/icon.png",
|
||||
)
|
||||
val configs = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"),
|
||||
)
|
||||
|
||||
val data = convo.toDrawerDisplayData(activeConversationId = null, endpointConfigs = configs)
|
||||
|
||||
assertEquals("https://convo-icon.example.com/icon.png", data.endpointIconUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconURLFallsBackToEndpointConfig() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
)
|
||||
val configs = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = "https://config-icon.example.com/icon.png"),
|
||||
)
|
||||
|
||||
val data = convo.toDrawerDisplayData(activeConversationId = null, endpointConfigs = configs)
|
||||
|
||||
assertEquals("https://config-icon.example.com/icon.png", data.endpointIconUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconURLNullWhenNeitherSet() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
)
|
||||
val configs = mapOf(
|
||||
"OpenRouter" to EndpointConfig(iconURL = null),
|
||||
)
|
||||
|
||||
val data = convo.toDrawerDisplayData(activeConversationId = null, endpointConfigs = configs)
|
||||
|
||||
assertNull(data.endpointIconUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconURLNullWhenEndpointMissingFromConfig() {
|
||||
val convo = Conversation(
|
||||
conversationId = "c1",
|
||||
endpoint = "OpenRouter",
|
||||
iconURL = null,
|
||||
)
|
||||
|
||||
val data = convo.toDrawerDisplayData(
|
||||
activeConversationId = null,
|
||||
endpointConfigs = emptyMap(),
|
||||
)
|
||||
|
||||
assertNull(data.endpointIconUrl)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue