Merge pull request #72 from garfiec/feat/issue-68-endpoint-icon-url
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build Android App (push) Has been cancelled
CI / Build iOS App (push) Has been cancelled

feat(ui): render custom endpoint iconURL in drawer and conversation list
This commit is contained in:
Garfie Chiu 2026-04-29 22:49:14 -06:00 committed by GitHub
commit 2b41b6f379
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 393 additions and 123 deletions

View file

@ -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 { companion object {
private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config") private val KEY_STARTUP_CONFIG = stringPreferencesKey("cached_startup_config")
private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs") private val KEY_ENDPOINT_CONFIGS = stringPreferencesKey("cached_endpoint_configs")

View file

@ -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.TagRepositoryImpl
import com.garfiec.librechat.core.data.repository.UserRepository import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.repository.UserRepositoryImpl 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.PermissionGate
import com.garfiec.librechat.core.data.util.RefreshTagsSessionTask import com.garfiec.librechat.core.data.util.RefreshTagsSessionTask
import com.garfiec.librechat.core.data.util.RoleFetchSessionTask import com.garfiec.librechat.core.data.util.RoleFetchSessionTask
@ -127,6 +128,7 @@ val dataModule = module {
singleOf(::RoleFetchSessionTask) bind SessionTask::class singleOf(::RoleFetchSessionTask) bind SessionTask::class
singleOf(::RefreshTagsSessionTask) bind SessionTask::class singleOf(::RefreshTagsSessionTask) bind SessionTask::class
singleOf(::SyncFavoritesSessionTask) bind SessionTask::class singleOf(::SyncFavoritesSessionTask) bind SessionTask::class
singleOf(::EndpointConfigFetchSessionTask) bind SessionTask::class
single { single {
SessionTaskRunner( SessionTaskRunner(
tasks = getAll<SessionTask>(), tasks = getAll<SessionTask>(),

View file

@ -47,4 +47,7 @@ interface ConfigRepository {
* [VersionCheckResult.isCompatible] = true (fail-open). * [VersionCheckResult.isCompatible] = true (fail-open).
*/ */
suspend fun checkBackendVersion(): Result<VersionCheckResult> suspend fun checkBackendVersion(): Result<VersionCheckResult>
/** Clear all cached config state (in-memory + disk). Called on logout. */
suspend fun clear()
} }

View file

@ -186,4 +186,12 @@ class ConfigRepositoryImpl(
} }
} }
} }
override suspend fun clear() {
_endpointConfigs.value = emptyMap()
_availableModels.value = emptyMap()
_startupConfig.value = null
_detectedBackendVersion.value = null
configCache.clear()
}
} }

View file

@ -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()
}
}

View file

@ -37,3 +37,13 @@ data class Conversation(
val createdAt: String? = null, val createdAt: String? = null,
val updatedAt: 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 }

View file

@ -1,7 +1,28 @@
package com.garfiec.librechat.core.ui.components 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.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.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.model.EModelEndpoint
import com.garfiec.librechat.core.ui.resources.Res import com.garfiec.librechat.core.ui.resources.Res
import com.garfiec.librechat.core.ui.resources.ic_agents import com.garfiec.librechat.core.ui.resources.ic_agents
@ -53,3 +74,75 @@ fun isMonochromeEndpointIcon(endpoint: String?): Boolean = when (endpoint) {
"openAI", "assistants", "agents" -> true "openAI", "assistants", "agents" -> true
else -> false 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,
)
}
}

View file

@ -21,10 +21,8 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder 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.Create
import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.SmartToy
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -43,16 +41,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.garfiec.librechat.core.common.EndpointConstants import com.garfiec.librechat.core.common.EndpointConstants
import com.garfiec.librechat.core.model.Agent import com.garfiec.librechat.core.model.Agent
import com.garfiec.librechat.core.model.EndpointConfig 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.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.*
import com.garfiec.librechat.feature.chat.resources.Res import com.garfiec.librechat.feature.chat.resources.Res
import com.garfiec.librechat.feature.chat.util.FuzzyMatch 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) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ModelSelectorSheet( fun ModelSelectorSheet(
@ -322,6 +309,9 @@ private fun EndpointGroupHeader(
EndpointIcon( EndpointIcon(
endpointName = endpointName, endpointName = endpointName,
iconUrl = iconUrl, iconUrl = iconUrl,
size = IconSize,
contentDescription = "$endpointName icon",
glyphTint = MaterialTheme.colorScheme.onSurface,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( 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 @Composable
private fun LazyItemScope.AgentListItem( private fun LazyItemScope.AgentListItem(
agent: Agent, agent: Agent,

View file

@ -1,6 +1,7 @@
package com.garfiec.librechat.feature.conversations.viewmodel package com.garfiec.librechat.feature.conversations.viewmodel
import com.garfiec.librechat.core.common.result.Result 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.ConversationRepository
import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SearchRepository 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.data.repository.TagRepository
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.ConversationTag 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.SAVED_TAG
import com.garfiec.librechat.feature.conversations.export.ConversationExporter import com.garfiec.librechat.feature.conversations.export.ConversationExporter
import com.garfiec.librechat.feature.conversations.export.ConversationImporter import com.garfiec.librechat.feature.conversations.export.ConversationImporter
@ -42,6 +44,10 @@ class ConversationListViewModelTest {
private val conversationExporter = mockk<ConversationExporter>(relaxed = true) private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
private val conversationImporter = mockk<ConversationImporter>(relaxed = true) private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
private val roleRepository = mockk<RoleRepository>(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 private lateinit var viewModel: ConversationListViewModel
@ -92,6 +98,7 @@ class ConversationListViewModelTest {
conversationExporter = conversationExporter, conversationExporter = conversationExporter,
conversationImporter = conversationImporter, conversationImporter = conversationImporter,
roleRepository = roleRepository, roleRepository = roleRepository,
configRepository = configRepository,
) )
@Test @Test
@ -539,4 +546,37 @@ class ConversationListViewModelTest {
tagRepository.toggleFavorite(any(), any()) 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")
}
} }

View file

@ -2,7 +2,9 @@ package com.garfiec.librechat.feature.conversations.components
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.garfiec.librechat.core.model.Conversation 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.SAVED_TAG
import com.garfiec.librechat.core.model.resolveEndpointIconUrl
@Immutable @Immutable
data class ConversationDisplayData( data class ConversationDisplayData(
@ -12,13 +14,17 @@ data class ConversationDisplayData(
val model: String?, val model: String?,
val updatedAt: String?, val updatedAt: String?,
val isBookmarked: Boolean, val isBookmarked: Boolean,
val endpointIconUrl: String? = null,
) )
fun Conversation.toDisplayData() = ConversationDisplayData( fun Conversation.toDisplayData(
endpointConfigs: Map<String, EndpointConfig>,
): ConversationDisplayData = ConversationDisplayData(
conversationId = conversationId ?: "", conversationId = conversationId ?: "",
title = title ?: "New Chat", title = title ?: "New Chat",
endpoint = endpoint, endpoint = endpoint,
model = model, model = model,
updatedAt = updatedAt, updatedAt = updatedAt,
isBookmarked = SAVED_TAG in tags, isBookmarked = SAVED_TAG in tags,
endpointIconUrl = resolveEndpointIconUrl(endpointConfigs),
) )

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -20,13 +19,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.model.EModelEndpoint import com.garfiec.librechat.core.model.EModelEndpoint
import com.garfiec.librechat.core.ui.components.endpointIconPainter import com.garfiec.librechat.core.ui.components.EndpointIcon
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
import com.garfiec.librechat.feature.conversations.resources.* import com.garfiec.librechat.feature.conversations.resources.*
import com.garfiec.librechat.feature.conversations.resources.Res import com.garfiec.librechat.feature.conversations.resources.Res
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@ -51,8 +48,6 @@ fun ConversationItem(
data.endpoint?.toDisplayLabel() ?: "Chat" data.endpoint?.toDisplayLabel() ?: "Chat"
} }
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -60,26 +55,12 @@ fun ConversationItem(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (iconPainter != null) { EndpointIcon(
val isMonochrome = isMonochromeEndpointIcon(data.endpoint) endpointName = data.endpoint,
Icon( iconUrl = data.endpointIconUrl,
painter = iconPainter, size = 24.dp,
contentDescription = endpointLabel, 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,
)
}
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
@ -106,7 +87,7 @@ fun ConversationItem(
val modelName = data.model val modelName = data.model
if (modelName != null) { if (modelName != null) {
Text( Text(
text = " \u00B7 ", text = " · ",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
@ -122,7 +103,7 @@ fun ConversationItem(
if (relativeTime.isNotEmpty()) { if (relativeTime.isNotEmpty()) {
Text( Text(
text = " \u00B7 ", text = " · ",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )

View file

@ -7,6 +7,7 @@ import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
import com.garfiec.librechat.core.common.result.Result 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.ConversationRepository
import com.garfiec.librechat.core.data.repository.RoleRepository import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SearchRepository 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.data.repository.TagRepository
import com.garfiec.librechat.core.model.Conversation import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.ConversationTag 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.SAVED_TAG
import com.garfiec.librechat.core.model.permissions.Permission import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType 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.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Immutable @Immutable
@ -67,6 +70,7 @@ class ConversationListViewModel(
private val conversationExporter: ConversationExporter, private val conversationExporter: ConversationExporter,
private val conversationImporter: ConversationImporter, private val conversationImporter: ConversationImporter,
private val roleRepository: RoleRepository, private val roleRepository: RoleRepository,
private val configRepository: ConfigRepository,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationListUiState()) private val _uiState = MutableStateFlow(ConversationListUiState())
@ -76,7 +80,7 @@ class ConversationListViewModel(
val events: SharedFlow<ConversationListEvent> = _events.asSharedFlow() val events: SharedFlow<ConversationListEvent> = _events.asSharedFlow()
/** Raw conversations kept for internal lookups (action sheets, etc.). */ /** 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 private var searchJob: Job? = null
init { init {
@ -84,6 +88,7 @@ class ConversationListViewModel(
observeConversations() observeConversations()
observeTags() observeTags()
observePermissions() observePermissions()
observeGroupedConversations()
} }
private fun observePermissions() { private fun observePermissions() {
@ -106,8 +111,7 @@ class ConversationListViewModel(
is Result.Success -> { is Result.Success -> {
// Only update from Room when not in search mode // Only update from Room when not in search mode
if (_uiState.value.searchQuery.isEmpty()) { if (_uiState.value.searchQuery.isEmpty()) {
conversations = result.data _conversations.value = result.data
recomputeGroupedConversations()
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
} }
} }
@ -133,16 +137,21 @@ class ConversationListViewModel(
} }
} }
private fun recomputeGroupedConversations() { private fun observeGroupedConversations() {
val grouped = groupConversationsByDate(conversations) viewModelScope.launch {
_uiState.value = _uiState.value.copy( combine(_conversations, configRepository.endpointConfigs) { convos, configs ->
groupedConversations = grouped, groupConversationsByDate(convos, configs) to convos.size
conversationCount = conversations.size, }.collect { (grouped, count) ->
) _uiState.value = _uiState.value.copy(
groupedConversations = grouped,
conversationCount = count,
)
}
}
} }
fun getConversation(conversationId: String): Conversation? = fun getConversation(conversationId: String): Conversation? =
conversations.firstOrNull { it.conversationId == conversationId } _conversations.value.firstOrNull { it.conversationId == conversationId }
fun toggleFavorite(conversation: Conversation) { fun toggleFavorite(conversation: Conversation) {
val id = conversation.conversationId ?: return val id = conversation.conversationId ?: return
@ -260,8 +269,7 @@ class ConversationListViewModel(
_uiState.value = _uiState.value.copy(isSearching = true) _uiState.value = _uiState.value.copy(isSearching = true)
when (val result = searchRepository.search(query)) { when (val result = searchRepository.search(query)) {
is Result.Success -> { is Result.Success -> {
conversations = result.data _conversations.value = result.data
recomputeGroupedConversations()
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isSearching = false, isSearching = false,
hasMore = false, hasMore = false,
@ -452,6 +460,7 @@ class ConversationListViewModel(
companion object { companion object {
private fun groupConversationsByDate( private fun groupConversationsByDate(
conversations: List<Conversation>, conversations: List<Conversation>,
endpointConfigs: Map<String, EndpointConfig>,
): List<Pair<String, List<ConversationDisplayData>>> { ): List<Pair<String, List<ConversationDisplayData>>> {
if (conversations.isEmpty()) return emptyList() if (conversations.isEmpty()) return emptyList()
return conversations return conversations
@ -462,7 +471,7 @@ class ConversationListViewModel(
?: "Unknown" ?: "Unknown"
} }
.map { (group, convos) -> .map { (group, convos) ->
group to convos.map { it.toDisplayData() } group to convos.map { it.toDisplayData(endpointConfigs) }
} }
} }
} }

View file

@ -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)
}
}

View file

@ -45,15 +45,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.garfiec.librechat.core.ui.components.endpointIconPainter import com.garfiec.librechat.core.ui.components.EndpointIcon
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
import com.garfiec.librechat.shared.resources.Res import com.garfiec.librechat.shared.resources.Res
import com.garfiec.librechat.shared.resources.agents import com.garfiec.librechat.shared.resources.agents
import com.garfiec.librechat.shared.resources.bookmark import com.garfiec.librechat.shared.resources.bookmark
@ -365,8 +363,6 @@ private fun DrawerConversationItem(
onToggleFavorite: () -> Unit = {}, onToggleFavorite: () -> Unit = {},
showBookmarkToggle: Boolean = true, showBookmarkToggle: Boolean = true,
) { ) {
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
val backgroundColor = if (data.isActive) { val backgroundColor = if (data.isActive) {
MaterialTheme.colorScheme.secondaryContainer MaterialTheme.colorScheme.secondaryContainer
} else { } else {
@ -399,24 +395,12 @@ private fun DrawerConversationItem(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary, 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 { } else {
Icon( EndpointIcon(
imageVector = Icons.Default.SmartToy, endpointName = data.endpoint,
contentDescription = null, iconUrl = data.endpointIconUrl,
modifier = Modifier.size(18.dp), size = 18.dp,
tint = if (data.isActive) { glyphTint = if (data.isActive) {
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant

View file

@ -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.formatMonthAbbrev
import com.garfiec.librechat.core.common.extensions.toInstantOrNull import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.model.Conversation 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.SAVED_TAG
import com.garfiec.librechat.core.model.resolveEndpointIconUrl
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
@ -18,6 +20,7 @@ internal data class DrawerDataSnapshot(
internal fun Conversation.toDrawerDisplayData( internal fun Conversation.toDrawerDisplayData(
activeConversationId: String?, activeConversationId: String?,
endpointConfigs: Map<String, EndpointConfig>,
): DrawerConversationDisplayData { ): DrawerConversationDisplayData {
val convId = conversationId ?: "" val convId = conversationId ?: ""
return DrawerConversationDisplayData( return DrawerConversationDisplayData(
@ -29,6 +32,7 @@ internal fun Conversation.toDrawerDisplayData(
isActive = convId == activeConversationId, isActive = convId == activeConversationId,
isFavorite = SAVED_TAG in tags, isFavorite = SAVED_TAG in tags,
tags = tags, tags = tags,
endpointIconUrl = resolveEndpointIconUrl(endpointConfigs),
) )
} }

View file

@ -16,6 +16,7 @@ data class DrawerConversationDisplayData(
val isActive: Boolean, val isActive: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
val tags: List<String>, val tags: List<String>,
val endpointIconUrl: String? = null,
) )
/** /**

View file

@ -33,7 +33,7 @@ import kotlinx.coroutines.launch
class NavHostViewModel( class NavHostViewModel(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
bannerRepository: BannerRepository, bannerRepository: BannerRepository,
configRepository: ConfigRepository, private val configRepository: ConfigRepository,
private val conversationRepository: ConversationRepository, private val conversationRepository: ConversationRepository,
private val roleRepository: RoleRepository, private val roleRepository: RoleRepository,
private val sessionTaskRunner: SessionTaskRunner, private val sessionTaskRunner: SessionTaskRunner,
@ -117,12 +117,16 @@ class NavHostViewModel(
Triple(refreshing, loadingMore, hasMore) Triple(refreshing, loadingMore, hasMore)
}, },
drawerPermissionFlags, drawerPermissionFlags,
) { data, (refreshing, loadingMore, hasMore), perms -> configRepository.endpointConfigs,
) { data, refreshState, perms, endpointConfigs ->
val (refreshing, loadingMore, hasMore) = refreshState
DrawerUiState( DrawerUiState(
groupedConversations = data.grouped.map { (group, convos) -> 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, searchQuery = data.query,
isRefreshing = refreshing, isRefreshing = refreshing,
isLoadingMore = loadingMore, isLoadingMore = loadingMore,
@ -216,6 +220,7 @@ class NavHostViewModel(
_isLoggedIn.value = false _isLoggedIn.value = false
conversationListStateHolder.reset() conversationListStateHolder.reset()
tagRepository.clearCache() tagRepository.clearCache()
configRepository.clear()
_sidebarMode.value = SidebarMode.Conversations _sidebarMode.value = SidebarMode.Conversations
_selectedSettingsCategory.value = null _selectedSettingsCategory.value = null
} }

View file

@ -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)
}
}