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.
|
This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules.
|
||||||
It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`.
|
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
|
### Server Banners
|
||||||
- `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()`
|
- `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)
|
- 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)
|
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
|
@Test
|
||||||
fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) {
|
fun settingsDataStore_roundTrip_mcpServers() = runTest(testDispatcher) {
|
||||||
val ds = createDataStore("settings-mcp")
|
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.edit
|
||||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
|
@ -69,10 +68,6 @@ class SettingsDataStore(
|
||||||
prefs[KEY_TTS_VOICE_NAME] ?: ""
|
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 ->
|
val tabletSidebarOpen: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||||
prefs[KEY_TABLET_SIDEBAR_OPEN] ?: true
|
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 {
|
companion object {
|
||||||
private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer")
|
private val KEY_LATEX_RENDERER = stringPreferencesKey("latex_renderer")
|
||||||
private val KEY_CHAT_FONT_SIZE = stringPreferencesKey("chat_font_size")
|
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_SPEECH_RATE = floatPreferencesKey("tts_speech_rate")
|
||||||
private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch")
|
private val KEY_TTS_PITCH = floatPreferencesKey("tts_pitch")
|
||||||
private val KEY_TTS_VOICE_NAME = stringPreferencesKey("tts_voice_name")
|
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_OPEN = booleanPreferencesKey("tablet_sidebar_open")
|
||||||
private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled")
|
private val KEY_TABLET_SIDEBAR_GESTURE_ENABLED = booleanPreferencesKey("tablet_sidebar_gesture_enabled")
|
||||||
private val KEY_AUTO_SEND_AFTER_STT = booleanPreferencesKey("auto_send_after_stt")
|
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.Dao
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
|
import com.garfiec.librechat.core.data.db.entity.ConversationEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -26,6 +27,28 @@ interface ConversationDao {
|
||||||
@Upsert
|
@Upsert
|
||||||
suspend fun upsertAll(conversations: List<ConversationEntity>)
|
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")
|
@Query("DELETE FROM conversations WHERE conversationId = :id")
|
||||||
suspend fun deleteById(id: String)
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
|
@ -38,6 +61,9 @@ interface ConversationDao {
|
||||||
@Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id")
|
@Query("UPDATE conversations SET isArchived = :isArchived, updatedAt = :updatedAt WHERE conversationId = :id")
|
||||||
suspend fun updateArchived(id: String, isArchived: Boolean, updatedAt: Long)
|
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")
|
@Query("DELETE FROM conversations")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,25 @@ package com.garfiec.librechat.core.data.db.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
|
import com.garfiec.librechat.core.data.db.entity.ConversationTagEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ConversationTagDao {
|
interface ConversationTagDao {
|
||||||
@Query("SELECT * FROM conversation_tags WHERE user = :userId ORDER BY position ASC")
|
@Query("SELECT * FROM conversation_tags ORDER BY position ASC")
|
||||||
fun getTagsForUser(userId: String): Flow<List<ConversationTagEntity>>
|
fun getAllTags(): Flow<List<ConversationTagEntity>>
|
||||||
|
|
||||||
@Upsert
|
|
||||||
suspend fun upsert(tag: ConversationTagEntity)
|
|
||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
suspend fun upsertAll(tags: List<ConversationTagEntity>)
|
suspend fun upsertAll(tags: List<ConversationTagEntity>)
|
||||||
|
|
||||||
@Query("DELETE FROM conversation_tags WHERE id = :id")
|
@Query("DELETE FROM conversation_tags")
|
||||||
suspend fun deleteById(id: Long)
|
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 importConversation(jsonContent: String): Result<Conversation>
|
||||||
suspend fun deleteAll(): Result<Unit>
|
suspend fun deleteAll(): Result<Unit>
|
||||||
suspend fun saveConversation(conversation: Conversation)
|
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
|
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.Result
|
||||||
import com.garfiec.librechat.core.common.result.safeApiCall
|
import com.garfiec.librechat.core.common.result.safeApiCall
|
||||||
import com.garfiec.librechat.core.data.db.dao.ConversationDao
|
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.toModel
|
||||||
import com.garfiec.librechat.core.data.mapper.toModels
|
import com.garfiec.librechat.core.data.mapper.toModels
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
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.model.request.ForkConversationRequest
|
||||||
import com.garfiec.librechat.core.network.api.ConversationsApi
|
import com.garfiec.librechat.core.network.api.ConversationsApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
class ConversationRepositoryImpl(
|
class ConversationRepositoryImpl(
|
||||||
private val conversationsApi: ConversationsApi,
|
private val conversationsApi: ConversationsApi,
|
||||||
private val conversationDao: ConversationDao,
|
private val conversationDao: ConversationDao,
|
||||||
|
private val json: Json,
|
||||||
) : ConversationRepository {
|
) : ConversationRepository {
|
||||||
|
|
||||||
override fun observeConversations(isArchived: Boolean): Flow<Result<List<Conversation>>> {
|
override fun observeConversations(isArchived: Boolean): Flow<Result<List<Conversation>>> {
|
||||||
|
|
@ -44,19 +49,15 @@ class ConversationRepositoryImpl(
|
||||||
sortBy = sortBy,
|
sortBy = sortBy,
|
||||||
sortDirection = sortDirection,
|
sortDirection = sortDirection,
|
||||||
)
|
)
|
||||||
// Cache conversations locally
|
conversationDao.upsertPreservingTags(response.conversations.map { it.toEntity() })
|
||||||
val entities = response.conversations.map { it.toEntity() }
|
|
||||||
conversationDao.upsertAll(entities)
|
|
||||||
response.nextCursor
|
response.nextCursor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getConversation(id: String): Result<Conversation> {
|
override suspend fun getConversation(id: String): Result<Conversation> {
|
||||||
// Try local cache first
|
|
||||||
val cached = conversationDao.getById(id)
|
val cached = conversationDao.getById(id)
|
||||||
if (cached != null) return Result.Success(cached.toModel())
|
if (cached != null) return Result.Success(cached.toModel())
|
||||||
|
|
||||||
// Fetch from network
|
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val conversation = conversationsApi.getConversation(id)
|
val conversation = conversationsApi.getConversation(id)
|
||||||
conversationDao.upsert(conversation.toEntity())
|
conversationDao.upsert(conversation.toEntity())
|
||||||
|
|
@ -75,7 +76,6 @@ class ConversationRepositoryImpl(
|
||||||
override suspend fun generateTitle(conversationId: String): Result<String> {
|
override suspend fun generateTitle(conversationId: String): Result<String> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val response = conversationsApi.generateTitle(conversationId)
|
val response = conversationsApi.generateTitle(conversationId)
|
||||||
// Update the local cache with the generated title
|
|
||||||
conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds())
|
conversationDao.updateTitle(conversationId, response.title, Clock.System.now().toEpochMilliseconds())
|
||||||
response.title
|
response.title
|
||||||
}
|
}
|
||||||
|
|
@ -153,16 +153,59 @@ class ConversationRepositoryImpl(
|
||||||
override suspend fun saveConversation(conversation: Conversation) {
|
override suspend fun saveConversation(conversation: Conversation) {
|
||||||
val id = conversation.conversationId ?: return
|
val id = conversation.conversationId ?: return
|
||||||
if (id.isBlank()) return
|
if (id.isBlank()) return
|
||||||
conversationDao.upsert(conversation.toEntity())
|
conversationDao.upsertPreservingTags(conversation.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun refreshConversations() {
|
override suspend fun updateConversationTagsLocal(id: String, tags: List<String>) {
|
||||||
try {
|
conversationDao.updateTags(id, encodeTags(tags), Clock.System.now().toEpochMilliseconds())
|
||||||
val response = conversationsApi.getConversations()
|
}
|
||||||
val entities = response.conversations.map { it.toEntity() }
|
|
||||||
conversationDao.upsertAll(entities)
|
// Reconciles SAVED_TAG attachment between the local Room cache and server by
|
||||||
} catch (e: Exception) {
|
// paginating `GET /api/convos?tags=Saved`. Needed because upstream's
|
||||||
Logger.w(e) { "Failed to refresh conversations" }
|
// 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.common.result.Result
|
||||||
import com.garfiec.librechat.core.model.ConversationTag
|
import com.garfiec.librechat.core.model.ConversationTag
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface TagRepository {
|
interface TagRepository {
|
||||||
suspend fun getTags(): Result<List<ConversationTag>>
|
fun observeTags(): Flow<List<ConversationTag>>
|
||||||
suspend fun updateConversationTags(conversationId: String, tags: List<String>): Result<Unit>
|
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.Result
|
||||||
import com.garfiec.librechat.core.common.result.safeApiCall
|
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.ConversationTag
|
||||||
|
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||||
import com.garfiec.librechat.core.network.api.TagsApi
|
import com.garfiec.librechat.core.network.api.TagsApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
class TagRepositoryImpl(
|
class TagRepositoryImpl(
|
||||||
private val tagsApi: TagsApi,
|
private val tagsApi: TagsApi,
|
||||||
|
private val tagDao: ConversationTagDao,
|
||||||
|
private val conversationRepository: ConversationRepository,
|
||||||
) : TagRepository {
|
) : TagRepository {
|
||||||
|
|
||||||
override suspend fun getTags(): Result<List<ConversationTag>> =
|
override fun observeTags(): Flow<List<ConversationTag>> =
|
||||||
safeApiCall { tagsApi.getTags() }
|
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,
|
conversationId: String,
|
||||||
tags: List<String>,
|
tags: List<String>,
|
||||||
): Result<Unit> = safeApiCall {
|
): Result<Unit> = safeApiCall {
|
||||||
tagsApi.updateConversationTags(conversationId, tags)
|
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.common.result.Result
|
||||||
import com.garfiec.librechat.core.model.User
|
import com.garfiec.librechat.core.model.User
|
||||||
import com.garfiec.librechat.core.model.UserFavorite
|
|
||||||
import com.garfiec.librechat.core.model.response.TermsResponse
|
import com.garfiec.librechat.core.model.response.TermsResponse
|
||||||
|
|
||||||
interface UserRepository {
|
interface UserRepository {
|
||||||
|
|
@ -13,6 +12,4 @@ interface UserRepository {
|
||||||
suspend fun resendVerification(email: String): Result<Unit>
|
suspend fun resendVerification(email: String): Result<Unit>
|
||||||
suspend fun getTerms(): Result<TermsResponse>
|
suspend fun getTerms(): Result<TermsResponse>
|
||||||
suspend fun acceptTerms(): Result<Unit>
|
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.Result
|
||||||
import com.garfiec.librechat.core.common.result.safeApiCall
|
import com.garfiec.librechat.core.common.result.safeApiCall
|
||||||
import com.garfiec.librechat.core.model.User
|
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.ResendVerificationRequest
|
||||||
import com.garfiec.librechat.core.model.request.VerifyEmailRequest
|
import com.garfiec.librechat.core.model.request.VerifyEmailRequest
|
||||||
import com.garfiec.librechat.core.model.response.TermsResponse
|
import com.garfiec.librechat.core.model.response.TermsResponse
|
||||||
|
|
@ -40,12 +39,4 @@ class UserRepositoryImpl(
|
||||||
override suspend fun acceptTerms(): Result<Unit> = safeApiCall {
|
override suspend fun acceptTerms(): Result<Unit> = safeApiCall {
|
||||||
userApi.acceptTerms()
|
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 role: String = "USER",
|
||||||
val twoFactorEnabled: Boolean = false,
|
val twoFactorEnabled: Boolean = false,
|
||||||
val termsAccepted: Boolean = false,
|
val termsAccepted: Boolean = false,
|
||||||
val favorites: List<UserFavorite> = emptyList(),
|
|
||||||
val createdAt: String? = null,
|
val createdAt: String? = null,
|
||||||
val updatedAt: 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",
|
role = "ADMIN",
|
||||||
twoFactorEnabled = true,
|
twoFactorEnabled = true,
|
||||||
termsAccepted = 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",
|
createdAt = "2026-01-01T00:00:00Z",
|
||||||
updatedAt = "2026-03-28T12:00:00Z",
|
updatedAt = "2026-03-28T12:00:00Z",
|
||||||
)
|
)
|
||||||
|
|
@ -55,9 +51,6 @@ class UserSerializationTest {
|
||||||
"role": "USER",
|
"role": "USER",
|
||||||
"twoFactorEnabled": false,
|
"twoFactorEnabled": false,
|
||||||
"termsAccepted": true,
|
"termsAccepted": true,
|
||||||
"favorites": [
|
|
||||||
{"agentId": "a1", "endpoint": "agents"}
|
|
||||||
],
|
|
||||||
"extraField": "ignored"
|
"extraField": "ignored"
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
@ -65,20 +58,6 @@ class UserSerializationTest {
|
||||||
assertEquals("user-srv", decoded.id)
|
assertEquals("user-srv", decoded.id)
|
||||||
assertEquals("mongo-srv", decoded.mongoId)
|
assertEquals("mongo-srv", decoded.mongoId)
|
||||||
assertEquals("Server User", decoded.name)
|
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
|
@Test
|
||||||
|
|
@ -89,6 +68,5 @@ class UserSerializationTest {
|
||||||
assertEquals("USER", user.role)
|
assertEquals("USER", user.role)
|
||||||
assertEquals(false, user.twoFactorEnabled)
|
assertEquals(false, user.twoFactorEnabled)
|
||||||
assertEquals(false, user.termsAccepted)
|
assertEquals(false, user.termsAccepted)
|
||||||
assertEquals(emptyList(), user.favorites)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,9 @@ class ConversationsApi constructor(
|
||||||
parameter("cursor", cursor)
|
parameter("cursor", cursor)
|
||||||
parameter("limit", limit)
|
parameter("limit", limit)
|
||||||
parameter("isArchived", isArchived)
|
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) }
|
search?.let { parameter("search", it) }
|
||||||
sortBy?.let { parameter("sortBy", it) }
|
sortBy?.let { parameter("sortBy", it) }
|
||||||
sortDirection?.let { parameter("sortDirection", it) }
|
sortDirection?.let { parameter("sortDirection", it) }
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
package com.garfiec.librechat.core.network.api
|
package com.garfiec.librechat.core.network.api
|
||||||
|
|
||||||
import com.garfiec.librechat.core.model.ConversationTag
|
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.TagUpdateRequest
|
||||||
import com.garfiec.librechat.core.model.request.UpdateTagRequest
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.delete
|
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.post
|
|
||||||
import io.ktor.client.request.put
|
import io.ktor.client.request.put
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
|
|
||||||
class TagsApi constructor(
|
class TagsApi(
|
||||||
private val client: HttpClient,
|
private val client: HttpClient,
|
||||||
) {
|
) {
|
||||||
suspend fun getTags(): List<ConversationTag> =
|
suspend fun getTags(): List<ConversationTag> =
|
||||||
|
|
@ -21,24 +17,6 @@ class TagsApi constructor(
|
||||||
url { path("api/tags") }
|
url { path("api/tags") }
|
||||||
}.body()
|
}.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>) {
|
suspend fun updateConversationTags(conversationId: String, tags: List<String>) {
|
||||||
client.put {
|
client.put {
|
||||||
url { path("api/tags/convo/$conversationId") }
|
url { path("api/tags/convo/$conversationId") }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
package com.garfiec.librechat.core.network.api
|
package com.garfiec.librechat.core.network.api
|
||||||
|
|
||||||
import com.garfiec.librechat.core.model.User
|
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.OtpVerificationRequest
|
||||||
import com.garfiec.librechat.core.model.request.ResendVerificationRequest
|
import com.garfiec.librechat.core.model.request.ResendVerificationRequest
|
||||||
import com.garfiec.librechat.core.model.request.VerifyEmailRequest
|
import com.garfiec.librechat.core.model.request.VerifyEmailRequest
|
||||||
import com.garfiec.librechat.core.model.response.TermsResponse
|
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.UpdatePluginsRequest
|
||||||
import com.garfiec.librechat.core.network.api.dto.UserUpdateRequest
|
import com.garfiec.librechat.core.network.api.dto.UserUpdateRequest
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
|
@ -82,17 +80,6 @@ class UserApi constructor(
|
||||||
url { path("api/user/avatar") }
|
url { path("api/user/avatar") }
|
||||||
}.body()
|
}.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 =
|
suspend fun updatePlugins(plugins: List<String>): User =
|
||||||
client.post {
|
client.post {
|
||||||
url { path("api/user/plugins") }
|
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
|
## Tags
|
||||||
- `TagFilterBar` displays `FilterChip` per tag (multi-select toggle)
|
- `TagFilterBar` displays `FilterChip` per tag (multi-select toggle)
|
||||||
- `TagPicker` for assigning tags to a conversation
|
- `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
|
- `toggleTag()` / `clearTagFilter()` reload conversations with new filter
|
||||||
|
|
||||||
## CRUD Actions (via `ConversationActions` component)
|
## CRUD Actions (via `ConversationActions` component)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package com.garfiec.librechat.feature.conversations.di
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
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.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||||
import com.garfiec.librechat.core.data.repository.MessageRepository
|
import com.garfiec.librechat.core.data.repository.MessageRepository
|
||||||
|
|
@ -27,7 +26,6 @@ class ConversationsModuleVerificationTest {
|
||||||
SearchRepository::class,
|
SearchRepository::class,
|
||||||
ConfigRepository::class,
|
ConfigRepository::class,
|
||||||
ServerDataStore::class,
|
ServerDataStore::class,
|
||||||
SettingsDataStore::class,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package com.garfiec.librechat.feature.conversations.viewmodel
|
package com.garfiec.librechat.feature.conversations.viewmodel
|
||||||
|
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
|
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SearchRepository
|
import com.garfiec.librechat.core.data.repository.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
import com.garfiec.librechat.core.data.repository.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
import com.garfiec.librechat.core.model.ConversationTag
|
import com.garfiec.librechat.core.model.ConversationTag
|
||||||
|
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||||
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
||||||
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
|
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
|
@ -17,7 +17,6 @@ import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
@ -40,7 +39,6 @@ class ConversationListViewModelTest {
|
||||||
private val shareRepository = mockk<ShareRepository>(relaxed = true)
|
private val shareRepository = mockk<ShareRepository>(relaxed = true)
|
||||||
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
|
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
|
||||||
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
|
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
|
||||||
private val settingsDataStore = mockk<SettingsDataStore>(relaxed = true)
|
|
||||||
|
|
||||||
private lateinit var viewModel: ConversationListViewModel
|
private lateinit var viewModel: ConversationListViewModel
|
||||||
|
|
||||||
|
|
@ -61,6 +59,7 @@ class ConversationListViewModelTest {
|
||||||
ConversationTag(tag = "work", count = 3),
|
ConversationTag(tag = "work", count = 3),
|
||||||
ConversationTag(tag = "personal", count = 2),
|
ConversationTag(tag = "personal", count = 2),
|
||||||
ConversationTag(tag = "empty", count = 0),
|
ConversationTag(tag = "empty", count = 0),
|
||||||
|
ConversationTag(tag = SAVED_TAG, count = 5),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
|
|
@ -70,9 +69,10 @@ class ConversationListViewModelTest {
|
||||||
every { conversationRepository.observeConversations(any()) } returns flowOf(
|
every { conversationRepository.observeConversations(any()) } returns flowOf(
|
||||||
Result.Success(testConversations),
|
Result.Success(testConversations),
|
||||||
)
|
)
|
||||||
every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet())
|
|
||||||
coEvery { conversationRepository.loadNextPage(any(), tags = any()) } returns Result.Success(null)
|
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
|
@After
|
||||||
|
|
@ -87,7 +87,6 @@ class ConversationListViewModelTest {
|
||||||
shareRepository = shareRepository,
|
shareRepository = shareRepository,
|
||||||
conversationExporter = conversationExporter,
|
conversationExporter = conversationExporter,
|
||||||
conversationImporter = conversationImporter,
|
conversationImporter = conversationImporter,
|
||||||
settingsDataStore = settingsDataStore,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -101,7 +100,7 @@ class ConversationListViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -433,16 +432,106 @@ class ConversationListViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `updateConversationTags reloads on success`() = runTest {
|
fun `updateConversationTags persists new tag list when conversation was not favorited`() = runTest {
|
||||||
coEvery { tagRepository.updateConversationTags("convo-1", listOf("work")) } returns
|
coEvery { tagRepository.setConversationTags("convo-1", listOf("work")) } returns
|
||||||
Result.Success(Unit)
|
Result.Success(Unit)
|
||||||
|
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
viewModel.updateConversationTags("convo-1", listOf("work"))
|
val convo = Conversation(conversationId = "convo-1", tags = listOf("old"))
|
||||||
|
viewModel.updateConversationTags(convo, listOf("work"))
|
||||||
advanceUntilIdle()
|
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 androidx.compose.runtime.Immutable
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
import com.garfiec.librechat.core.model.EModelEndpoint
|
||||||
|
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class ConversationDisplayData(
|
data class ConversationDisplayData(
|
||||||
|
|
@ -14,11 +15,11 @@ data class ConversationDisplayData(
|
||||||
val isBookmarked: Boolean,
|
val isBookmarked: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Conversation.toDisplayData(bookmarkedIds: Set<String>) = ConversationDisplayData(
|
fun Conversation.toDisplayData() = ConversationDisplayData(
|
||||||
conversationId = conversationId ?: "",
|
conversationId = conversationId ?: "",
|
||||||
title = title ?: "New Chat",
|
title = title ?: "New Chat",
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
model = model,
|
model = model,
|
||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
isBookmarked = 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.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
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.EmptyState
|
||||||
import com.garfiec.librechat.core.ui.components.ErrorBanner
|
import com.garfiec.librechat.core.ui.components.ErrorBanner
|
||||||
import com.garfiec.librechat.core.ui.components.LoadingIndicator
|
import com.garfiec.librechat.core.ui.components.LoadingIndicator
|
||||||
|
|
@ -326,12 +327,10 @@ fun ConversationListScreen(
|
||||||
ConversationActions(
|
ConversationActions(
|
||||||
conversation = selectedConversation!!,
|
conversation = selectedConversation!!,
|
||||||
onDismiss = { selectedConversation = null },
|
onDismiss = { selectedConversation = null },
|
||||||
isBookmarked = selectedConversation?.conversationId?.let {
|
isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()),
|
||||||
viewModel.isBookmarked(it)
|
|
||||||
} ?: false,
|
|
||||||
onBookmarkToggle = {
|
onBookmarkToggle = {
|
||||||
selectedConversation?.conversationId?.let { id ->
|
selectedConversation?.let { convo ->
|
||||||
viewModel.toggleBookmark(id)
|
viewModel.toggleFavorite(convo)
|
||||||
}
|
}
|
||||||
selectedConversation = null
|
selectedConversation = null
|
||||||
},
|
},
|
||||||
|
|
@ -383,10 +382,10 @@ fun ConversationListScreen(
|
||||||
if (showTagPicker && tagPickerConversation != null) {
|
if (showTagPicker && tagPickerConversation != null) {
|
||||||
TagPicker(
|
TagPicker(
|
||||||
availableTags = uiState.tags,
|
availableTags = uiState.tags,
|
||||||
currentTags = tagPickerConversation?.tags ?: emptyList(),
|
currentTags = tagPickerConversation?.tags?.filterNot { it == SAVED_TAG } ?: emptyList(),
|
||||||
onTagsChange = { newTags ->
|
onTagsChange = { newTags ->
|
||||||
tagPickerConversation?.conversationId?.let { id ->
|
tagPickerConversation?.let { convo ->
|
||||||
viewModel.updateConversationTags(id, newTags)
|
viewModel.updateConversationTags(convo, newTags)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismiss = {
|
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.toInstantOrNull
|
||||||
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
|
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
|
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SearchRepository
|
import com.garfiec.librechat.core.data.repository.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
import com.garfiec.librechat.core.data.repository.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
import com.garfiec.librechat.core.model.ConversationTag
|
import com.garfiec.librechat.core.model.ConversationTag
|
||||||
|
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||||
import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData
|
import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData
|
||||||
import com.garfiec.librechat.feature.conversations.components.toDisplayData
|
import com.garfiec.librechat.feature.conversations.components.toDisplayData
|
||||||
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
||||||
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
|
import com.garfiec.librechat.feature.conversations.export.ConversationImporter
|
||||||
import com.garfiec.librechat.feature.conversations.export.ExportFormat
|
import com.garfiec.librechat.feature.conversations.export.ExportFormat
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -59,7 +60,6 @@ class ConversationListViewModel(
|
||||||
private val shareRepository: ShareRepository,
|
private val shareRepository: ShareRepository,
|
||||||
private val conversationExporter: ConversationExporter,
|
private val conversationExporter: ConversationExporter,
|
||||||
private val conversationImporter: ConversationImporter,
|
private val conversationImporter: ConversationImporter,
|
||||||
private val settingsDataStore: SettingsDataStore,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ConversationListUiState())
|
private val _uiState = MutableStateFlow(ConversationListUiState())
|
||||||
|
|
@ -70,14 +70,12 @@ class ConversationListViewModel(
|
||||||
|
|
||||||
/** Raw conversations kept for internal lookups (action sheets, etc.). */
|
/** Raw conversations kept for internal lookups (action sheets, etc.). */
|
||||||
private var conversations: List<Conversation> = emptyList()
|
private var conversations: List<Conversation> = emptyList()
|
||||||
private var bookmarkedIds: Set<String> = emptySet()
|
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadConversations()
|
loadConversations()
|
||||||
observeConversations()
|
observeConversations()
|
||||||
observeBookmarks()
|
observeTags()
|
||||||
loadTags()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeConversations() {
|
private fun observeConversations() {
|
||||||
|
|
@ -104,17 +102,18 @@ class ConversationListViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeBookmarks() {
|
private fun observeTags() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsDataStore.bookmarkedConversationIds.collect { ids ->
|
tagRepository.observeTags().collect { tags ->
|
||||||
bookmarkedIds = ids
|
_uiState.value = _uiState.value.copy(
|
||||||
recomputeGroupedConversations()
|
tags = tags.filter { it.count > 0 && it.tag != SAVED_TAG },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun recomputeGroupedConversations() {
|
private fun recomputeGroupedConversations() {
|
||||||
val grouped = groupConversationsByDate(conversations, bookmarkedIds)
|
val grouped = groupConversationsByDate(conversations)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
groupedConversations = grouped,
|
groupedConversations = grouped,
|
||||||
conversationCount = conversations.size,
|
conversationCount = conversations.size,
|
||||||
|
|
@ -124,12 +123,20 @@ class ConversationListViewModel(
|
||||||
fun getConversation(conversationId: String): Conversation? =
|
fun getConversation(conversationId: String): Conversation? =
|
||||||
conversations.firstOrNull { it.conversationId == conversationId }
|
conversations.firstOrNull { it.conversationId == conversationId }
|
||||||
|
|
||||||
fun isBookmarked(conversationId: String): Boolean =
|
fun toggleFavorite(conversation: Conversation) {
|
||||||
conversationId in bookmarkedIds
|
val id = conversation.conversationId ?: return
|
||||||
|
|
||||||
fun toggleBookmark(conversationId: String) {
|
|
||||||
viewModelScope.launch {
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||||
val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null }
|
val activeTags = _uiState.value.selectedTags.toList().ifEmpty { null }
|
||||||
when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) {
|
coroutineScope {
|
||||||
is Result.Success -> {
|
launch {
|
||||||
_uiState.value = _uiState.value.copy(
|
when (val result = conversationRepository.loadNextPage(null, tags = activeTags)) {
|
||||||
isRefreshing = false,
|
is Result.Success -> {
|
||||||
nextCursor = result.data,
|
_uiState.value = _uiState.value.copy(
|
||||||
hasMore = result.data != null,
|
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 -> {
|
launch { tagRepository.refreshTags() }
|
||||||
_uiState.value = _uiState.value.copy(
|
launch { conversationRepository.syncFavoritesFromServer() }
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
|
_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 {
|
viewModelScope.launch {
|
||||||
when (tagRepository.updateConversationTags(conversationId, tags)) {
|
when (tagRepository.setConversationTags(id, finalTags)) {
|
||||||
is Result.Success -> {
|
is Result.Success -> {
|
||||||
loadTags()
|
// Room Flow already emits the updated conversation via
|
||||||
refresh()
|
// observeConversations; we only need fresh tag counts.
|
||||||
|
tagRepository.refreshTags()
|
||||||
}
|
}
|
||||||
is Result.Error -> {
|
is Result.Error -> {
|
||||||
_events.emit(ConversationListEvent.ShowError("Failed to update tags"))
|
_events.emit(ConversationListEvent.ShowError("Failed to update tags"))
|
||||||
|
|
@ -434,7 +431,6 @@ class ConversationListViewModel(
|
||||||
companion object {
|
companion object {
|
||||||
private fun groupConversationsByDate(
|
private fun groupConversationsByDate(
|
||||||
conversations: List<Conversation>,
|
conversations: List<Conversation>,
|
||||||
bookmarkedIds: Set<String>,
|
|
||||||
): List<Pair<String, List<ConversationDisplayData>>> {
|
): List<Pair<String, List<ConversationDisplayData>>> {
|
||||||
if (conversations.isEmpty()) return emptyList()
|
if (conversations.isEmpty()) return emptyList()
|
||||||
return conversations
|
return conversations
|
||||||
|
|
@ -445,7 +441,7 @@ class ConversationListViewModel(
|
||||||
?: "Unknown"
|
?: "Unknown"
|
||||||
}
|
}
|
||||||
.map { (group, convos) ->
|
.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.showAvatars } returns MutableStateFlow(true)
|
||||||
every { settingsDataStore.showBubbles } returns MutableStateFlow(false)
|
every { settingsDataStore.showBubbles } returns MutableStateFlow(false)
|
||||||
every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX)
|
every { settingsDataStore.latexRenderer } returns MutableStateFlow(LatexRenderer.KATEX)
|
||||||
every { settingsDataStore.bookmarkedConversationIds } returns MutableStateFlow(emptySet())
|
|
||||||
|
|
||||||
// Setup default API responses
|
// Setup default API responses
|
||||||
coEvery { userRepository.getUser() } returns Result.Success(testUser)
|
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 com.garfiec.librechat.core.model.Conversation
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -108,7 +109,10 @@ class ConversationListStateHolder(
|
||||||
scope.launch {
|
scope.launch {
|
||||||
nextCursor = null
|
nextCursor = null
|
||||||
_hasMore.value = true
|
_hasMore.value = true
|
||||||
loadPage(cursor = null)
|
coroutineScope {
|
||||||
|
launch { loadPage(cursor = null) }
|
||||||
|
launch { conversationRepository.syncFavoritesFromServer() }
|
||||||
|
}
|
||||||
_isRefreshing.value = false
|
_isRefreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ fun DrawerContent(
|
||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
onAgentsClick = onAgentsClick,
|
onAgentsClick = onAgentsClick,
|
||||||
onFilesClick = onFilesClick,
|
onFilesClick = onFilesClick,
|
||||||
onToggleFavorite = viewModel::toggleFavorite,
|
onToggleFavorite = { data -> viewModel.toggleFavorite(data.conversationId, data.tags) },
|
||||||
onRefresh = viewModel::refreshConversations,
|
onRefresh = viewModel::refreshConversations,
|
||||||
onLoadMore = viewModel::loadMoreConversations,
|
onLoadMore = viewModel::loadMoreConversations,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -113,7 +113,7 @@ fun DrawerContent(
|
||||||
onAgentsClick: () -> Unit,
|
onAgentsClick: () -> Unit,
|
||||||
onFilesClick: () -> Unit,
|
onFilesClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onToggleFavorite: (String) -> Unit = {},
|
onToggleFavorite: (DrawerConversationDisplayData) -> Unit = {},
|
||||||
onRefresh: () -> Unit = {},
|
onRefresh: () -> Unit = {},
|
||||||
onLoadMore: () -> Unit = {},
|
onLoadMore: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
@ -257,7 +257,7 @@ fun DrawerContent(
|
||||||
DrawerConversationItem(
|
DrawerConversationItem(
|
||||||
data = data,
|
data = data,
|
||||||
onClick = { onConversationClick(data.conversationId) },
|
onClick = { onConversationClick(data.conversationId) },
|
||||||
onToggleFavorite = { onToggleFavorite(data.conversationId) },
|
onToggleFavorite = { onToggleFavorite(data) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,7 +303,7 @@ fun DrawerContent(
|
||||||
DrawerConversationItem(
|
DrawerConversationItem(
|
||||||
data = data,
|
data = data,
|
||||||
onClick = { onConversationClick(data.conversationId) },
|
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.formatMonthAbbrev
|
||||||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
|
import com.garfiec.librechat.core.model.SAVED_TAG
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
@ -11,14 +12,12 @@ import kotlin.time.Instant
|
||||||
internal data class DrawerDataSnapshot(
|
internal data class DrawerDataSnapshot(
|
||||||
val grouped: List<Pair<String, List<Conversation>>>,
|
val grouped: List<Pair<String, List<Conversation>>>,
|
||||||
val activeId: String?,
|
val activeId: String?,
|
||||||
val favIds: Set<String>,
|
|
||||||
val favConvos: List<Conversation>,
|
val favConvos: List<Conversation>,
|
||||||
val query: String,
|
val query: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun Conversation.toDrawerDisplayData(
|
internal fun Conversation.toDrawerDisplayData(
|
||||||
activeConversationId: String?,
|
activeConversationId: String?,
|
||||||
favoriteIds: Set<String>,
|
|
||||||
): DrawerConversationDisplayData {
|
): DrawerConversationDisplayData {
|
||||||
val convId = conversationId ?: ""
|
val convId = conversationId ?: ""
|
||||||
return DrawerConversationDisplayData(
|
return DrawerConversationDisplayData(
|
||||||
|
|
@ -28,7 +27,8 @@ internal fun Conversation.toDrawerDisplayData(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
|
relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "",
|
||||||
isActive = convId == activeConversationId,
|
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 relativeTime: String,
|
||||||
val isActive: Boolean,
|
val isActive: Boolean,
|
||||||
val isFavorite: 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 androidx.lifecycle.viewModelScope
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.garfiec.librechat.core.common.extensions.firstBlocking
|
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.datastore.SettingsDataStore
|
||||||
import com.garfiec.librechat.core.data.repository.AuthRepository
|
import com.garfiec.librechat.core.data.repository.AuthRepository
|
||||||
import com.garfiec.librechat.core.data.repository.BannerRepository
|
import com.garfiec.librechat.core.data.repository.BannerRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||||
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
import com.garfiec.librechat.core.model.Banner
|
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 com.garfiec.librechat.core.network.client.TokenManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
@ -17,6 +21,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -24,7 +29,8 @@ class NavHostViewModel(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
bannerRepository: BannerRepository,
|
bannerRepository: BannerRepository,
|
||||||
configRepository: ConfigRepository,
|
configRepository: ConfigRepository,
|
||||||
conversationRepository: ConversationRepository,
|
private val conversationRepository: ConversationRepository,
|
||||||
|
private val tagRepository: TagRepository,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val settingsDataStore: SettingsDataStore,
|
private val settingsDataStore: SettingsDataStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
@ -32,8 +38,11 @@ class NavHostViewModel(
|
||||||
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
|
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
|
||||||
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
|
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
|
||||||
private val conversationListStateHolder = ConversationListStateHolder(conversationRepository, 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)
|
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
|
||||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||||
|
|
@ -73,11 +82,10 @@ class NavHostViewModel(
|
||||||
combine(
|
combine(
|
||||||
conversationListStateHolder.groupedConversations,
|
conversationListStateHolder.groupedConversations,
|
||||||
conversationListStateHolder.activeConversationId,
|
conversationListStateHolder.activeConversationId,
|
||||||
favoritesStateHolder.favorites,
|
favoriteConversations,
|
||||||
favoritesStateHolder.favoriteConversations,
|
|
||||||
conversationListStateHolder.searchQuery,
|
conversationListStateHolder.searchQuery,
|
||||||
) { grouped, activeId, favIds, favConvos, query ->
|
) { grouped, activeId, favConvos, query ->
|
||||||
DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query)
|
DrawerDataSnapshot(grouped, activeId, favConvos, query)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
conversationListStateHolder.isRefreshing,
|
conversationListStateHolder.isRefreshing,
|
||||||
|
|
@ -89,9 +97,9 @@ class NavHostViewModel(
|
||||||
) { data, (refreshing, loadingMore, hasMore) ->
|
) { data, (refreshing, loadingMore, hasMore) ->
|
||||||
DrawerUiState(
|
DrawerUiState(
|
||||||
groupedConversations = data.grouped.map { (group, convos) ->
|
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,
|
searchQuery = data.query,
|
||||||
isRefreshing = refreshing,
|
isRefreshing = refreshing,
|
||||||
isLoadingMore = loadingMore,
|
isLoadingMore = loadingMore,
|
||||||
|
|
@ -106,6 +114,8 @@ class NavHostViewModel(
|
||||||
_isLoggedIn.value = loggedIn
|
_isLoggedIn.value = loggedIn
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
versionCheckStateHolder.checkBackendVersion()
|
versionCheckStateHolder.checkBackendVersion()
|
||||||
|
launch { tagRepository.refreshTags() }
|
||||||
|
launch { conversationRepository.syncFavoritesFromServer() }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w(e) { "Failed to check auth state on init" }
|
Logger.w(e) { "Failed to check auth state on init" }
|
||||||
|
|
@ -121,12 +131,17 @@ class NavHostViewModel(
|
||||||
fun onAuthComplete() {
|
fun onAuthComplete() {
|
||||||
_isLoggedIn.value = true
|
_isLoggedIn.value = true
|
||||||
conversationListStateHolder.refreshConversations()
|
conversationListStateHolder.refreshConversations()
|
||||||
|
viewModelScope.launch {
|
||||||
|
launch { tagRepository.refreshTags() }
|
||||||
|
launch { conversationRepository.syncFavoritesFromServer() }
|
||||||
|
}
|
||||||
bannerStateHolder.fetchBanners()
|
bannerStateHolder.fetchBanners()
|
||||||
versionCheckStateHolder.checkBackendVersion()
|
versionCheckStateHolder.checkBackendVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshConversations() {
|
fun refreshConversations() {
|
||||||
conversationListStateHolder.refreshConversations()
|
conversationListStateHolder.refreshConversations()
|
||||||
|
viewModelScope.launch { tagRepository.refreshTags() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setActiveConversation(conversationId: String?) {
|
fun setActiveConversation(conversationId: String?) {
|
||||||
|
|
@ -137,8 +152,13 @@ class NavHostViewModel(
|
||||||
conversationListStateHolder.onSearchQueryChanged(query)
|
conversationListStateHolder.onSearchQueryChanged(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleFavorite(conversationId: String) {
|
fun toggleFavorite(conversationId: String, currentTags: List<String>) {
|
||||||
favoritesStateHolder.toggleFavorite(conversationId)
|
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) {
|
fun setTabletSidebarOpen(open: Boolean) {
|
||||||
|
|
@ -164,7 +184,7 @@ class NavHostViewModel(
|
||||||
authRepository.logout()
|
authRepository.logout()
|
||||||
_isLoggedIn.value = false
|
_isLoggedIn.value = false
|
||||||
conversationListStateHolder.reset()
|
conversationListStateHolder.reset()
|
||||||
favoritesStateHolder.reset()
|
tagRepository.clearCache()
|
||||||
_sidebarMode.value = SidebarMode.Conversations
|
_sidebarMode.value = SidebarMode.Conversations
|
||||||
_selectedSettingsCategory.value = null
|
_selectedSettingsCategory.value = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue