Merge pull request #72 from garfiec/feat/issue-68-endpoint-icon-url
feat(ui): render custom endpoint iconURL in drawer and conversation list
This commit is contained in:
commit
2b41b6f379
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 {
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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>(),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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 }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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