Merge pull request #35 from garfiec/feat/favorites-server-backed-tags

feat(favorites): move bookmarks to server-backed tags with offline cache
This commit is contained in:
Garfie Chiu 2026-04-13 20:17:04 -05:00 committed by GitHub
commit 9e06c43096
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 782 additions and 341 deletions

View file

@ -52,11 +52,6 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a
This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules.
It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`.
### Server-Synced Favorites
- `NavHostViewModel` (shared) exposes `favorites: StateFlow<Set<String>>` and `toggleFavorite()`
- Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring
- `DrawerContent` (shared) shows star icon per conversation (filled = bookmarked)
### Server Banners
- `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
- Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow<Set<String>>` (session-scoped, not persisted)

View file

@ -173,24 +173,6 @@ class DataStoreRoundTripTest {
assertThat(store.ttsPitch.first()).isEqualTo(0.8f)
}
@Test
fun settingsDataStore_roundTrip_bookmarks() = runTest(testDispatcher) {
val ds = createDataStore("settings-bookmarks")
val store = SettingsDataStore(ds)
assertThat(store.bookmarkedConversationIds.first()).isEmpty()
store.toggleBookmark("conv-001")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001")
store.toggleBookmark("conv-002")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-001", "conv-002")
// Toggle off
store.toggleBookmark("conv-001")
assertThat(store.bookmarkedConversationIds.first()).containsExactly("conv-002")
}
@Test
fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) {
val ds = createDataStore("settings-mcp")

View file

@ -0,0 +1,204 @@
package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.db.dao.ConversationDao
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.model.response.ConversationListResponse
import com.garfiec.librechat.core.network.api.ConversationsApi
import com.google.common.truth.Truth.assertThat
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ConversationRepositoryImplTest {
private val conversationsApi = mockk<ConversationsApi>(relaxed = true)
private val conversationDao = mockk<ConversationDao>(relaxed = true)
private val json = Json { ignoreUnknownKeys = true }
private lateinit var repository: ConversationRepositoryImpl
@Before
fun setup() {
repository = ConversationRepositoryImpl(
conversationsApi = conversationsApi,
conversationDao = conversationDao,
json = json,
)
}
private fun entity(id: String, tagsJson: String) = ConversationEntity(
conversationId = id,
title = "Title $id",
user = "user-1",
endpoint = null,
endpointType = null,
model = null,
agentId = null,
isArchived = false,
tags = tagsJson,
iconURL = null,
greeting = null,
modelParams = null,
createdAt = 0L,
updatedAt = 0L,
lastSyncedAt = 0L,
)
@Test
fun `loadNextPage delegates server entities to upsertPreservingTags`() = runTest {
val serverConvo = Conversation(conversationId = "convo-1", title = "Server Title")
coEvery { conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any()) } returns
ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
val captured = slot<List<ConversationEntity>>()
coEvery { conversationDao.upsertPreservingTags(capture(captured)) } answers {}
val result = repository.loadNextPage(cursor = null)
assertThat(result).isInstanceOf(Result.Success::class.java)
val entity = captured.captured.single()
assertThat(entity.conversationId).isEqualTo("convo-1")
assertThat(entity.title).isEqualTo("Server Title")
}
@Test
fun `saveConversation delegates to upsertPreservingTags`() = runTest {
val streamingConvo = Conversation(conversationId = "convo-1", title = "Updated mid-stream")
val captured = slot<ConversationEntity>()
coEvery { conversationDao.upsertPreservingTags(capture(captured)) } answers {}
repository.saveConversation(streamingConvo)
assertThat(captured.captured.conversationId).isEqualTo("convo-1")
assertThat(captured.captured.title).isEqualTo("Updated mid-stream")
}
@Test
fun `saveConversation no-op for blank conversationId`() = runTest {
val blank = Conversation(conversationId = "", title = "Blank")
repository.saveConversation(blank)
coVerify(exactly = 0) { conversationDao.upsertPreservingTags(any<ConversationEntity>()) }
}
@Test
fun `syncFavoritesFromServer adds SAVED_TAG to existing rows server reports as favorited`() = runTest {
val serverConvo = Conversation(conversationId = "convo-1", title = "Cross-client fav")
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
coEvery { conversationDao.getById("convo-1") } returns entity("convo-1", """["work"]""")
coEvery { conversationDao.getAllConversations(false) } returns flowOf(emptyList())
coEvery { conversationDao.updateTags(any(), any(), any()) } just Runs
repository.syncFavoritesFromServer()
val tagsCaptor = slot<String>()
coVerify(exactly = 1) {
conversationDao.updateTags("convo-1", capture(tagsCaptor), any())
}
assertThat(json.decodeFromString<List<String>>(tagsCaptor.captured))
.containsExactly("work", SAVED_TAG).inOrder()
}
@Test
fun `syncFavoritesFromServer removes SAVED_TAG from rows server no longer considers favorited`() = runTest {
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = emptyList(), nextCursor = null)
val staleFav = entity("convo-stale", """["work","Saved"]""")
coEvery { conversationDao.getAllConversations(false) } returns flowOf(listOf(staleFav))
coEvery { conversationDao.updateTags(any(), any(), any()) } just Runs
repository.syncFavoritesFromServer()
val tagsCaptor = slot<String>()
coVerify(exactly = 1) {
conversationDao.updateTags("convo-stale", capture(tagsCaptor), any())
}
assertThat(json.decodeFromString<List<String>>(tagsCaptor.captured))
.containsExactly("work")
}
@Test
fun `syncFavoritesFromServer upserts missing conversations with SAVED_TAG preset`() = runTest {
val serverConvo = Conversation(conversationId = "convo-new", title = "From web")
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
coEvery { conversationDao.getById("convo-new") } returns null
coEvery { conversationDao.getAllConversations(false) } returns flowOf(emptyList())
val upsertCaptor = slot<ConversationEntity>()
coEvery { conversationDao.upsert(capture(upsertCaptor)) } answers {}
repository.syncFavoritesFromServer()
assertThat(upsertCaptor.captured.conversationId).isEqualTo("convo-new")
assertThat(json.decodeFromString<List<String>>(upsertCaptor.captured.tags))
.containsExactly(SAVED_TAG)
}
@Test
fun `syncFavoritesFromServer paginates through multiple pages`() = runTest {
val page1 = ConversationListResponse(
conversations = listOf(Conversation(conversationId = "c1")),
nextCursor = "cursor-2",
)
val page2 = ConversationListResponse(
conversations = listOf(Conversation(conversationId = "c2")),
nextCursor = null,
)
coEvery {
conversationsApi.getConversations(null, any(), any(), any(), any(), any(), any())
} returns page1
coEvery {
conversationsApi.getConversations("cursor-2", any(), any(), any(), any(), any(), any())
} returns page2
coEvery { conversationDao.getById(any()) } returns null
coEvery { conversationDao.getAllConversations(false) } returns flowOf(emptyList())
coEvery { conversationDao.upsert(any()) } answers {}
repository.syncFavoritesFromServer()
coVerify(exactly = 1) {
conversationsApi.getConversations(null, any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 1) {
conversationsApi.getConversations("cursor-2", any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 2) { conversationDao.upsert(any()) }
}
@Test
fun `syncFavoritesFromServer no-op when local and server match`() = runTest {
val serverConvo = Conversation(conversationId = "convo-1")
coEvery {
conversationsApi.getConversations(any(), any(), any(), any(), any(), any(), any())
} returns ConversationListResponse(conversations = listOf(serverConvo), nextCursor = null)
coEvery { conversationDao.getById("convo-1") } returns entity("convo-1", """["Saved"]""")
coEvery {
conversationDao.getAllConversations(false)
} returns flowOf(listOf(entity("convo-1", """["Saved"]""")))
repository.syncFavoritesFromServer()
coVerify(exactly = 0) { conversationDao.updateTags(any(), any(), any()) }
coVerify(exactly = 0) { conversationDao.upsert(any()) }
}
}

View file

@ -0,0 +1,188 @@
package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.db.dao.ConversationTagDao
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.network.api.TagsApi
import com.google.common.truth.Truth.assertThat
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TagRepositoryImplTest {
private val tagsApi = mockk<TagsApi>(relaxed = true)
private val tagDao = mockk<ConversationTagDao>(relaxed = true)
private val conversationRepository = mockk<ConversationRepository>(relaxed = true)
private lateinit var repository: TagRepositoryImpl
private val serverTags = listOf(
ConversationTag(tag = "work", count = 3, position = 0),
ConversationTag(tag = "personal", count = 2, position = 1),
ConversationTag(tag = SAVED_TAG, count = 5, position = 2),
)
@Before
fun setup() {
repository = TagRepositoryImpl(
tagsApi = tagsApi,
tagDao = tagDao,
conversationRepository = conversationRepository,
)
}
@Test
fun `refreshTags fetches from api and replaces all tags in dao`() = runTest {
coEvery { tagsApi.getTags() } returns serverTags
coEvery { tagDao.replaceAll(any()) } just Runs
val result = repository.refreshTags()
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) { tagsApi.getTags() }
coVerify(exactly = 1) {
tagDao.replaceAll(
match { entities ->
entities.size == 3 &&
entities.map { it.tag }.containsAll(listOf("work", "personal", SAVED_TAG))
},
)
}
}
@Test
fun `refreshTags returns Error when api throws`() = runTest {
coEvery { tagsApi.getTags() } throws RuntimeException("network down")
val result = repository.refreshTags()
assertThat(result).isInstanceOf(Result.Error::class.java)
coVerify(exactly = 0) { tagDao.replaceAll(any()) }
}
@Test
fun `toggleFavorite adds SAVED_TAG when not present and preserves existing tags`() = runTest {
coEvery { tagsApi.updateConversationTags(any(), any()) } just Runs
coEvery { conversationRepository.updateConversationTagsLocal(any(), any()) } just Runs
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work"),
)
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) {
tagsApi.updateConversationTags("convo-1", listOf("work", SAVED_TAG))
}
coVerify(exactly = 1) {
conversationRepository.updateConversationTagsLocal("convo-1", listOf("work", SAVED_TAG))
}
}
@Test
fun `toggleFavorite removes SAVED_TAG when already present`() = runTest {
coEvery { tagsApi.updateConversationTags(any(), any()) } just Runs
coEvery { conversationRepository.updateConversationTagsLocal(any(), any()) } just Runs
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work", SAVED_TAG),
)
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) {
tagsApi.updateConversationTags("convo-1", listOf("work"))
}
coVerify(exactly = 1) {
conversationRepository.updateConversationTagsLocal("convo-1", listOf("work"))
}
}
@Test
fun `toggleFavorite removes SAVED_TAG when it is the only tag`() = runTest {
coEvery { tagsApi.updateConversationTags(any(), any()) } just Runs
coEvery { conversationRepository.updateConversationTagsLocal(any(), any()) } just Runs
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf(SAVED_TAG),
)
assertThat(result).isInstanceOf(Result.Success::class.java)
coVerify(exactly = 1) {
tagsApi.updateConversationTags("convo-1", emptyList())
}
}
@Test
fun `toggleFavorite returns Error when api throws and skips local update`() = runTest {
coEvery {
tagsApi.updateConversationTags(any(), any())
} throws RuntimeException("boom")
val result = repository.toggleFavorite(
conversationId = "convo-1",
currentTags = emptyList(),
)
assertThat(result).isInstanceOf(Result.Error::class.java)
coVerify(exactly = 0) {
conversationRepository.updateConversationTagsLocal(any(), any())
}
}
@Test
fun `observeTags maps dao entities to domain models sorted by position`() = runTest {
val entities = listOf(
ConversationTagEntity(
id = 1,
tag = "work",
user = "user-1",
description = null,
count = 3,
position = 0,
createdAt = 0L,
updatedAt = 0L,
),
ConversationTagEntity(
id = 2,
tag = "personal",
user = "user-1",
description = null,
count = 2,
position = 1,
createdAt = 0L,
updatedAt = 0L,
),
)
every { tagDao.getAllTags() } returns flowOf(entities)
val result = repository.observeTags().first()
assertThat(result).hasSize(2)
assertThat(result.map { it.tag }).containsExactly("work", "personal").inOrder()
assertThat(result.map { it.count }).containsExactly(3, 2).inOrder()
}
@Test
fun `clearCache delegates to tagDao deleteAll`() = runTest {
coEvery { tagDao.deleteAll() } just Runs
repository.clearCache()
coVerify(exactly = 1) { tagDao.deleteAll() }
}
}

View file

@ -6,7 +6,6 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -69,10 +68,6 @@ class SettingsDataStore(
prefs[KEY_TTS_VOICE_NAME] ?: ""
}
val bookmarkedConversationIds: Flow<Set<String>> = dataStore.data.map { prefs ->
prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
}
val tabletSidebarOpen: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true
}
@ -308,17 +303,6 @@ class SettingsDataStore(
}
}
suspend fun toggleBookmark(conversationId: String) {
dataStore.edit { prefs ->
val current = prefs[KEY_BOOKMARKED_CONVERSATIONS] ?: emptySet()
prefs[KEY_BOOKMARKED_CONVERSATIONS] = if (conversationId in current) {
current - conversationId
} else {
current + conversationId
}
}
}
companion object {
private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer")
private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size")
@ -334,7 +318,6 @@ class SettingsDataStore(
private val KEY_TTS_SPEECH_RATE = floatPreferencesKey("tts_speech_rate")
private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch")
private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name")
private val KEY_BOOKMARKED_CONVERSATIONS = stringSetPreferencesKey("bookmarked_conversations")
private val KEY_TABLET_SIDEBAR_OPEN = booleanPreferencesKey("tablet_sidebar_open")
private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled")
private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt")

View file

@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
import kotlinx.coroutines.flow.Flow
@ -26,6 +27,28 @@ interface ConversationDao {
@Upsert
suspend fun upsertAll(conversations: List<ConversationEntity>)
// Atomic read-merge-write that preserves locally-stored tags when a row
// already exists. Used by paths that receive server responses without the
// `tags` field (list endpoint + streaming save). Must run inside a single
// transaction so it can't be interleaved by a concurrent updateTags write
// from `syncFavoritesFromServer` (which would otherwise get clobbered).
@Transaction
suspend fun upsertPreservingTags(entities: List<ConversationEntity>) {
for (entity in entities) {
if (entity.conversationId.isBlank()) {
upsert(entity)
continue
}
val existing = getById(entity.conversationId)
val toUpsert = if (existing != null) entity.copy(tags = existing.tags) else entity
upsert(toUpsert)
}
}
suspend fun upsertPreservingTags(entity: ConversationEntity) {
upsertPreservingTags(listOf(entity))
}
@Query("DELETE FROM conversations WHERE conversationId = :id")
suspend fun deleteById(id: String)
@ -38,6 +61,9 @@ interface ConversationDao {
@Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id")
suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long)
@Query("UPDATE conversations SET tags = :tagsJson, updatedAt = :updatedAt WHERE conversationId = :id")
suspend fun updateTags(id: String, tagsJson: String, updatedAt: Long)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
}

View file

@ -2,21 +2,25 @@ package com.garfiec.librechat.core.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ConversationTagDao {
@Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC")
fun getTagsForUser(userId: String): Flow<List<ConversationTagEntity>>
@Upsert
suspend fun upsert(tag: ConversationTagEntity)
@Query("SELECT * FROM conversation_tags ORDER BY position ASC")
fun getAllTags(): Flow<List<ConversationTagEntity>>
@Upsert
suspend fun upsertAll(tags: List<ConversationTagEntity>)
@Query("DELETE FROM conversation_tags WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("DELETE FROM conversation_tags")
suspend fun deleteAll()
@Transaction
suspend fun replaceAll(tags: List<ConversationTagEntity>) {
deleteAll()
upsertAll(tags)
}
}

View file

@ -0,0 +1,42 @@
package com.garfiec.librechat.core.data.mapper
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
import com.garfiec.librechat.core.model.ConversationTag
import kotlin.time.Clock
import kotlin.time.Instant
fun ConversationTag.toEntity(): ConversationTagEntity = ConversationTagEntity(
tag = tag ?: "",
user = user ?: "",
description = description,
count = count,
position = position,
createdAt = parseTagTimestamp(createdAt),
updatedAt = parseTagTimestamp(updatedAt),
)
fun ConversationTagEntity.toModel(): ConversationTag = ConversationTag(
id = null,
tag = tag,
user = user,
description = description,
count = count,
position = position,
createdAt = formatTagTimestamp(createdAt),
updatedAt = formatTagTimestamp(updatedAt),
)
fun List<ConversationTagEntity>.toModels(): List<ConversationTag> = map { it.toModel() }
private fun parseTagTimestamp(dateString: String?): Long {
if (dateString == null) return Clock.System.now().toEpochMilliseconds()
return try {
Instant.parse(dateString).toEpochMilliseconds()
} catch (_: Exception) {
Clock.System.now().toEpochMilliseconds()
}
}
private fun formatTagTimestamp(epochMillis: Long): String {
return Instant.fromEpochMilliseconds(epochMillis).toString()
}

View file

@ -30,4 +30,6 @@ interface ConversationRepository {
suspend fun importConversation(jsonContent: String): Result<Conversation>
suspend fun deleteAll(): Result<Unit>
suspend fun saveConversation(conversation: Conversation)
suspend fun updateConversationTagsLocal(id: String, tags: List<String>)
suspend fun syncFavoritesFromServer(): Result<Unit>
}

View file

@ -1,6 +1,5 @@
package com.garfiec.librechat.core.data.repository
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.data.db.dao.ConversationDao
@ -8,15 +7,21 @@ import com.garfiec.librechat.core.data.mapper.toEntity
import com.garfiec.librechat.core.data.mapper.toModel
import com.garfiec.librechat.core.data.mapper.toModels
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.model.request.ForkConversationRequest
import com.garfiec.librechat.core.network.api.ConversationsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import kotlin.time.Clock
class ConversationRepositoryImpl(
private val conversationsApi: ConversationsApi,
private val conversationDao: ConversationDao,
private val json: Json,
) : ConversationRepository {
override fun observeConversations(isArchived: Boolean): Flow<Result<List<Conversation>>> {
@ -44,19 +49,15 @@ class ConversationRepositoryImpl(
sortBy = sortBy,
sortDirection = sortDirection,
)
// Cache conversations locally
val entities = response.conversations.map { it.toEntity() }
conversationDao.upsertAll(entities)
conversationDao.upsertPreservingTags(response.conversations.map { it.toEntity() })
response.nextCursor
}
}
override suspend fun getConversation(id: String): Result<Conversation> {
// Try local cache first
val cached = conversationDao.getById(id)
if (cached != null) return Result.Success(cached.toModel())
// Fetch from network
return safeApiCall {
val conversation = conversationsApi.getConversation(id)
conversationDao.upsert(conversation.toEntity())
@ -75,7 +76,6 @@ class ConversationRepositoryImpl(
override suspend fun generateTitle(conversationId: String): Result<String> {
return safeApiCall {
val response = conversationsApi.generateTitle(conversationId)
// Update the local cache with the generated title
conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds())
response.title
}
@ -153,16 +153,59 @@ class ConversationRepositoryImpl(
override suspend fun saveConversation(conversation: Conversation) {
val id = conversation.conversationId ?: return
if (id.isBlank()) return
conversationDao.upsert(conversation.toEntity())
conversationDao.upsertPreservingTags(conversation.toEntity())
}
suspend fun refreshConversations() {
try {
val response = conversationsApi.getConversations()
val entities = response.conversations.map { it.toEntity() }
conversationDao.upsertAll(entities)
} catch (e: Exception) {
Logger.w(e) { "Failed to refresh conversations" }
override suspend fun updateConversationTagsLocal(id: String, tags: List<String>) {
conversationDao.updateTags(id, encodeTags(tags), Clock.System.now().toEpochMilliseconds())
}
// Reconciles SAVED_TAG attachment between the local Room cache and server by
// paginating `GET /api/convos?tags=Saved`. Needed because upstream's
// getConvosByCursor projection omits `tags`, so the main conversation list
// endpoint can't deliver cross-client favorite changes. Only the reserved
// SAVED_TAG is synced; other user-created tags aren't fetched here. Known
// gap: the stale-removal pass below only scans non-archived rows, so a
// conversation that was archived while favorited and later unfavorited
// elsewhere will keep its local SAVED_TAG until the user unarchives it.
override suspend fun syncFavoritesFromServer(): Result<Unit> = safeApiCall {
val serverFavoriteIds = mutableSetOf<String>()
var cursor: String? = null
do {
val response = conversationsApi.getConversations(
cursor = cursor,
tags = listOf(SAVED_TAG),
)
for (convo in response.conversations) {
val id = convo.conversationId ?: continue
serverFavoriteIds.add(id)
val existing = conversationDao.getById(id)
if (existing == null) {
conversationDao.upsert(
convo.toEntity().copy(tags = encodeTags(listOf(SAVED_TAG))),
)
} else {
val currentTags = existing.toModel().tags
if (SAVED_TAG !in currentTags) {
updateConversationTagsLocal(id, currentTags + SAVED_TAG)
}
}
}
cursor = response.nextCursor
} while (cursor != null)
val localEntities = conversationDao.getAllConversations(isArchived = false).first()
for (entity in localEntities) {
val currentTags = entity.toModel().tags
if (SAVED_TAG in currentTags && entity.conversationId !in serverFavoriteIds) {
updateConversationTagsLocal(
entity.conversationId,
currentTags.filterNot { it == SAVED_TAG },
)
}
}
}
private fun encodeTags(tags: List<String>): String =
json.encodeToString(ListSerializer(serializer<String>()), tags)
}

View file

@ -2,8 +2,12 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.ConversationTag
import kotlinx.coroutines.flow.Flow
interface TagRepository {
suspend fun getTags(): Result<List<ConversationTag>>
suspend fun updateConversationTags(conversationId: String, tags: List<String>): Result<Unit>
fun observeTags(): Flow<List<ConversationTag>>
suspend fun refreshTags(): Result<Unit>
suspend fun setConversationTags(conversationId: String, tags: List<String>): Result<Unit>
suspend fun toggleFavorite(conversationId: String, currentTags: List<String>): Result<Unit>
suspend fun clearCache()
}

View file

@ -2,20 +2,50 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.data.db.dao.ConversationTagDao
import com.garfiec.librechat.core.data.mapper.toEntity
import com.garfiec.librechat.core.data.mapper.toModels
import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.network.api.TagsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class TagRepositoryImpl(
private val tagsApi: TagsApi,
private val tagDao: ConversationTagDao,
private val conversationRepository: ConversationRepository,
) : TagRepository {
override suspend fun getTags(): Result<List<ConversationTag>> =
safeApiCall { tagsApi.getTags() }
override fun observeTags(): Flow<List<ConversationTag>> =
tagDao.getAllTags().map { it.toModels() }
override suspend fun updateConversationTags(
override suspend fun refreshTags(): Result<Unit> = safeApiCall {
val tags = tagsApi.getTags()
tagDao.replaceAll(tags.map { it.toEntity() })
}
override suspend fun setConversationTags(
conversationId: String,
tags: List<String>,
): Result<Unit> = safeApiCall {
tagsApi.updateConversationTags(conversationId, tags)
conversationRepository.updateConversationTagsLocal(conversationId, tags)
}
override suspend fun toggleFavorite(
conversationId: String,
currentTags: List<String>,
): Result<Unit> {
val newTags = if (SAVED_TAG in currentTags) {
currentTags.filterNot { it == SAVED_TAG }
} else {
currentTags + SAVED_TAG
}
return setConversationTags(conversationId, newTags)
}
override suspend fun clearCache() {
tagDao.deleteAll()
}
}

View file

@ -2,7 +2,6 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.UserFavorite
import com.garfiec.librechat.core.model.response.TermsResponse
interface UserRepository {
@ -13,6 +12,4 @@ interface UserRepository {
suspend fun resendVerification(email: String): Result<Unit>
suspend fun getTerms(): Result<TermsResponse>
suspend fun acceptTerms(): Result<Unit>
suspend fun getFavorites(): Result<List<UserFavorite>>
suspend fun updateFavorites(favorites: List<UserFavorite>): Result<User>
}

View file

@ -3,7 +3,6 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.UserFavorite
import com.garfiec.librechat.core.model.request.ResendVerificationRequest
import com.garfiec.librechat.core.model.request.VerifyEmailRequest
import com.garfiec.librechat.core.model.response.TermsResponse
@ -40,12 +39,4 @@ class UserRepositoryImpl(
override suspend fun acceptTerms(): Result<Unit> = safeApiCall {
userApi.acceptTerms()
}
override suspend fun getFavorites(): Result<List<UserFavorite>> = safeApiCall {
userApi.getFavorites()
}
override suspend fun updateFavorites(favorites: List<UserFavorite>): Result<User> = safeApiCall {
userApi.updateFavorites(favorites)
}
}

View file

@ -0,0 +1,3 @@
package com.garfiec.librechat.core.model
const val SAVED_TAG = "Saved"

View file

@ -16,14 +16,6 @@ data class User(
val role: String = "USER",
val twoFactorEnabled: Boolean = false,
val termsAccepted: Boolean = false,
val favorites: List<UserFavorite> = emptyList(),
val createdAt: String? = null,
val updatedAt: String? = null,
)
@Serializable
data class UserFavorite(
val agentId: String? = null,
val model: String? = null,
val endpoint: String? = null,
)

View file

@ -1,16 +0,0 @@
package com.garfiec.librechat.core.model.request
import kotlinx.serialization.Serializable
@Serializable
data class CreateTagRequest(
val tag: String,
val description: String? = null,
val position: Int? = null,
)
@Serializable
data class UpdateTagRequest(
val description: String? = null,
val position: Int? = null,
)

View file

@ -30,10 +30,6 @@ class UserSerializationTest {
role = "ADMIN",
twoFactorEnabled = true,
termsAccepted = true,
favorites = listOf(
UserFavorite(agentId = "agent-1", model = "gpt-4o", endpoint = "openAI"),
UserFavorite(model = "claude-3-opus"),
),
createdAt = "2026-01-01T00:00:00Z",
updatedAt = "2026-03-28T12:00:00Z",
)
@ -55,9 +51,6 @@ class UserSerializationTest {
"role": "USER",
"twoFactorEnabled": false,
"termsAccepted": true,
"favorites": [
{"agentId": "a1", "endpoint": "agents"}
],
"extraField": "ignored"
}
""".trimIndent()
@ -65,20 +58,6 @@ class UserSerializationTest {
assertEquals("user-srv", decoded.id)
assertEquals("mongo-srv", decoded.mongoId)
assertEquals("Server User", decoded.name)
assertEquals(1, decoded.favorites.size)
assertEquals("a1", decoded.favorites[0].agentId)
}
@Test
fun userFavoriteRoundTrip() {
val original = UserFavorite(
agentId = "agent-x",
model = "gpt-4o-mini",
endpoint = "openAI",
)
val encoded = json.encodeToString(UserFavorite.serializer(), original)
val decoded = json.decodeFromString(UserFavorite.serializer(), encoded)
assertEquals(original, decoded)
}
@Test
@ -89,6 +68,5 @@ class UserSerializationTest {
assertEquals("USER", user.role)
assertEquals(false, user.twoFactorEnabled)
assertEquals(false, user.termsAccepted)
assertEquals(emptyList(), user.favorites)
}
}

View file

@ -42,7 +42,9 @@ class ConversationsApi constructor(
parameter("cursor", cursor)
parameter("limit", limit)
parameter("isArchived", isArchived)
tags?.forEach { tag -> parameter("tags[]", tag) }
// Upstream's route handler reads req.query.tags directly, so we send
// repeated `tags=value` params rather than PHP-style `tags[]=value`.
tags?.forEach { tag -> parameter("tags", tag) }
search?.let { parameter("search", it) }
sortBy?.let { parameter("sortBy", it) }
sortDirection?.let { parameter("sortDirection", it) }

View file

@ -1,19 +1,15 @@
package com.garfiec.librechat.core.network.api
import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.request.CreateTagRequest
import com.garfiec.librechat.core.model.request.TagUpdateRequest
import com.garfiec.librechat.core.model.request.UpdateTagRequest
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.path
class TagsApi constructor(
class TagsApi(
private val client: HttpClient,
) {
suspend fun getTags(): List<ConversationTag> =
@ -21,24 +17,6 @@ class TagsApi constructor(
url { path("api/tags") }
}.body()
suspend fun createTag(request: CreateTagRequest): ConversationTag =
client.post {
url { path("api/tags") }
setBody(request)
}.body()
suspend fun updateTag(tag: String, request: UpdateTagRequest): ConversationTag =
client.put {
url { path("api/tags/$tag") }
setBody(request)
}.body()
suspend fun deleteTag(tag: String) {
client.delete {
url { path("api/tags/$tag") }
}
}
suspend fun updateConversationTags(conversationId: String, tags: List<String>) {
client.put {
url { path("api/tags/convo/$conversationId") }

View file

@ -1,12 +1,10 @@
package com.garfiec.librechat.core.network.api
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.UserFavorite
import com.garfiec.librechat.core.model.request.OtpVerificationRequest
import com.garfiec.librechat.core.model.request.ResendVerificationRequest
import com.garfiec.librechat.core.model.request.VerifyEmailRequest
import com.garfiec.librechat.core.model.response.TermsResponse
import com.garfiec.librechat.core.network.api.dto.UpdateFavoritesRequest
import com.garfiec.librechat.core.network.api.dto.UpdatePluginsRequest
import com.garfiec.librechat.core.network.api.dto.UserUpdateRequest
import io.ktor.client.HttpClient
@ -82,17 +80,6 @@ class UserApi constructor(
url { path("api/user/avatar") }
}.body()
suspend fun getFavorites(): List<UserFavorite> =
client.get {
url { path("api/user/favorites") }
}.body()
suspend fun updateFavorites(favorites: List<UserFavorite>): User =
client.post {
url { path("api/user/favorites") }
setBody(UpdateFavoritesRequest(favorites = favorites))
}.body()
suspend fun updatePlugins(plugins: List<String>): User =
client.post {
url { path("api/user/plugins") }

View file

@ -1,9 +0,0 @@
package com.garfiec.librechat.core.network.api.dto
import com.garfiec.librechat.core.model.UserFavorite
import kotlinx.serialization.Serializable
@Serializable
data class UpdateFavoritesRequest(
val favorites: List<UserFavorite>,
)

View file

@ -22,7 +22,7 @@
## Tags
- `TagFilterBar` displays `FilterChip` per tag (multi-select toggle)
- `TagPicker` for assigning tags to a conversation
- Tags fetched via `TagRepository.getTags()`, hidden if `count == 0`
- Tags observed via `TagRepository.observeTags()` (Room-backed Flow); `SAVED_TAG` and `count == 0` tags are filtered out in the ViewModel
- `toggleTag()` / `clearTagFilter()` reload conversations with new filter
## CRUD Actions (via `ConversationActions` component)

View file

@ -3,7 +3,6 @@ package com.garfiec.librechat.feature.conversations.di
import android.app.Application
import android.content.Context
import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.MessageRepository
@ -27,7 +26,6 @@ class ConversationsModuleVerificationTest {
SearchRepository::class,
ConfigRepository::class,
ServerDataStore::class,
SettingsDataStore::class,
),
)
}

View file

@ -1,13 +1,13 @@
package com.garfiec.librechat.feature.conversations.viewmodel
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.SearchRepository
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.SAVED_TAG
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
import com.google.common.truth.Truth.assertThat
@ -17,7 +17,6 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
@ -40,7 +39,6 @@ class ConversationListViewModelTest {
private val shareRepository = mockk<ShareRepository>(relaxed = true)
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
private val settingsDataStore = mockk<SettingsDataStore>(relaxed = true)
private lateinit var viewModel: ConversationListViewModel
@ -61,6 +59,7 @@ class ConversationListViewModelTest {
ConversationTag(tag = "work", count = 3),
ConversationTag(tag = "personal", count = 2),
ConversationTag(tag = "empty", count = 0),
ConversationTag(tag = SAVED_TAG, count = 5),
)
@Before
@ -70,9 +69,10 @@ class ConversationListViewModelTest {
every { conversationRepository.observeConversations(any()) } returns flowOf(
Result.Success(testConversations),
)
every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet())
coEvery { conversationRepository.loadNextPage(any(), tags = any()) } returns Result.Success(null)
coEvery { tagRepository.getTags() } returns Result.Success(testTags)
coEvery { conversationRepository.syncFavoritesFromServer() } returns Result.Success(Unit)
every { tagRepository.observeTags() } returns flowOf(testTags)
coEvery { tagRepository.refreshTags() } returns Result.Success(Unit)
}
@After
@ -87,7 +87,6 @@ class ConversationListViewModelTest {
shareRepository = shareRepository,
conversationExporter = conversationExporter,
conversationImporter = conversationImporter,
settingsDataStore = settingsDataStore,
)
@Test
@ -101,7 +100,7 @@ class ConversationListViewModelTest {
}
@Test
fun `initial state loads tags with non-zero counts`() = runTest {
fun `initial state loads tags with non-zero counts and excludes SAVED_TAG`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
@ -433,16 +432,106 @@ class ConversationListViewModelTest {
}
@Test
fun `updateConversationTags reloads on success`() = runTest {
coEvery { tagRepository.updateConversationTags("convo-1", listOf("work")) } returns
fun `updateConversationTags persists new tag list when conversation was not favorited`() = runTest {
coEvery { tagRepository.setConversationTags("convo-1", listOf("work")) } returns
Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
viewModel.updateConversationTags("convo-1", listOf("work"))
val convo = Conversation(conversationId = "convo-1", tags = listOf("old"))
viewModel.updateConversationTags(convo, listOf("work"))
advanceUntilIdle()
coVerify { tagRepository.updateConversationTags("convo-1", listOf("work")) }
coVerify { tagRepository.setConversationTags("convo-1", listOf("work")) }
}
@Test
fun `updateConversationTags preserves SAVED_TAG when conversation was favorited`() = runTest {
coEvery { tagRepository.setConversationTags(any(), any()) } returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = "convo-1", tags = listOf("work", SAVED_TAG))
// Picker submits the user-tag list WITHOUT SAVED_TAG (the screen strips it).
viewModel.updateConversationTags(convo, listOf("projects"))
advanceUntilIdle()
coVerify { tagRepository.setConversationTags("convo-1", listOf("projects", SAVED_TAG)) }
}
@Test
fun `updateConversationTags strips SAVED_TAG from picker submission when not favorited`() = runTest {
coEvery { tagRepository.setConversationTags(any(), any()) } returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = "convo-1", tags = listOf("work"))
// Defensive: if the picker ever submits SAVED_TAG on an unfavorited convo, drop it.
viewModel.updateConversationTags(convo, listOf("projects", SAVED_TAG))
advanceUntilIdle()
coVerify { tagRepository.setConversationTags("convo-1", listOf("projects")) }
}
@Test
fun `toggleFavorite on unfavorited convo delegates to repo`() = runTest {
coEvery {
tagRepository.toggleFavorite(any(), any())
} returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = "convo-1", tags = listOf("work"))
viewModel.toggleFavorite(convo)
advanceUntilIdle()
coVerify {
tagRepository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work"),
)
}
}
@Test
fun `toggleFavorite on favorited convo delegates to repo`() = runTest {
coEvery {
tagRepository.toggleFavorite(any(), any())
} returns Result.Success(Unit)
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(
conversationId = "convo-1",
tags = listOf("work", SAVED_TAG),
)
viewModel.toggleFavorite(convo)
advanceUntilIdle()
coVerify {
tagRepository.toggleFavorite(
conversationId = "convo-1",
currentTags = listOf("work", SAVED_TAG),
)
}
}
@Test
fun `toggleFavorite with null conversationId is a no-op`() = runTest {
viewModel = createViewModel()
advanceUntilIdle()
val convo = Conversation(conversationId = null, tags = listOf("work"))
viewModel.toggleFavorite(convo)
advanceUntilIdle()
coVerify(exactly = 0) {
tagRepository.toggleFavorite(any(), any())
}
}
}

View file

@ -3,6 +3,7 @@ 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.EModelEndpoint
import com.garfiec.librechat.core.model.SAVED_TAG
@Immutable
data class ConversationDisplayData(
@ -14,11 +15,11 @@ data class ConversationDisplayData(
val isBookmarked: Boolean,
)
fun Conversation.toDisplayData(bookmarkedIds: Set<String>) = ConversationDisplayData(
fun Conversation.toDisplayData() = ConversationDisplayData(
conversationId = conversationId ?: "",
title = title ?: "New Chat",
endpoint = endpoint,
model = model,
updatedAt = updatedAt,
isBookmarked = conversationId in bookmarkedIds,
isBookmarked = SAVED_TAG in tags,
)

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.ui.components.EmptyState
import com.garfiec.librechat.core.ui.components.ErrorBanner
import com.garfiec.librechat.core.ui.components.LoadingIndicator
@ -326,12 +327,10 @@ fun ConversationListScreen(
ConversationActions(
conversation = selectedConversation!!,
onDismiss = { selectedConversation = null },
isBookmarked = selectedConversation?.conversationId?.let {
viewModel.isBookmarked(it)
} ?: false,
isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()),
onBookmarkToggle = {
selectedConversation?.conversationId?.let { id ->
viewModel.toggleBookmark(id)
selectedConversation?.let { convo ->
viewModel.toggleFavorite(convo)
}
selectedConversation = null
},
@ -383,10 +382,10 @@ fun ConversationListScreen(
if (showTagPicker && tagPickerConversation != null) {
TagPicker(
availableTags = uiState.tags,
currentTags = tagPickerConversation?.tags ?: emptyList(),
currentTags = tagPickerConversation?.tags?.filterNot { it == SAVED_TAG } ?: emptyList(),
onTagsChange = { newTags ->
tagPickerConversation?.conversationId?.let { id ->
viewModel.updateConversationTags(id, newTags)
tagPickerConversation?.let { convo ->
viewModel.updateConversationTags(convo, newTags)
}
},
onDismiss = {

View file

@ -7,19 +7,20 @@ 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.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.SearchRepository
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.SAVED_TAG
import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData
import com.garfiec.librechat.feature.conversations.components.toDisplayData
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
import com.garfiec.librechat.feature.conversations.export.ExportFormat
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@ -59,7 +60,6 @@ class ConversationListViewModel(
private val shareRepository: ShareRepository,
private val conversationExporter: ConversationExporter,
private val conversationImporter: ConversationImporter,
private val settingsDataStore: SettingsDataStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationListUiState())
@ -70,14 +70,12 @@ class ConversationListViewModel(
/** Raw conversations kept for internal lookups (action sheets, etc.). */
private var conversations: List<Conversation> = emptyList()
private var bookmarkedIds: Set<String> = emptySet()
private var searchJob: Job? = null
init {
loadConversations()
observeConversations()
observeBookmarks()
loadTags()
observeTags()
}
private fun observeConversations() {
@ -104,17 +102,18 @@ class ConversationListViewModel(
}
}
private fun observeBookmarks() {
private fun observeTags() {
viewModelScope.launch {
settingsDataStore.bookmarkedConversationIds.collect { ids ->
bookmarkedIds = ids
recomputeGroupedConversations()
tagRepository.observeTags().collect { tags ->
_uiState.value = _uiState.value.copy(
tags = tags.filter { it.count > 0 && it.tag != SAVED_TAG },
)
}
}
}
private fun recomputeGroupedConversations() {
val grouped = groupConversationsByDate(conversations, bookmarkedIds)
val grouped = groupConversationsByDate(conversations)
_uiState.value = _uiState.value.copy(
groupedConversations = grouped,
conversationCount = conversations.size,
@ -124,12 +123,20 @@ class ConversationListViewModel(
fun getConversation(conversationId: String): Conversation? =
conversations.firstOrNull { it.conversationId == conversationId }
fun isBookmarked(conversationId: String): Boolean =
conversationId in bookmarkedIds
fun toggleBookmark(conversationId: String) {
fun toggleFavorite(conversation: Conversation) {
val id = conversation.conversationId ?: return
viewModelScope.launch {
settingsDataStore.toggleBookmark(conversationId)
when (val result = tagRepository.toggleFavorite(id, conversation.tags)) {
is Result.Error -> {
_events.emit(
ConversationListEvent.ShowError(
result.message ?: "Failed to update favorite",
),
)
}
is Result.Success -> { /* no-op */ }
is Result.Loading -> { /* no-op */ }
}
}
}
@ -181,40 +188,25 @@ class ConversationListViewModel(
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null }
when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) {
is Result.Success -> {
_uiState.value = _uiState.value.copy(
isRefreshing = false,
nextCursor = result.data,
hasMore = result.data != null,
)
coroutineScope {
launch {
when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) {
is Result.Success -> {
_uiState.value = _uiState.value.copy(
nextCursor = result.data,
hasMore = result.data != null,
)
}
is Result.Error -> {
_uiState.value = _uiState.value.copy(error = result.message)
}
is Result.Loading -> { /* no-op */ }
}
}
is Result.Error -> {
_uiState.value = _uiState.value.copy(
isRefreshing = false,
error = result.message,
)
}
is Result.Loading -> { /* no-op */ }
}
loadTags()
}
}
private fun loadTags() {
viewModelScope.launch {
when (val result = tagRepository.getTags()) {
is Result.Success -> {
_uiState.value = _uiState.value.copy(
tags = result.data.filter { it.count > 0 },
)
}
is Result.Error -> {
// Tags are non-critical, silently fail
Logger.d(result.exception) { "Failed to load tags: ${result.message}" }
}
is Result.Loading -> { /* no-op */ }
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
}
_uiState.value = _uiState.value.copy(isRefreshing = false)
}
}
@ -265,12 +257,17 @@ class ConversationListViewModel(
}
}
fun updateConversationTags(conversationId: String, tags: List<String>) {
fun updateConversationTags(conversation: Conversation, userTags: List<String>) {
val id = conversation.conversationId ?: return
val wasFavorited = SAVED_TAG in conversation.tags
val cleaned = userTags.filterNot { it == SAVED_TAG }
val finalTags = if (wasFavorited) cleaned + SAVED_TAG else cleaned
viewModelScope.launch {
when (tagRepository.updateConversationTags(conversationId, tags)) {
when (tagRepository.setConversationTags(id, finalTags)) {
is Result.Success -> {
loadTags()
refresh()
// Room Flow already emits the updated conversation via
// observeConversations; we only need fresh tag counts.
tagRepository.refreshTags()
}
is Result.Error -> {
_events.emit(ConversationListEvent.ShowError("Failed to update tags"))
@ -434,7 +431,6 @@ class ConversationListViewModel(
companion object {
private fun groupConversationsByDate(
conversations: List<Conversation>,
bookmarkedIds: Set<String>,
): List<Pair<String, List<ConversationDisplayData>>> {
if (conversations.isEmpty()) return emptyList()
return conversations
@ -445,7 +441,7 @@ class ConversationListViewModel(
?: "Unknown"
}
.map { (group, convos) ->
group to convos.map { it.toDisplayData(bookmarkedIds) }
group to convos.map { it.toDisplayData() }
}
}
}

View file

@ -100,7 +100,6 @@ class SettingsViewModelTest {
every { settingsDataStore.showAvatars } returns MutableStateFlow(true)
every { settingsDataStore.showBubbles } returns MutableStateFlow(false)
every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX)
every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet())
// Setup default API responses
coEvery { userRepository.getUser() } returns Result.Success(testUser)

View file

@ -8,6 +8,7 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.model.Conversation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -108,7 +109,10 @@ class ConversationListStateHolder(
scope.launch {
nextCursor = null
_hasMore.value = true
loadPage(cursor = null)
coroutineScope {
launch { loadPage(cursor = null) }
launch { conversationRepository.syncFavoritesFromServer() }
}
_isRefreshing.value = false
}
}

View file

@ -95,7 +95,7 @@ fun DrawerContent(
onSettingsClick = onSettingsClick,
onAgentsClick = onAgentsClick,
onFilesClick = onFilesClick,
onToggleFavorite = viewModel::toggleFavorite,
onToggleFavorite = { data -> viewModel.toggleFavorite(data.conversationId, data.tags) },
onRefresh = viewModel::refreshConversations,
onLoadMore = viewModel::loadMoreConversations,
modifier = modifier,
@ -113,7 +113,7 @@ fun DrawerContent(
onAgentsClick: () -> Unit,
onFilesClick: () -> Unit,
modifier: Modifier = Modifier,
onToggleFavorite: (String) -> Unit = {},
onToggleFavorite: (DrawerConversationDisplayData) -> Unit = {},
onRefresh: () -> Unit = {},
onLoadMore: () -> Unit = {},
) {
@ -257,7 +257,7 @@ fun DrawerContent(
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data) },
)
}
@ -303,7 +303,7 @@ fun DrawerContent(
DrawerConversationItem(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data) },
)
}
}

View file

@ -3,6 +3,7 @@ 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.SAVED_TAG
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
@ -11,14 +12,12 @@ import kotlin.time.Instant
internal data class DrawerDataSnapshot(
val grouped: List<Pair<String, List<Conversation>>>,
val activeId: String?,
val favIds: Set<String>,
val favConvos: List<Conversation>,
val query: String,
)
internal fun Conversation.toDrawerDisplayData(
activeConversationId: String?,
favoriteIds: Set<String>,
): DrawerConversationDisplayData {
val convId = conversationId ?: ""
return DrawerConversationDisplayData(
@ -28,7 +27,8 @@ internal fun Conversation.toDrawerDisplayData(
endpoint = endpoint,
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
isActive = convId == activeConversationId,
isFavorite = convId in favoriteIds,
isFavorite = SAVED_TAG in tags,
tags = tags,
)
}

View file

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

View file

@ -1,72 +0,0 @@
package com.garfiec.librechat.shared.navigation
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.model.Conversation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class FavoritesStateHolder(
private val settingsDataStore: SettingsDataStore,
private val recentConversations: StateFlow<List<Conversation>>,
private val scope: CoroutineScope,
) {
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
val favorites: StateFlow<Set<String>> = _favorites.asStateFlow()
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
val favoriteConversations: StateFlow<List<Conversation>> = _favoriteConversations.asStateFlow()
init {
observeBookmarks()
observeRecentConversations()
}
private fun observeBookmarks() {
scope.launch {
try {
settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds ->
_favorites.value = bookmarkedIds
updateFavoriteConversations()
}
} catch (e: Exception) {
Logger.w(e) { "Failed to observe bookmarks" }
}
}
}
private fun observeRecentConversations() {
scope.launch {
recentConversations.collect {
updateFavoriteConversations()
}
}
}
fun toggleFavorite(conversationId: String) {
scope.launch {
settingsDataStore.toggleBookmark(conversationId)
}
}
private fun updateFavoriteConversations() {
val bookmarkedIds = _favorites.value
val allConversations = recentConversations.value
_favoriteConversations.value = allConversations.filter { conversation ->
conversation.conversationId in bookmarkedIds ||
conversation.tags.any {
it.equals("favorite", ignoreCase = true) ||
it.equals("bookmark", ignoreCase = true)
}
}
}
fun reset() {
_favorites.value = emptySet()
_favoriteConversations.value = emptyList()
}
}

View file

@ -4,12 +4,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.extensions.firstBlocking
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.AuthRepository
import com.garfiec.librechat.core.data.repository.BannerRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.model.Banner
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.network.client.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -24,7 +29,8 @@ class NavHostViewModel(
private val authRepository: AuthRepository,
bannerRepository: BannerRepository,
configRepository: ConfigRepository,
conversationRepository: ConversationRepository,
private val conversationRepository: ConversationRepository,
private val tagRepository: TagRepository,
private val tokenManager: TokenManager,
private val settingsDataStore: SettingsDataStore,
) : ViewModel() {
@ -32,8 +38,11 @@ class NavHostViewModel(
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
private val conversationListStateHolder = ConversationListStateHolder(conversationRepository, viewModelScope)
private val favoritesStateHolder =
FavoritesStateHolder(settingsDataStore, conversationListStateHolder.recentConversations, viewModelScope)
private val favoriteConversations: StateFlow<List<Conversation>> =
conversationListStateHolder.recentConversations
.map { list -> list.filter { SAVED_TAG in it.tags } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@ -73,11 +82,10 @@ class NavHostViewModel(
combine(
conversationListStateHolder.groupedConversations,
conversationListStateHolder.activeConversationId,
favoritesStateHolder.favorites,
favoritesStateHolder.favoriteConversations,
favoriteConversations,
conversationListStateHolder.searchQuery,
) { grouped, activeId, favIds, favConvos, query ->
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query)
) { grouped, activeId, favConvos, query ->
DrawerDataSnapshot(grouped, activeId, favConvos, query)
},
combine(
conversationListStateHolder.isRefreshing,
@ -89,9 +97,9 @@ class NavHostViewModel(
) { data, (refreshing, loadingMore, hasMore) ->
DrawerUiState(
groupedConversations = data.grouped.map { (group, convos) ->
group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }
group to convos.map { it.toDrawerDisplayData(data.activeId) }
},
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) },
favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId) },
searchQuery = data.query,
isRefreshing = refreshing,
isLoadingMore = loadingMore,
@ -106,6 +114,8 @@ class NavHostViewModel(
_isLoggedIn.value = loggedIn
if (loggedIn) {
versionCheckStateHolder.checkBackendVersion()
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
}
} catch (e: Exception) {
Logger.w(e) { "Failed to check auth state on init" }
@ -121,12 +131,17 @@ class NavHostViewModel(
fun onAuthComplete() {
_isLoggedIn.value = true
conversationListStateHolder.refreshConversations()
viewModelScope.launch {
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
}
bannerStateHolder.fetchBanners()
versionCheckStateHolder.checkBackendVersion()
}
fun refreshConversations() {
conversationListStateHolder.refreshConversations()
viewModelScope.launch { tagRepository.refreshTags() }
}
fun setActiveConversation(conversationId: String?) {
@ -137,8 +152,13 @@ class NavHostViewModel(
conversationListStateHolder.onSearchQueryChanged(query)
}
fun toggleFavorite(conversationId: String) {
favoritesStateHolder.toggleFavorite(conversationId)
fun toggleFavorite(conversationId: String, currentTags: List<String>) {
viewModelScope.launch {
val result = tagRepository.toggleFavorite(conversationId, currentTags)
if (result is Result.Error) {
Logger.w(result.exception) { "Failed to toggle favorite for $conversationId" }
}
}
}
fun setTabletSidebarOpen(open: Boolean) {
@ -164,7 +184,7 @@ class NavHostViewModel(
authRepository.logout()
_isLoggedIn.value = false
conversationListStateHolder.reset()
favoritesStateHolder.reset()
tagRepository.clearCache()
_sidebarMode.value = SidebarMode.Conversations
_selectedSettingsCategory.value = null
}