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:
commit
9e06c43096
35 changed files with 782 additions and 341 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.garfiec.librechat.core.model
|
||||
|
||||
const val SAVED_TAG = "Saved"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ data class DrawerConversationDisplayData(
|
|||
val relativeTime: String,
|
||||
val isActive: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
val tags: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue