Merge pull request #52 from garfiec/worktree-issue-49-investigation-resume

fix(permissions): gate mobile surfaces on server role permissions (#49)
This commit is contained in:
Garfie Chiu 2026-04-24 23:00:53 -05:00 committed by GitHub
commit 89a0dd7bd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1714 additions and 193 deletions

View file

@ -25,11 +25,15 @@ import com.garfiec.librechat.core.data.repository.MemoryRepository
import com.garfiec.librechat.core.data.repository.MessageRepository
import com.garfiec.librechat.core.data.repository.PresetRepository
import com.garfiec.librechat.core.data.repository.PromptRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.SpeechRepository
import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.data.util.SessionTask
import com.garfiec.librechat.core.data.util.SessionTaskRunner
import com.garfiec.librechat.core.network.api.AgentsApi
import com.garfiec.librechat.core.network.api.ApiKeysApi
import com.garfiec.librechat.core.network.api.AuthApi
@ -155,11 +159,15 @@ class KoinGraphVerificationTest {
MessageRepository::class,
PresetRepository::class,
PromptRepository::class,
RoleRepository::class,
SearchRepository::class,
ShareRepository::class,
SpeechRepository::class,
TagRepository::class,
UserRepository::class,
PermissionGate::class,
SessionTask::class,
SessionTaskRunner::class,
// feature:auth platform provides
OAuthLauncher::class,
// feature:files platform provides

View file

@ -9,10 +9,12 @@ import com.garfiec.librechat.core.common.di.KoinQualifiers
import com.garfiec.librechat.core.data.datastore.TokenDataStore
import com.garfiec.librechat.core.data.db.LibreChatDatabase
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SessionCacheCleaner
import com.garfiec.librechat.core.network.client.SecureTokenStorage
import com.garfiec.librechat.core.network.client.TokenManager
import io.ktor.client.HttpClient
import kotlinx.coroutines.CoroutineScope
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.binds
@ -41,5 +43,11 @@ actual val dataPlatformModule: Module = module {
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
// --- Session Cache Cleaner ---
single<SessionCacheCleaner> { CommonSessionCacheCleaner(androidContext().cacheDir.absolutePath) }
single<SessionCacheCleaner> {
CommonSessionCacheCleaner(
cacheRoot = androidContext().cacheDir.absolutePath,
roleRepository = get<RoleRepository>(),
applicationScope = get<CoroutineScope>(KoinQualifiers.ApplicationScope),
)
}
}

View file

@ -20,6 +20,7 @@ import com.garfiec.librechat.core.network.api.MemoriesApi
import com.garfiec.librechat.core.network.api.MessagesApi
import com.garfiec.librechat.core.network.api.PresetsApi
import com.garfiec.librechat.core.network.api.PromptsApi
import com.garfiec.librechat.core.network.api.RolesApi
import com.garfiec.librechat.core.network.api.SearchApi
import com.garfiec.librechat.core.network.api.ShareApi
import com.garfiec.librechat.core.network.api.SpeechApi
@ -28,6 +29,7 @@ import com.garfiec.librechat.core.network.api.UserApi
import com.garfiec.librechat.core.network.sse.SseClient
import io.ktor.client.HttpClient
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json
import org.junit.Test
import org.koin.test.verify.verify
@ -43,6 +45,7 @@ class DataModuleVerificationTest {
HttpClient::class,
Json::class,
CoroutineDispatcher::class,
CoroutineScope::class,
ConnectivityObserver::class,
SseClient::class,
AuthApi::class,
@ -55,6 +58,7 @@ class DataModuleVerificationTest {
AgentsApi::class,
PresetsApi::class,
PromptsApi::class,
RolesApi::class,
TagsApi::class,
ShareApi::class,
ConfigApi::class,

View file

@ -0,0 +1,186 @@
package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.RoleCacheDataStore
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import com.garfiec.librechat.core.network.api.RolesApi
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class RoleRepositoryImplTest {
private val rolesApi = mockk<RolesApi>(relaxed = true)
private val userRepository = mockk<UserRepository>(relaxed = true)
private val cacheDataStore = mockk<RoleCacheDataStore>(relaxed = true)
private val adminRole = UserRolePermissions(
name = "ADMIN",
permissions = mapOf(
"PROMPTS" to mapOf("USE" to false),
"BOOKMARKS" to mapOf("USE" to true),
),
)
private val userRole = UserRolePermissions(
name = "USER",
permissions = mapOf("PROMPTS" to mapOf("USE" to true)),
)
private fun TestScope.newRepo(): RoleRepositoryImpl {
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
return RoleRepositoryImpl(
rolesApi = rolesApi,
userRepository = userRepository,
cacheDataStore = cacheDataStore,
applicationScope = scope,
)
}
@Test
fun `fetchUserRole calls rolesApi with user's role name`() = runTest {
coEvery { cacheDataStore.load() } returns null
coEvery { cacheDataStore.save(any()) } just Runs
coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN"))
coEvery { rolesApi.getRole("ADMIN") } returns adminRole
val repo = newRepo()
advanceUntilIdle()
val result = repo.fetchUserRole()
assertThat(result).isInstanceOf(Result.Success::class.java)
assertThat((result as Result.Success).data).isEqualTo(adminRole)
coVerify(exactly = 1) { rolesApi.getRole("ADMIN") }
}
@Test
fun `fetchUserRole uses role name not a hardcoded string`() = runTest {
coEvery { cacheDataStore.load() } returns null
coEvery { cacheDataStore.save(any()) } just Runs
coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "USER"))
coEvery { rolesApi.getRole("USER") } returns userRole
val repo = newRepo()
advanceUntilIdle()
repo.fetchUserRole()
coVerify(exactly = 1) { rolesApi.getRole("USER") }
}
@Test
fun `fetchUserRole populates StateFlow and saves to cache on success`() = runTest {
coEvery { cacheDataStore.load() } returns null
coEvery { cacheDataStore.save(adminRole) } just Runs
coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN"))
coEvery { rolesApi.getRole("ADMIN") } returns adminRole
val repo = newRepo()
advanceUntilIdle()
repo.fetchUserRole()
assertThat(repo.userPermissions.value).isEqualTo(adminRole)
coVerify(exactly = 1) { cacheDataStore.save(adminRole) }
}
@Test
fun `fetchUserRole returns cached value on network failure when cache populated`() = runTest {
// Prime the cache so StateFlow has a value before the failing fetch.
coEvery { cacheDataStore.load() } returns adminRole
coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN"))
coEvery { rolesApi.getRole("ADMIN") } throws IOException("offline")
val repo = newRepo()
advanceUntilIdle() // let the cache-prime launch complete
val result = repo.fetchUserRole()
assertThat(result).isInstanceOf(Result.Success::class.java)
assertThat((result as Result.Success).data).isEqualTo(adminRole)
assertThat(repo.userPermissions.value).isEqualTo(adminRole)
}
@Test
fun `fetchUserRole returns Error when network fails and no cached value`() = runTest {
coEvery { cacheDataStore.load() } returns null
coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "ADMIN"))
coEvery { rolesApi.getRole("ADMIN") } throws IOException("offline")
val repo = newRepo()
advanceUntilIdle()
val result = repo.fetchUserRole()
assertThat(result).isInstanceOf(Result.Error::class.java)
assertThat(repo.userPermissions.value).isNull()
}
@Test
fun `fetchUserRole returns Error when userRepository fails`() = runTest {
coEvery { cacheDataStore.load() } returns null
coEvery { userRepository.getUser() } returns Result.Error(RuntimeException("no user"), "no user")
val repo = newRepo()
advanceUntilIdle()
val result = repo.fetchUserRole()
assertThat(result).isInstanceOf(Result.Error::class.java)
coVerify(exactly = 0) { rolesApi.getRole(any()) }
}
@Test
fun `clear wipes in-memory StateFlow and DataStore`() = runTest {
coEvery { cacheDataStore.load() } returns adminRole
coEvery { cacheDataStore.clear() } just Runs
val repo = newRepo()
advanceUntilIdle()
assertThat(repo.userPermissions.value).isEqualTo(adminRole)
repo.clear()
assertThat(repo.userPermissions.value).isNull()
coVerify(exactly = 1) { cacheDataStore.clear() }
}
@Test
fun `custom role 404-style error keeps prior cached value intact`() = runTest {
// Cache holds the pre-change custom role permissions.
val customRole = UserRolePermissions(
name = "CUSTOM_ROLE",
permissions = mapOf(PermissionType.AGENTS.serverKey to mapOf(Permission.USE.serverKey to true)),
)
coEvery { cacheDataStore.load() } returns customRole
coEvery { userRepository.getUser() } returns Result.Success(User(email = "a@b.c", role = "CUSTOM_ROLE"))
coEvery { rolesApi.getRole("CUSTOM_ROLE") } throws IOException("404 Not Found")
val repo = newRepo()
advanceUntilIdle()
val result = repo.fetchUserRole()
// Repository falls back to cached value, StateFlow not nulled out.
assertThat(result).isInstanceOf(Result.Success::class.java)
assertThat(repo.userPermissions.value).isEqualTo(customRole)
}
@Test
fun `cache prime populates StateFlow from DataStore at construction`() = runTest {
coEvery { cacheDataStore.load() } returns adminRole
val repo = newRepo()
advanceUntilIdle() // StandardTestDispatcher needs this to run the init-block launch
assertThat(repo.userPermissions.value).isEqualTo(adminRole)
}
}

View file

@ -0,0 +1,51 @@
package com.garfiec.librechat.core.data.util
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PermissionGateTest {
private fun fakeRepository(flow: StateFlow<UserRolePermissions?>): RoleRepository =
object : RoleRepository {
override val userPermissions: StateFlow<UserRolePermissions?> = flow
override suspend fun fetchUserRole() = error("not needed in gate tests")
override suspend fun clear() { /* no-op */ }
}
private val adminRole = UserRolePermissions(
name = "ADMIN",
permissions = mapOf(
"PROMPTS" to mapOf("USE" to false, "CREATE" to true),
"AGENTS" to mapOf("USE" to false),
),
)
@Test
fun `awaitRole returns null when role never loads within 5s`() = runTest {
val flow = MutableStateFlow<UserRolePermissions?>(null)
val gate = PermissionGate(fakeRepository(flow))
val deferred = async { gate.awaitRole() }
advanceTimeBy(PermissionGate.ROLE_LOAD_TIMEOUT_MS + 100)
assertThat(deferred.await()).isNull()
}
@Test
fun `awaitRole returns loaded role immediately after emission`() = runTest {
val flow = MutableStateFlow<UserRolePermissions?>(adminRole)
val gate = PermissionGate(fakeRepository(flow))
val role = gate.awaitRole()
assertThat(role).isEqualTo(adminRole)
}
}

View file

@ -0,0 +1,48 @@
package com.garfiec.librechat.core.data.datastore
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
class RoleCacheDataStore(
private val dataStore: DataStore<Preferences>,
private val json: Json,
) {
suspend fun save(role: UserRolePermissions) {
try {
val serialized = json.encodeToString(UserRolePermissions.serializer(), role)
dataStore.edit { prefs -> prefs[KEY_ROLE] = serialized }
} catch (e: Exception) {
Logger.w(e) { "Failed to cache user role permissions" }
}
}
suspend fun load(): UserRolePermissions? {
return try {
val prefs = dataStore.data.first()
val serialized = prefs[KEY_ROLE] ?: return null
json.decodeFromString(UserRolePermissions.serializer(), serialized)
} catch (e: Exception) {
Logger.w(e) { "Failed to load cached user role permissions" }
null
}
}
suspend fun clear() {
try {
dataStore.edit { prefs -> prefs.remove(KEY_ROLE) }
} catch (e: Exception) {
Logger.w(e) { "Failed to clear cached user role permissions" }
}
}
private companion object {
val KEY_ROLE = stringPreferencesKey("cached_user_role_permissions")
}
}

View file

@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.di
import com.garfiec.librechat.core.common.di.KoinQualifiers
import com.garfiec.librechat.core.data.datastore.ConfigCacheDataStore
import com.garfiec.librechat.core.data.datastore.RoleCacheDataStore
import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.datastore.ThemeDataStore
@ -38,6 +39,8 @@ import com.garfiec.librechat.core.data.repository.PresetRepository
import com.garfiec.librechat.core.data.repository.PresetRepositoryImpl
import com.garfiec.librechat.core.data.repository.PromptRepository
import com.garfiec.librechat.core.data.repository.PromptRepositoryImpl
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.RoleRepositoryImpl
import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.SearchRepositoryImpl
import com.garfiec.librechat.core.data.repository.ShareRepository
@ -48,7 +51,14 @@ import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.data.repository.TagRepositoryImpl
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.repository.UserRepositoryImpl
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.data.util.RefreshTagsSessionTask
import com.garfiec.librechat.core.data.util.RoleFetchSessionTask
import com.garfiec.librechat.core.data.util.SessionTask
import com.garfiec.librechat.core.data.util.SessionTaskRunner
import com.garfiec.librechat.core.data.util.SyncFavoritesSessionTask
import com.garfiec.librechat.core.network.client.ServerUrlProvider
import kotlinx.coroutines.CoroutineScope
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
@ -81,6 +91,7 @@ val dataModule = module {
} bind ServerUrlProvider::class
singleOf(::ThemeDataStore)
singleOf(::ConfigCacheDataStore)
singleOf(::RoleCacheDataStore)
singleOf(::SettingsDataStore)
// --- Repositories (special wiring) ---
@ -91,6 +102,33 @@ val dataModule = module {
userApi = get(),
tokenManager = get(),
sessionCacheCleaner = get(),
sessionTaskRunner = get(),
)
}
single<RoleRepository> {
RoleRepositoryImpl(
rolesApi = get(),
userRepository = get(),
cacheDataStore = get(),
applicationScope = get<CoroutineScope>(KoinQualifiers.ApplicationScope),
)
}
singleOf(::PermissionGate)
// --- Session tasks ---
// Work that runs whenever the app transitions into an authenticated session.
// Fires from two places only: AuthRepositoryImpl's login/OAuth/2FA success paths
// and NavHostViewModel.init when a session is restored at cold-start.
// Add new tasks here.
singleOf(::RoleFetchSessionTask) bind SessionTask::class
singleOf(::RefreshTagsSessionTask) bind SessionTask::class
singleOf(::SyncFavoritesSessionTask) bind SessionTask::class
single {
SessionTaskRunner(
tasks = getAll<SessionTask>(),
applicationScope = get<CoroutineScope>(KoinQualifiers.ApplicationScope),
)
}

View file

@ -2,6 +2,7 @@ package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.common.result.safeApiCall
import com.garfiec.librechat.core.data.util.SessionTaskRunner
import com.garfiec.librechat.core.model.LoginOutcome
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.response.TwoFactorSetupResponse
@ -14,8 +15,18 @@ class AuthRepositoryImpl(
private val userApi: UserApi,
private val tokenManager: TokenManager,
private val sessionCacheCleaner: SessionCacheCleaner,
private val sessionTaskRunner: SessionTaskRunner,
) : AuthRepository {
/**
* Fires session tasks when [this] represents an authenticated success. Callers pass
* [isAuthenticated] to discriminate: for OAuth/2FA any `Result.Success` is a real
* sign-in, but `login()`'s `LoginOutcome.TwoFactorRequired` is a pending state (user
* isn't authenticated yet) so tasks defer to `verifyTwoFactor()`.
*/
private fun <T> Result<T>.fireSessionTasksIfLoggedIn(isAuthenticated: (T) -> Boolean = { true }): Result<T> =
also { if (it is Result.Success && isAuthenticated(it.data)) sessionTaskRunner.runAll() }
override suspend fun login(email: String, password: String): Result<LoginOutcome> {
return safeApiCall {
val result = authApi.login(email, password)
@ -34,7 +45,7 @@ class AuthRepositoryImpl(
?: throw IllegalStateException("Login succeeded but user was null in response"),
)
}
}
}.fireSessionTasksIfLoggedIn { outcome -> outcome is LoginOutcome.Success }
}
override suspend fun loginWithOAuthToken(refreshToken: String): Result<User> {
@ -50,7 +61,7 @@ class AuthRepositoryImpl(
)
// Fetch the user profile
userApi.getUser()
}
}.fireSessionTasksIfLoggedIn()
}
override suspend fun verifyTwoFactor(tempToken: String, code: String): Result<User> {
@ -61,7 +72,7 @@ class AuthRepositoryImpl(
refreshToken = result.refreshToken ?: "",
)
result.response.user ?: throw IllegalStateException("No user in 2FA response")
}
}.fireSessionTasksIfLoggedIn()
}
override suspend fun register(

View file

@ -1,6 +1,8 @@
package com.garfiec.librechat.core.data.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Subdirectories under the platform cache root that are cleared on logout.
@ -24,10 +26,13 @@ internal expect fun deleteDirectoryRecursively(path: String)
/**
* Common implementation of [SessionCacheCleaner] that iterates [CACHE_SUBDIRECTORIES]
* and deletes each from the provided [cacheRoot] directory.
* and deletes each from the provided [cacheRoot] directory. Also clears the cached
* role permissions so permission gates don't leak across user sessions.
*/
class CommonSessionCacheCleaner(
private val cacheRoot: String,
private val roleRepository: RoleRepository,
private val applicationScope: CoroutineScope,
) : SessionCacheCleaner {
override fun clearSessionCaches() {
try {
@ -37,5 +42,8 @@ class CommonSessionCacheCleaner(
} catch (e: Exception) {
Logger.w(e) { "Failed to clear session caches on logout" }
}
// roleRepository.clear() is a suspend function; dispatch it into the app scope so
// logout remains synchronous and can't be stalled by DataStore write latency.
applicationScope.launch { roleRepository.clear() }
}
}

View file

@ -0,0 +1,23 @@
package com.garfiec.librechat.core.data.repository
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import kotlinx.coroutines.flow.StateFlow
interface RoleRepository {
/**
* Latest permissions for the authenticated user's role, or null if not yet loaded.
* Emits null briefly at startup before the DataStore cache prime completes.
*/
val userPermissions: StateFlow<UserRolePermissions?>
/**
* Fetches `/api/roles/{currentUser.role}`, updates the StateFlow + DataStore on success.
* On network failure with a cached value present, returns Result.Success(cached) so
* gates can keep working offline. On network failure with no cache, returns Result.Error.
*/
suspend fun fetchUserRole(): Result<UserRolePermissions>
/** Wipes in-memory StateFlow and DataStore cache. Called on logout. */
suspend fun clear()
}

View file

@ -0,0 +1,61 @@
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.data.datastore.RoleCacheDataStore
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import com.garfiec.librechat.core.network.api.RolesApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class RoleRepositoryImpl(
private val rolesApi: RolesApi,
private val userRepository: UserRepository,
private val cacheDataStore: RoleCacheDataStore,
applicationScope: CoroutineScope,
) : RoleRepository {
private val _userPermissions = MutableStateFlow<UserRolePermissions?>(null)
override val userPermissions: StateFlow<UserRolePermissions?> = _userPermissions.asStateFlow()
init {
// Async DataStore prime — never runBlocking. The StateFlow briefly emits null
// then populates from disk; PermissionGate's permissive default covers the flash.
applicationScope.launch {
cacheDataStore.load()?.let { _userPermissions.value = it }
}
}
override suspend fun fetchUserRole(): Result<UserRolePermissions> {
val userResult = userRepository.getUser()
val user = when (userResult) {
is Result.Success -> userResult.data
is Result.Error -> return Result.Error(userResult.exception, "No current user")
is Result.Loading -> return Result.Error(message = "User load still in progress")
}
return try {
val role = rolesApi.getRole(user.role)
_userPermissions.value = role
cacheDataStore.save(role)
Result.Success(role)
} catch (e: Exception) {
val cached = _userPermissions.value
if (cached != null) {
Logger.w(e) { "fetchUserRole failed, keeping cached role '${cached.name}'" }
Result.Success(cached)
} else {
Logger.w(e) { "fetchUserRole failed with no cached value" }
Result.Error(e, e.message ?: "Failed to load role permissions")
}
}
}
override suspend fun clear() {
_userPermissions.value = null
cacheDataStore.clear()
}
}

View file

@ -0,0 +1,22 @@
package com.garfiec.librechat.core.data.util
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
class PermissionGate(private val roleRepository: RoleRepository) {
/**
* Awaits the role to load (up to [ROLE_LOAD_TIMEOUT_MS]) and returns it.
* Null return = timeout or fetch-failure; callers should treat it as permissive.
*/
suspend fun awaitRole(): UserRolePermissions? =
withTimeoutOrNull(ROLE_LOAD_TIMEOUT_MS) {
roleRepository.userPermissions.filterNotNull().first()
}
companion object {
const val ROLE_LOAD_TIMEOUT_MS = 5_000L
}
}

View file

@ -0,0 +1,15 @@
package com.garfiec.librechat.core.data.util
import com.garfiec.librechat.core.data.repository.TagRepository
/**
* Refreshes conversation tags from the server when a session starts so the drawer has
* up-to-date tag counts. Fires in parallel with other session tasks.
*/
class RefreshTagsSessionTask(
private val tagRepository: TagRepository,
) : SessionTask {
override suspend fun run() {
tagRepository.refreshTags()
}
}

View file

@ -0,0 +1,16 @@
package com.garfiec.librechat.core.data.util
import com.garfiec.librechat.core.data.repository.RoleRepository
/**
* Loads the authenticated user's role permissions when a session starts. Without this,
* permission gates stay permissive-null across app restarts until a gated screen
* happens to trigger a user fetch.
*/
class RoleFetchSessionTask(
private val roleRepository: RoleRepository,
) : SessionTask {
override suspend fun run() {
roleRepository.fetchUserRole()
}
}

View file

@ -0,0 +1,37 @@
package com.garfiec.librechat.core.data.util
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Work that runs whenever the app transitions into an authenticated session either a
* fresh login / OAuth / 2FA success, or a cold-start where a valid session is restored.
* Implementations should be fire-and-forget; the caller does not block on completion.
*/
interface SessionTask {
suspend fun run()
}
/**
* Invokes each registered [SessionTask] on [applicationScope] so task work outlives the
* caller's scope (short-lived auth VM or the one-shot NavHostViewModel init block).
* Individual task failures are logged and swallowed so one bad task doesn't poison the
* rest of the session-start path.
*/
class SessionTaskRunner(
private val tasks: List<SessionTask>,
private val applicationScope: CoroutineScope,
) {
fun runAll() {
tasks.forEach { task ->
applicationScope.launch {
try {
task.run()
} catch (e: Exception) {
Logger.w(e) { "SessionTask ${task::class.simpleName} failed" }
}
}
}
}
}

View file

@ -0,0 +1,15 @@
package com.garfiec.librechat.core.data.util
import com.garfiec.librechat.core.data.repository.ConversationRepository
/**
* Pulls the latest favorite-conversation markers from the server when a session starts
* so the drawer's favorites section is correct before the user interacts with it.
*/
class SyncFavoritesSessionTask(
private val conversationRepository: ConversationRepository,
) : SessionTask {
override suspend fun run() {
conversationRepository.syncFavoritesFromServer()
}
}

View file

@ -10,11 +10,13 @@ import com.garfiec.librechat.core.data.datastore.IosTokenDataStore
import com.garfiec.librechat.core.data.datastore.ServerUrlKeychainFallback
import com.garfiec.librechat.core.data.db.LibreChatDatabase
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SessionCacheCleaner
import com.garfiec.librechat.core.network.client.SecureTokenStorage
import com.garfiec.librechat.core.network.client.TokenManager
import io.ktor.client.HttpClient
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import okio.Path.Companion.toPath
@ -69,6 +71,10 @@ actual val dataPlatformModule: Module = module {
NSUserDomainMask,
true,
).firstOrNull() as? String ?: error("Unable to resolve NSCachesDirectory")
CommonSessionCacheCleaner(cachePath)
CommonSessionCacheCleaner(
cacheRoot = cachePath,
roleRepository = get<RoleRepository>(),
applicationScope = get<CoroutineScope>(KoinQualifiers.ApplicationScope),
)
}
}

View file

@ -0,0 +1,7 @@
package com.garfiec.librechat.core.model.permissions
enum class Permission(val serverKey: String) {
USE("USE"),
CREATE("CREATE"),
SHARE("SHARE"),
}

View file

@ -0,0 +1,16 @@
package com.garfiec.librechat.core.model.permissions
enum class PermissionType(val serverKey: String) {
PROMPTS("PROMPTS"),
AGENTS("AGENTS"),
MEMORIES("MEMORIES"),
MCP_SERVERS("MCP_SERVERS"),
BOOKMARKS("BOOKMARKS"),
WEB_SEARCH("WEB_SEARCH"),
RUN_CODE("RUN_CODE"),
FILE_SEARCH("FILE_SEARCH"),
TEMPORARY_CHAT("TEMPORARY_CHAT"),
MULTI_CONVO("MULTI_CONVO"),
MARKETPLACE("MARKETPLACE"),
REMOTE_AGENTS("REMOTE_AGENTS"),
}

View file

@ -0,0 +1,38 @@
package com.garfiec.librechat.core.model.permissions
import kotlinx.serialization.Serializable
@Serializable
data class UserRolePermissions(
val name: String,
val description: String? = null,
val permissions: Map<String, Map<String, Boolean>> = emptyMap(),
) {
/**
* Returns true if the permission is explicitly granted OR unknown.
*
* Permissive-default is intentional for forward compatibility: if a future server
* adds a new PermissionType the client doesn't know about, or omits a field on an
* older build, the client optimistically shows the UI. The server's middleware still
* enforces via 403 the client gate is for UX (hiding dead-end buttons), not security.
*
* This diverges from the PWA's `useHasAccess` (which returns false on unknown). The
* rationale is mobile-specific: mobile apps are long-lived, so an old-client-on-new-server
* scenario is more likely than on web. Permissive default avoids breaking existing users
* when the server adds new permission types between releases.
*/
fun hasAccess(type: PermissionType, permission: Permission): Boolean {
val perms = permissions[type.serverKey] ?: return true
val value = perms[permission.serverKey] ?: return true
return value
}
}
/**
* Permissive-default variant of [UserRolePermissions.hasAccess] that treats a null role
* (still loading, fetch failed, timed out) as permissive. Replaces the `role?.hasAccess(...) ?: true`
* idiom so the permissive contract lives in one place instead of being open-coded at every
* role-flag mapping site across the VMs.
*/
fun UserRolePermissions?.hasAccessOrPermissive(type: PermissionType, permission: Permission): Boolean =
this?.hasAccess(type, permission) ?: true

View file

@ -0,0 +1,162 @@
package com.garfiec.librechat.core.model.permissions
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UserRolePermissionsTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun hasAccessReturnsTrueWhenPermissionExplicitlyGranted() {
val role = UserRolePermissions(
name = "ADMIN",
permissions = mapOf("PROMPTS" to mapOf("USE" to true)),
)
assertTrue(role.hasAccess(PermissionType.PROMPTS, Permission.USE))
}
@Test
fun hasAccessReturnsFalseWhenPermissionExplicitlyDenied() {
val role = UserRolePermissions(
name = "ADMIN",
permissions = mapOf("PROMPTS" to mapOf("USE" to false)),
)
assertFalse(role.hasAccess(PermissionType.PROMPTS, Permission.USE))
}
@Test
fun hasAccessReturnsTrueWhenPermissionTypeMissing() {
val role = UserRolePermissions(
name = "ADMIN",
permissions = mapOf("PROMPTS" to mapOf("USE" to true)),
)
// AGENTS type not in map → permissive default
assertTrue(role.hasAccess(PermissionType.AGENTS, Permission.USE))
}
@Test
fun hasAccessReturnsTrueWhenActionMissingWithinKnownType() {
val role = UserRolePermissions(
name = "ADMIN",
permissions = mapOf("PROMPTS" to mapOf("USE" to false)),
)
// CREATE action absent under PROMPTS → permissive default
assertTrue(role.hasAccess(PermissionType.PROMPTS, Permission.CREATE))
}
@Test
fun hasAccessReturnsTrueForEveryPermissionWhenPermissionsMapEmpty() {
val role = UserRolePermissions(name = "ADMIN", permissions = emptyMap())
assertTrue(role.hasAccess(PermissionType.PROMPTS, Permission.USE))
assertTrue(role.hasAccess(PermissionType.AGENTS, Permission.CREATE))
assertTrue(role.hasAccess(PermissionType.MCP_SERVERS, Permission.USE))
}
@Test
fun deserializesLiveRolesAdminResponseFromV085Rc1() {
// Captured 2026-04-24 from `GET /api/roles/ADMIN` on v0.8.5-rc1 test server
// with mongo roles seeded from an earlier USE=false configuration.
val decoded = json.decodeFromString(UserRolePermissions.serializer(), LIVE_ADMIN_ROLE_JSON)
assertEquals("ADMIN", decoded.name)
assertEquals("", decoded.description)
// Spot-check a mix of denied + granted permissions from the live response.
assertFalse(decoded.hasAccess(PermissionType.PROMPTS, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.PROMPTS, Permission.CREATE))
assertFalse(decoded.hasAccess(PermissionType.AGENTS, Permission.USE))
assertFalse(decoded.hasAccess(PermissionType.MEMORIES, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.MEMORIES, Permission.CREATE))
assertFalse(decoded.hasAccess(PermissionType.MCP_SERVERS, Permission.USE))
assertFalse(decoded.hasAccess(PermissionType.MARKETPLACE, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.BOOKMARKS, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.MULTI_CONVO, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.TEMPORARY_CHAT, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.RUN_CODE, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.WEB_SEARCH, Permission.USE))
assertTrue(decoded.hasAccess(PermissionType.FILE_SEARCH, Permission.USE))
assertFalse(decoded.hasAccess(PermissionType.REMOTE_AGENTS, Permission.USE))
assertFalse(decoded.hasAccess(PermissionType.REMOTE_AGENTS, Permission.CREATE))
}
@Test
fun deserializesMinimalRolePayload() {
val minimal = """{"name":"USER","permissions":{}}"""
val decoded = json.decodeFromString(UserRolePermissions.serializer(), minimal)
assertEquals("USER", decoded.name)
assertTrue(decoded.permissions.isEmpty())
assertTrue(decoded.hasAccess(PermissionType.PROMPTS, Permission.USE))
}
@Test
fun ignoresUnknownTopLevelFieldsFromServer() {
// Live payload includes `_id` and `__v` which aren't in our model; ensure it deserializes.
val json = Json { ignoreUnknownKeys = true }
val payload = """{"_id":"abc","name":"ADMIN","__v":0,"permissions":{"PROMPTS":{"USE":true}}}"""
val decoded = json.decodeFromString(UserRolePermissions.serializer(), payload)
assertEquals("ADMIN", decoded.name)
assertTrue(decoded.hasAccess(PermissionType.PROMPTS, Permission.USE))
}
private companion object {
// Verbatim response body from the running v0.8.5-rc1 test server, 2026-04-24.
private val LIVE_ADMIN_ROLE_JSON = """
{
"_id": "69eab599e5c9757247ad5c21",
"name": "ADMIN",
"permissions": {
"BOOKMARKS": { "USE": true },
"PROMPTS": {
"USE": false,
"CREATE": true,
"SHARE": false,
"SHARE_PUBLIC": false
},
"MEMORIES": {
"USE": false,
"CREATE": true,
"UPDATE": true,
"READ": true,
"OPT_OUT": true
},
"AGENTS": {
"USE": false,
"CREATE": true,
"SHARE": false,
"SHARE_PUBLIC": false
},
"MULTI_CONVO": { "USE": true },
"TEMPORARY_CHAT": { "USE": true },
"RUN_CODE": { "USE": true },
"WEB_SEARCH": { "USE": true },
"PEOPLE_PICKER": {
"VIEW_USERS": true,
"VIEW_GROUPS": true,
"VIEW_ROLES": true
},
"MARKETPLACE": { "USE": false },
"FILE_SEARCH": { "USE": true },
"FILE_CITATIONS": { "USE": true },
"MCP_SERVERS": {
"USE": false,
"CREATE": true,
"SHARE": false,
"SHARE_PUBLIC": false
},
"REMOTE_AGENTS": {
"USE": false,
"CREATE": false,
"SHARE": false,
"SHARE_PUBLIC": false
}
},
"__v": 0,
"description": ""
}
""".trimIndent()
}
}

View file

@ -0,0 +1,16 @@
package com.garfiec.librechat.core.network.api
import com.garfiec.librechat.core.model.permissions.UserRolePermissions
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.http.path
class RolesApi(
private val client: HttpClient,
) {
suspend fun getRole(roleName: String): UserRolePermissions =
client.get {
url { path("api/roles/$roleName") }
}.body()
}

View file

@ -17,6 +17,7 @@ import com.garfiec.librechat.core.network.api.MemoriesApi
import com.garfiec.librechat.core.network.api.MessagesApi
import com.garfiec.librechat.core.network.api.PresetsApi
import com.garfiec.librechat.core.network.api.PromptsApi
import com.garfiec.librechat.core.network.api.RolesApi
import com.garfiec.librechat.core.network.api.SearchApi
import com.garfiec.librechat.core.network.api.ShareApi
import com.garfiec.librechat.core.network.api.SpeechApi
@ -131,6 +132,7 @@ val networkModule = module {
singleOf(::MessagesApi)
singleOf(::PresetsApi)
singleOf(::PromptsApi)
singleOf(::RolesApi)
singleOf(::SearchApi)
singleOf(::ShareApi)
singleOf(::SpeechApi)

View file

@ -26,6 +26,10 @@ fun ErrorBanner(
message: String,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null,
retryLabel: String = "Retry",
dismissLabel: String = "Dismiss",
errorContentDescription: String = "Error",
) {
Card(
modifier = modifier
@ -42,7 +46,7 @@ fun ErrorBanner(
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Error",
contentDescription = errorContentDescription,
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(12.dp))
@ -54,7 +58,12 @@ fun ErrorBanner(
)
if (onRetry != null) {
TextButton(onClick = onRetry) {
Text("Retry")
Text(retryLabel)
}
}
if (onDismiss != null) {
TextButton(onClick = onDismiss) {
Text(dismissLabel)
}
}
}

View file

@ -6,6 +6,8 @@ import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.repository.AgentRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import org.junit.Test
import org.koin.test.verify.verify
@ -19,6 +21,8 @@ class AgentsModuleVerificationTest {
AgentRepository::class,
ConfigRepository::class,
McpRepository::class,
RoleRepository::class,
PermissionGate::class,
ServerDataStore::class,
),
)

View file

@ -3,6 +3,8 @@ package com.garfiec.librechat.feature.agents.viewmodel
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.repository.AgentRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.Agent
import com.garfiec.librechat.core.model.AgentCategory
import com.garfiec.librechat.core.model.PaginatedAgents
@ -13,6 +15,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -29,6 +32,8 @@ class AgentMarketplaceViewModelTest {
private val agentRepository = mockk<AgentRepository>(relaxed = true)
private val serverDataStore = mockk<ServerDataStore>(relaxed = true)
private val roleRepository = mockk<RoleRepository>(relaxed = true)
private val permissionGate = mockk<PermissionGate>(relaxed = true)
private lateinit var viewModel: AgentMarketplaceViewModel
@ -47,6 +52,12 @@ class AgentMarketplaceViewModelTest {
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
// Permissive-by-null StateFlow for role repository so the collector leaves
// flags at their default `true`. awaitRole() returns null (no timeout needed
// in tests — the suspend returns null immediately), so the gated loadAgents()
// path fires via `?: != false` permissive.
every { roleRepository.userPermissions } returns MutableStateFlow(null)
coEvery { permissionGate.awaitRole() } returns null
every { serverDataStore.getBaseUrl() } returns "https://chat.example.com"
coEvery {
agentRepository.getAgentsPaginated(
@ -67,6 +78,8 @@ class AgentMarketplaceViewModelTest {
private fun createViewModel() = AgentMarketplaceViewModel(
agentRepository = agentRepository,
serverDataStore = serverDataStore,
roleRepository = roleRepository,
permissionGate = permissionGate,
)
@Test

View file

@ -37,6 +37,8 @@
<string name="search_agents_hint">Search agents\u2026</string>
<string name="no_agents_found">No agents found</string>
<string name="no_agents_available">No agents available</string>
<string name="agents_not_available_title">Agents not available</string>
<string name="agents_not_available_description">Your server administrator has disabled agents for your account.</string>
<string name="try_different_search">Try a different search term</string>
<string name="check_back_later">Check back later for new agents</string>
<string name="error_unknown">An unknown error occurred</string>

View file

@ -97,13 +97,15 @@ fun AgentMarketplaceScreen(
)
},
floatingActionButton = {
FloatingActionButton(
onClick = onCreateAgent,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(Res.string.cd_create_agent),
)
if (uiState.agentsEnabled && uiState.agentsCreateEnabled) {
FloatingActionButton(
onClick = onCreateAgent,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(Res.string.cd_create_agent),
)
}
}
},
) { innerPadding ->
@ -114,6 +116,17 @@ fun AgentMarketplaceScreen(
.fillMaxSize()
.padding(innerPadding),
) {
if (!uiState.agentsEnabled) {
// Role denies AGENTS.USE — render degraded empty state. Hide search,
// category chips, grid, and FAB. Matches the Settings MCP "not available"
// pattern for consistency across gated surfaces.
EmptyState(
title = stringResource(Res.string.agents_not_available_title),
description = stringResource(Res.string.agents_not_available_description),
icon = Icons.Default.SmartToy,
)
return@PullToRefreshBox
}
Column(modifier = Modifier.fillMaxSize()) {
// Search bar
OutlinedTextField(

View file

@ -6,7 +6,12 @@ import androidx.lifecycle.viewModelScope
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.repository.AgentRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.Agent
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive
import com.garfiec.librechat.feature.agents.AgentCardDisplayData
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -28,11 +33,17 @@ data class AgentMarketplaceUiState(
val categories: List<String> = emptyList(),
val hasMore: Boolean = true,
val currentPage: Int = 1,
// Role-permission gates — default permissive.
val agentsEnabled: Boolean = true,
val agentsCreateEnabled: Boolean = true,
val marketplaceEnabled: Boolean = true,
)
class AgentMarketplaceViewModel(
private val agentRepository: AgentRepository,
private val serverDataStore: ServerDataStore,
private val roleRepository: RoleRepository,
private val permissionGate: PermissionGate,
) : ViewModel() {
private val _uiState = MutableStateFlow(AgentMarketplaceUiState())
@ -64,8 +75,32 @@ class AgentMarketplaceViewModel(
}
init {
loadAgents()
// Categories endpoint is ungated server-side — load unconditionally.
loadCategories()
observePermissionFlags()
loadInitialAgents()
}
/** Continuous collector mirroring the current role into UiState permission flags. */
private fun observePermissionFlags() {
viewModelScope.launch {
roleRepository.userPermissions.collect { role ->
_uiState.value = _uiState.value.copy(
agentsEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.USE),
agentsCreateEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.CREATE),
marketplaceEnabled = role.hasAccessOrPermissive(PermissionType.MARKETPLACE, Permission.USE),
)
}
}
}
/** Fetches the first page of agents once the role confirms AGENTS.USE. Permissive on timeout. */
private fun loadInitialAgents() {
viewModelScope.launch {
if (permissionGate.awaitRole()?.hasAccess(PermissionType.AGENTS, Permission.USE) != false) {
loadAgents()
}
}
}
fun loadAgents() {

View file

@ -85,6 +85,10 @@ fun ChatInput(
onOpenModelSelector: () -> Unit = {},
selectedModelDisplay: String? = null,
isCodeInterpreterAvailable: Boolean = true,
webSearchEnabled: Boolean = true,
runCodeEnabled: Boolean = true,
fileSearchEnabled: Boolean = true,
mcpServersEnabled: Boolean = true,
) {
val cdOpenToolsMenu = stringResource(Res.string.cd_open_tools_menu)
val cdPasteImage = stringResource(Res.string.cd_paste_image)
@ -396,6 +400,10 @@ fun ChatInput(
selectedModelDisplay = selectedModelDisplay,
onDismiss = { showToolsSheet = false },
isCodeInterpreterAvailable = isCodeInterpreterAvailable,
webSearchEnabled = webSearchEnabled,
runCodeEnabled = runCodeEnabled,
fileSearchEnabled = fileSearchEnabled,
mcpServersEnabled = mcpServersEnabled,
)
}
}

View file

@ -32,6 +32,8 @@ actual val chatPlatformModule: Module = module {
shareRepository = get(),
mcpRepository = get(),
userRepository = get(),
roleRepository = get(),
permissionGate = get(),
connectivityObserver = get(),
serverDataStore = get(),
settingsDataStore = get(),

View file

@ -95,6 +95,7 @@ import com.garfiec.librechat.feature.chat.resources.Res
import com.garfiec.librechat.feature.chat.util.MessageNode
import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
import com.garfiec.librechat.feature.chat.viewmodel.asString
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@ -136,7 +137,6 @@ actual fun ChatScreen(
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
var showPresetPicker by remember { mutableStateOf(false) }
var showSavePresetDialog by remember { mutableStateOf(false) }
var showModelSheet by remember { mutableStateOf(false) }
var showSecondaryModelSheet by remember { mutableStateOf(false) }
var activeComparisonTab by remember { mutableStateOf(0) }
@ -259,6 +259,8 @@ actual fun ChatScreen(
}
}
val sendBlockMessage = uiState.sendBlockReason?.asString()
// Stream resume on foreground
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { viewModel.onPause() }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onResume() }
@ -320,9 +322,11 @@ actual fun ChatScreen(
onOpenDrawer = onOpenDrawer,
onOpenSearch = viewModel::openSearch,
onOpenPromptsLibrary = onNavigateToPromptsLibrary,
promptsEnabled = uiState.promptsEnabled,
multiConvoEnabled = uiState.multiConvoEnabled,
isTemporaryChat = uiState.isTemporaryChat,
onToggleTemporaryChat = viewModel::toggleTemporaryChat,
showTempChatToggle = uiState.conversationId == null,
showTempChatToggle = uiState.conversationId == null && uiState.temporaryChatEnabled,
isComparisonEnabled = uiState.comparisonState.isEnabled,
onToggleComparison = viewModel::toggleComparison,
conversationId = uiState.conversationId,
@ -521,7 +525,7 @@ actual fun ChatScreen(
primaryModelSelector = {
ModelSelectorButton(
modelName = displayModel,
onClick = { showModelSheet = true },
onClick = viewModel::openModelSheet,
)
},
secondaryModelSelector = {
@ -644,7 +648,7 @@ actual fun ChatScreen(
if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) {
showSecondaryModelSheet = true
} else {
showModelSheet = true
viewModel.openModelSheet()
}
},
selectedModelDisplay = if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) {
@ -655,6 +659,10 @@ actual fun ChatScreen(
displayModel
},
isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable,
webSearchEnabled = uiState.webSearchEnabled,
runCodeEnabled = uiState.runCodeEnabled,
fileSearchEnabled = uiState.fileSearchEnabled,
mcpServersEnabled = uiState.mcpServersEnabled,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
@ -728,7 +736,7 @@ actual fun ChatScreen(
)
}
if (showModelSheet) {
if (uiState.showModelSheet) {
ModelSelectorSheet(
endpointConfigs = uiState.endpointConfigs,
availableModels = uiState.availableModels,
@ -737,10 +745,26 @@ actual fun ChatScreen(
selectedModel = uiState.selectedModel,
onModelSelect = { endpoint, model ->
viewModel.onModelSelected(endpoint, model)
showModelSheet = false
// Clear any pending scaffold-level snackbar for the same error so it
// doesn't flash behind the sheet's close animation. Harmless no-op when
// error is already null.
viewModel.dismissError()
viewModel.dismissSendBlockReason()
viewModel.dismissModelSheet()
},
onDismiss = {
viewModel.dismissError()
viewModel.dismissSendBlockReason()
viewModel.dismissModelSheet()
},
onDismiss = { showModelSheet = false },
serverUrl = uiState.serverUrl,
// Send-block reasons take precedence: when set, the sheet was auto-opened
// to help the user resolve the block, so surface that context inline.
errorMessage = sendBlockMessage ?: uiState.error,
onErrorDismiss = {
viewModel.dismissSendBlockReason()
viewModel.dismissError()
},
)
}
@ -771,6 +795,8 @@ private fun ChatTopBar(
modifier: Modifier = Modifier,
onOpenSearch: () -> Unit = {},
onOpenPromptsLibrary: (() -> Unit)? = null,
promptsEnabled: Boolean = true,
multiConvoEnabled: Boolean = true,
isTemporaryChat: Boolean = false,
onToggleTemporaryChat: () -> Unit = {},
showTempChatToggle: Boolean = false,
@ -870,7 +896,7 @@ private fun ChatTopBar(
Icon(Icons.Outlined.SaveAs, contentDescription = null)
},
)
if (onOpenPromptsLibrary != null) {
if (onOpenPromptsLibrary != null && promptsEnabled) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.prompts_library)) },
onClick = {
@ -882,32 +908,34 @@ private fun ChatTopBar(
},
)
}
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.compare_models),
modifier = Modifier.weight(1f),
)
if (isComparisonEnabled) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(Res.string.cd_comparison_enabled),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
if (multiConvoEnabled) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.compare_models),
modifier = Modifier.weight(1f),
)
if (isComparisonEnabled) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(Res.string.cd_comparison_enabled),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
},
onClick = {
showOverflowMenu = false
onToggleComparison()
},
leadingIcon = {
Icon(Icons.Outlined.Compare, contentDescription = null)
},
)
},
onClick = {
showOverflowMenu = false
onToggleComparison()
},
leadingIcon = {
Icon(Icons.Outlined.Compare, contentDescription = null)
},
)
}
if (conversationId != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(

View file

@ -15,9 +15,11 @@ import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.repository.MessageRepository
import com.garfiec.librechat.core.data.repository.PresetRepository
import com.garfiec.librechat.core.data.repository.PromptRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.SpeechRepository
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import org.junit.Test
import org.koin.test.verify.verify
@ -37,6 +39,8 @@ class ChatModuleVerificationTest {
FileRepository::class,
PresetRepository::class,
PromptRepository::class,
RoleRepository::class,
PermissionGate::class,
ShareRepository::class,
SpeechRepository::class,
McpRepository::class,

View file

@ -0,0 +1,95 @@
package com.garfiec.librechat.feature.chat.viewmodel
import com.garfiec.librechat.core.common.EndpointConstants
import com.google.common.truth.Truth.assertThat
import org.junit.Test
/**
* Tests for [ChatUiState.isSendReady], the pre-send gate used by [ChatViewModel]'s
* `runWhenSendReady` helper to avoid the cold-start race where endpoint/config hasn't
* arrived yet and firing startChat would produce a mislabeled 403.
*
* Pure-function tests no ViewModel instantiation, no mockk overhead.
*/
class ChatUiStateSendReadyTest {
private val anthropic = "anthropic"
private val haiku = "claude-haiku-4-5-20251001"
@Test
fun `not ready when availableModels is empty`() {
val state = ChatUiState(
selectedEndpoint = anthropic,
selectedModel = haiku,
agentsEnabled = true,
availableModels = emptyMap(),
)
assertThat(state.isSendReady).isFalse()
}
@Test
fun `ready when non-agents endpoint selected and models available`() {
val state = ChatUiState(
selectedEndpoint = anthropic,
selectedModel = haiku,
agentsEnabled = true,
availableModels = mapOf(anthropic to listOf(haiku)),
)
assertThat(state.isSendReady).isTrue()
}
@Test
fun `not ready when agents endpoint selected but AGENTS USE denied`() {
// This is the reporter's scenario: cold start with agents as default
// selection, but role has loaded and denied AGENTS.USE. A send right now
// would hit /api/agents/chat/agents and 403.
val state = ChatUiState(
selectedEndpoint = EndpointConstants.AGENTS,
agentsEnabled = false,
availableModels = mapOf(anthropic to listOf(haiku)),
)
assertThat(state.isSendReady).isFalse()
}
@Test
fun `ready when agents endpoint selected and AGENTS USE granted`() {
val state = ChatUiState(
selectedEndpoint = EndpointConstants.AGENTS,
agentsEnabled = true,
availableModels = mapOf(
EndpointConstants.AGENTS to listOf("someAgentId"),
anthropic to listOf(haiku),
),
)
assertThat(state.isSendReady).isTrue()
}
@Test
fun `not ready when only agents endpoint is offered by server but AGENTS USE denied`() {
// Edge: server exposes only the agents endpoint in availableModels, role denies.
// No viable send path — stays not-ready. runWhenSendReady will time out and toast.
val state = ChatUiState(
selectedEndpoint = EndpointConstants.AGENTS,
agentsEnabled = false,
availableModels = mapOf(EndpointConstants.AGENTS to listOf("someAgentId")),
)
assertThat(state.isSendReady).isFalse()
}
@Test
fun `ready when non-agents endpoint selected and model list for that endpoint is empty`() {
// isSendReady only cares that availableModels is non-empty and the selected
// endpoint isn't a known-denied one. An empty model list on the current endpoint
// is the user's concern (pick a model), not the race guard's.
val state = ChatUiState(
selectedEndpoint = anthropic,
selectedModel = null,
agentsEnabled = true,
availableModels = mapOf(
EndpointConstants.AGENTS to listOf("someAgentId"),
anthropic to emptyList(),
),
)
assertThat(state.isSendReady).isTrue()
}
}

View file

@ -327,4 +327,12 @@
<!-- Slash command menu -->
<string name="slash_command_title">/%1$s</string>
<!-- Send block reasons (shown in error snackbar when send can't proceed) -->
<string name="send_block_select_agent">Select an agent before sending your message.</string>
<string name="send_block_select_model">Select a model before sending your message.</string>
<string name="send_block_agents_unavailable">Agents aren\'t available on this server. Please pick a different model.</string>
<string name="send_block_agent_not_available">The selected agent isn\'t available on this server. Please pick a different agent.</string>
<string name="send_block_model_not_available">The selected model isn\'t available on this server. Please pick a different model.</string>
<string name="send_block_model_load_failed">Couldn\'t load the selected model yet. Please try again or pick a different model.</string>
</resources>

View file

@ -75,6 +75,10 @@ fun ChatToolsBottomSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
isCodeInterpreterAvailable: Boolean = true,
webSearchEnabled: Boolean = true,
runCodeEnabled: Boolean = true,
fileSearchEnabled: Boolean = true,
mcpServersEnabled: Boolean = true,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showMcpServers by remember { mutableStateOf(false) }
@ -204,15 +208,17 @@ fun ChatToolsBottomSheet(
Spacer(modifier = Modifier.height(8.dp))
// Tool toggle items
ToolToggleRow(
icon = Icons.Default.Search,
title = stringResource(Res.string.tool_web_search),
subtitle = stringResource(Res.string.tool_web_search_desc),
isEnabled = ToolConstants.WEB_SEARCH in enabledTools,
onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) },
)
if (webSearchEnabled) {
ToolToggleRow(
icon = Icons.Default.Search,
title = stringResource(Res.string.tool_web_search),
subtitle = stringResource(Res.string.tool_web_search_desc),
isEnabled = ToolConstants.WEB_SEARCH in enabledTools,
onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) },
)
}
if (isCodeInterpreterAvailable) {
if (isCodeInterpreterAvailable && runCodeEnabled) {
ToolToggleRow(
icon = Icons.Default.Code,
title = stringResource(Res.string.tool_code),
@ -222,16 +228,18 @@ fun ChatToolsBottomSheet(
)
}
ToolToggleRow(
icon = Icons.Default.FindInPage,
title = stringResource(Res.string.tool_file_search),
subtitle = stringResource(Res.string.tool_file_search_desc),
isEnabled = ToolConstants.FILE_SEARCH in enabledTools,
onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) },
)
if (fileSearchEnabled) {
ToolToggleRow(
icon = Icons.Default.FindInPage,
title = stringResource(Res.string.tool_file_search),
subtitle = stringResource(Res.string.tool_file_search_desc),
isEnabled = ToolConstants.FILE_SEARCH in enabledTools,
onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) },
)
}
// MCP section
if (mcpServers.isNotEmpty()) {
// MCP section — hidden entirely when role denies MCP_SERVERS.USE.
if (mcpServersEnabled && mcpServers.isNotEmpty()) {
val anyMcpSelected = selectedMcpServerNames.isNotEmpty()
Row(
modifier = Modifier

View file

@ -48,6 +48,7 @@ import coil3.compose.AsyncImage
import com.garfiec.librechat.core.common.EndpointConstants
import com.garfiec.librechat.core.model.Agent
import com.garfiec.librechat.core.model.EndpointConfig
import com.garfiec.librechat.core.ui.components.ErrorBanner
import com.garfiec.librechat.core.ui.components.endpointIconPainter
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
import com.garfiec.librechat.feature.chat.resources.*
@ -96,6 +97,8 @@ fun ModelSelectorSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
serverUrl: String = "",
errorMessage: String? = null,
onErrorDismiss: () -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var searchQuery by remember { mutableStateOf("") }
@ -144,20 +147,28 @@ fun ModelSelectorSheet(
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp),
) {
// Banner applies its own 16dp inset, so it sits outside the Column's
// horizontal padding to line up with the other elements.
if (errorMessage != null) {
ErrorBanner(message = errorMessage, onDismiss = onErrorDismiss)
}
Text(
text = stringResource(Res.string.select_a_model),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 16.dp),
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
)
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Search models...") },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
singleLine = true,
trailingIcon = {
if (searchQuery.isNotEmpty()) {
@ -173,7 +184,11 @@ fun ModelSelectorSheet(
Spacer(modifier = Modifier.height(12.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
) {
// "My Agents" group (shown first, like the web frontend)
if (filteredAgents.isNotEmpty()) {
val agentsExpanded = expandedGroups[EndpointConstants.AGENTS] != false

View file

@ -155,8 +155,10 @@ fun PromptsLibraryScreen(
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(onClick = { onNavigateToEditor(null) }) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.cd_create_prompt))
if (uiState.promptsCreateEnabled) {
FloatingActionButton(onClick = { onNavigateToEditor(null) }) {
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.cd_create_prompt))
}
}
},
) { innerPadding ->
@ -255,6 +257,7 @@ fun PromptsLibraryScreen(
group = group,
onClick = { viewModel.selectGroup(group.id) },
onShare = { viewModel.showShareDialog(group.id) },
showShareButton = uiState.promptsShareEnabled,
)
}
}
@ -281,6 +284,7 @@ private fun PromptGroupItem(
onClick: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
showShareButton: Boolean = true,
) {
Card(
modifier = modifier
@ -321,12 +325,14 @@ private fun PromptGroupItem(
)
}
}
IconButton(onClick = onShare) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(Res.string.cd_share),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (showShareButton) {
IconButton(onClick = onShare) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringResource(Res.string.cd_share),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
val promptText = group.promptText

View file

@ -6,7 +6,12 @@ import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.PromptRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.PromptGroup
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive
import com.garfiec.librechat.core.model.request.AddPromptToGroupRequest
import com.garfiec.librechat.core.model.request.CreatePromptData
import com.garfiec.librechat.core.model.request.CreatePromptGroupData
@ -42,10 +47,15 @@ data class PromptsUiState(
val showVariableDialog: Boolean = false,
val variablePromptTemplate: String = "",
val variableNames: List<String> = emptyList(),
// Role-permission gates — default permissive.
val promptsCreateEnabled: Boolean = true,
val promptsShareEnabled: Boolean = true,
)
class PromptsViewModel(
private val promptRepository: PromptRepository,
private val roleRepository: RoleRepository,
private val permissionGate: PermissionGate,
) : ViewModel() {
private val _uiState = MutableStateFlow(PromptsUiState())
@ -55,7 +65,29 @@ class PromptsViewModel(
private var rawGroups: List<PromptGroup> = emptyList()
init {
loadGroups()
observePermissionFlags()
loadInitialGroups()
}
/** Continuous collector mirroring CREATE/SHARE sub-action flags into UiState. */
private fun observePermissionFlags() {
viewModelScope.launch {
roleRepository.userPermissions.collect { role ->
_uiState.value = _uiState.value.copy(
promptsCreateEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.CREATE),
promptsShareEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.SHARE),
)
}
}
}
/** Fetches the first page of prompt groups once the role confirms PROMPTS.USE. Permissive on timeout. */
private fun loadInitialGroups() {
viewModelScope.launch {
if (permissionGate.awaitRole()?.hasAccess(PermissionType.PROMPTS, Permission.USE) != false) {
loadGroups()
}
}
}
fun loadGroups() {

View file

@ -18,6 +18,19 @@ import com.garfiec.librechat.feature.chat.util.MessageNode
enum class ChatScreenState { LANDING, LOADING, ACTIVE }
/**
* Reasons why a send attempt was blocked. Resolved to a user-facing string in the Compose
* layer via `stringResource`, so the ViewModel stays free of hard-coded English UI copy.
*/
sealed interface SendBlockReason {
data object SelectAgent : SendBlockReason
data object SelectModel : SendBlockReason
data object AgentsUnavailable : SendBlockReason
data object AgentNotAvailable : SendBlockReason
data object ModelNotAvailable : SendBlockReason
data object ModelLoadFailed : SendBlockReason
}
/**
* Consolidated chat-related user preferences from [SettingsDataStore].
* Exposed as a single [StateFlow] to reduce the number of individual subscriptions
@ -82,6 +95,9 @@ data class ChatUiState(
val agents: List<Agent> = emptyList(),
val conversationId: String? = null,
val error: String? = null,
/** Set when a send was blocked for a selection/readiness reason. Resolved to a
* user-facing string in the Compose layer. Null means no send-block to show. */
val sendBlockReason: SendBlockReason? = null,
val presets: List<PresetDisplayData> = emptyList(),
val availablePrompts: List<PromptMentionDisplayData> = emptyList(),
val editingMessageId: String? = null,
@ -131,8 +147,25 @@ data class ChatUiState(
* The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled],
* which also resets this ViewModel to a clean landing state. */
val pendingNavigationConversationId: String? = null,
/** Single source of truth for whether the model-selector sheet is open. Preflight
* failures and readiness timeouts flip this to true so the user sees the sheet with
* a send-block banner. Manual taps on the model chip also set it true via
* [ChatViewModel.openModelSheet]. UI dismissal routes through [ChatViewModel.dismissModelSheet]. */
val showModelSheet: Boolean = false,
// Model comparison state
val comparisonState: ComparisonState = ComparisonState(),
// Role-permission gates — default permissive; narrowed once RoleRepository emits.
val promptsEnabled: Boolean = true,
val promptsCreateEnabled: Boolean = true,
val agentsEnabled: Boolean = true,
val agentsCreateEnabled: Boolean = true,
val mcpServersEnabled: Boolean = true,
val multiConvoEnabled: Boolean = true,
val temporaryChatEnabled: Boolean = true,
val webSearchEnabled: Boolean = true,
val runCodeEnabled: Boolean = true,
val fileSearchEnabled: Boolean = true,
val bookmarksEnabled: Boolean = true,
) {
/**
* Effective tool set that merges [enabledTools] with the web search state from
@ -159,4 +192,22 @@ data class ChatUiState(
// the actual capabilities list is authoritative.
return agentsConfig?.capabilities?.contains(ToolConstants.EXECUTE_CODE) ?: true
}
/**
* True when firing `chatRepository.startChat(...)` is unlikely to race against
* cold-start initialization.
*
* Conditions:
* - [availableModels] is non-empty (server's endpoint/model list has arrived).
* - The currently-selected endpoint isn't a known-denied one. Specifically: if
* [selectedEndpoint] == "agents", [agentsEnabled] must be true.
*
* Not included: [selectedModel] != null. The agents endpoint auto-selects a
* default agent when none is set (see ModelSelectionDelegate.loadAgents) and
* the non-agents flows always set model+endpoint together, so a null here is
* orthogonal to the cold-start race we're guarding against.
*/
val isSendReady: Boolean
get() = availableModels.isNotEmpty() &&
(selectedEndpoint != EndpointConstants.AGENTS || agentsEnabled)
}

View file

@ -20,13 +20,18 @@ import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.repository.MessageRepository
import com.garfiec.librechat.core.data.repository.PresetRepository
import com.garfiec.librechat.core.data.repository.PromptRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.Attachment
import com.garfiec.librechat.core.model.EModelEndpoint
import com.garfiec.librechat.core.model.Message
import com.garfiec.librechat.core.model.Preset
import com.garfiec.librechat.core.model.StreamEvent
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive
import com.garfiec.librechat.core.model.request.EphemeralAgent
import com.garfiec.librechat.core.ui.components.ModelParameters
import com.garfiec.librechat.feature.chat.components.AttachedFile
@ -48,16 +53,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Clock
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
class ChatViewModel(
initialConversationId: String? = null,
private val agentRepository: AgentRepository,
@ -71,6 +79,8 @@ class ChatViewModel(
shareRepository: ShareRepository,
mcpRepository: McpRepository,
private val userRepository: UserRepository,
private val roleRepository: RoleRepository,
private val permissionGate: PermissionGate,
private val connectivityObserver: ConnectivityObserver,
serverDataStore: ServerDataStore,
private val settingsDataStore: SettingsDataStore,
@ -93,7 +103,7 @@ class ChatViewModel(
ConversationActionsDelegate(stateHandle, conversationRepository, shareRepository)
private val presetPromptDelegate = PresetPromptDelegate(stateHandle, presetRepository, promptRepository)
private val modelDelegate =
ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore)
ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore, permissionGate)
// --- Delegate-owned flows exposed to the UI ---
val attachedFiles: StateFlow<List<AttachedFile>> get() = fileDelegate.attachedFiles
@ -153,6 +163,11 @@ class ChatViewModel(
companion object {
/** Minimum interval between streaming UI state updates to avoid recomposition spam. */
private const val STREAMING_UI_UPDATE_INTERVAL_MS = 50L
/** Timeout for the pre-send "is the endpoint/config ready" await. Snappier than the
* 5 s role-load timeout because this only needs one of role OR availableModels to
* satisfy the check. */
private const val SEND_READY_TIMEOUT_MS = 3_000L
}
/** True when the current stream is from an edit, regenerate, or continue operation. */
@ -272,12 +287,26 @@ class ChatViewModel(
}
presetPromptDelegate.loadPresets()
presetPromptDelegate.loadAvailablePrompts()
modelDelegate.loadMcpServers()
loadUserProfile()
modelDelegate.loadAgents()
loadSharedLinksEnabled()
loadFlags()
voiceDelegate.loadSpeechConfig()
// Gated loads share a single 5-second role-await budget so offline/timeout
// launches don't serialize into N×5s. `role?.hasAccess(...) != false`
// preserves permissive default: null role (timeout/never-loaded) → true,
// missing type/action → true, explicit false → false.
viewModelScope.launch {
val role = permissionGate.awaitRole()
if (role?.hasAccess(PermissionType.PROMPTS, Permission.USE) != false) {
presetPromptDelegate.loadAvailablePrompts()
}
if (role?.hasAccess(PermissionType.MCP_SERVERS, Permission.USE) != false) {
modelDelegate.loadMcpServers()
}
if (role?.hasAccess(PermissionType.AGENTS, Permission.USE) != false) {
modelDelegate.loadAgents()
}
}
}
// ── Core chat flow ──────────────────────────────────────────────
@ -426,12 +455,12 @@ class ChatViewModel(
if (fileDelegate.hasPendingUploads()) {
Logger.d { "sendMessage: waiting for pending upload(s) to complete" }
fileDelegate.pendingUploadSendJob = viewModelScope.launch {
fileDelegate.waitForUploadsAndSend(text) { doSendMessage(it) }
fileDelegate.waitForUploadsAndSend(text) { runWhenSendReady { doSendMessage(it) } }
}
return
}
doSendMessage(text)
runWhenSendReady { doSendMessage(text) }
}
/**
@ -637,10 +666,12 @@ class ChatViewModel(
val originalMessage = _uiState.value.messages.find { it.messageId == messageId } ?: return
if (originalMessage.isCreatedByUser) {
editUserMessage(originalMessage, newText)
} else {
editAiMessage(originalMessage, newText)
runWhenSendReady {
if (originalMessage.isCreatedByUser) {
editUserMessage(originalMessage, newText)
} else {
editAiMessage(originalMessage, newText)
}
}
}
@ -717,6 +748,10 @@ class ChatViewModel(
it.messageId == aiMessage.parentMessageId
} ?: return
runWhenSendReady { regenerateMessageNow(parentUserMessage) }
}
private fun regenerateMessageNow(parentUserMessage: Message) {
isEditOrRegenerate = true
prepareForStreaming()
@ -1114,6 +1149,10 @@ class ChatViewModel(
it.messageId == lastAiMessage.message.parentMessageId
} ?: return
runWhenSendReady { continueGenerationNow(lastAiMessage.message, parentUserMessage) }
}
private fun continueGenerationNow(lastAiMessage: Message, parentUserMessage: Message) {
isEditOrRegenerate = true
prepareForStreaming()
@ -1132,7 +1171,7 @@ class ChatViewModel(
parentMessageId = parentUserMessage.parentMessageId,
agentId = if (isAgentContinue) _uiState.value.selectedModel else null,
overrideParentMessageId = parentUserMessage.messageId,
responseMessageId = lastAiMessage.message.messageId,
responseMessageId = lastAiMessage.messageId,
isEdited = true,
isRegenerate = true,
isContinued = true,
@ -1363,14 +1402,133 @@ class ChatViewModel(
}
}
private fun loadSharedLinksEnabled() {
private fun loadFlags() {
// startupConfig-driven flags (UI-only toggles, not permission gates).
viewModelScope.launch {
configRepository.startupConfig.collect { config ->
_uiState.value = _uiState.value.copy(
sharedLinksEnabled = config?.sharedLinksEnabled ?: false,
)
_uiState.update {
it.copy(sharedLinksEnabled = config?.sharedLinksEnabled ?: false)
}
}
}
// Role-permission-driven flags. Permissive default: null role (not loaded) → true;
// missing type/action in the map → true (see UserRolePermissions.hasAccess).
viewModelScope.launch {
roleRepository.userPermissions.collect { role ->
_uiState.update {
it.copy(
promptsEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.USE),
promptsCreateEnabled = role.hasAccessOrPermissive(PermissionType.PROMPTS, Permission.CREATE),
agentsEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.USE),
agentsCreateEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.CREATE),
mcpServersEnabled = role.hasAccessOrPermissive(PermissionType.MCP_SERVERS, Permission.USE),
multiConvoEnabled = role.hasAccessOrPermissive(PermissionType.MULTI_CONVO, Permission.USE),
temporaryChatEnabled = role.hasAccessOrPermissive(PermissionType.TEMPORARY_CHAT, Permission.USE),
webSearchEnabled = role.hasAccessOrPermissive(PermissionType.WEB_SEARCH, Permission.USE),
runCodeEnabled = role.hasAccessOrPermissive(PermissionType.RUN_CODE, Permission.USE),
fileSearchEnabled = role.hasAccessOrPermissive(PermissionType.FILE_SEARCH, Permission.USE),
bookmarksEnabled = role.hasAccessOrPermissive(PermissionType.BOOKMARKS, Permission.USE),
)
}
}
}
}
/**
* Suspends until [ChatUiState.isSendReady] becomes true, up to [timeoutMs]. Returns
* true if the state became ready; false on timeout. Used as a pre-flight guard on all
* send variants to avoid the cold-start race where endpoint/config hasn't arrived yet
* and firing `chatRepository.startChat(...)` would produce a mislabeled 403.
*
* 3 s chosen to be snappier than the role-load timeout (5 s) since this only needs
* one of the async inits (role OR availableModels) to complete enough to satisfy
* `isSendReady` usually both have landed by the time a human can tap send.
*/
private suspend fun awaitSendReady(timeoutMs: Long = SEND_READY_TIMEOUT_MS): Boolean {
if (_uiState.value.isSendReady) return true
return withTimeoutOrNull(timeoutMs) {
_uiState.map { it.isSendReady }.distinctUntilChanged().first { it }
} != null
}
/**
* Guard for each of the four send variants (send / edit / regenerate / continue).
* Runs a synchronous pre-flight that fails fast on user-input errors (e.g., no model
* selected, agents denied with role already loaded) so the user isn't made to wait
* for the readiness timeout just to be told something they could have acted on
* immediately. Otherwise, awaits readiness up to 3 s and falls back to a
* selection-aware availability message if the wait times out.
*/
private fun runWhenSendReady(action: () -> Unit) {
val current = _uiState.value
preflightSendBlockReason(current)?.let { reason ->
_uiState.update { it.copy(sendBlockReason = reason, showModelSheet = true) }
return
}
if (current.isSendReady) {
action()
return
}
viewModelScope.launch {
if (awaitSendReady()) {
action()
} else {
_uiState.update {
it.copy(
sendBlockReason = sendReadinessTimeoutReason(it),
showModelSheet = true,
)
}
}
}
}
/**
* Synchronous pre-flight. Returns a typed reason when sending is guaranteed
* to fail regardless of outstanding async inits; null when we still need to wait
* for the readiness signal. This keeps "no model selected" and "agents denied"
* instantaneous instead of waiting out the readiness timeout.
*/
private fun preflightSendBlockReason(state: ChatUiState): SendBlockReason? {
if (state.selectedModel == null) {
return if (state.selectedEndpoint == EndpointConstants.AGENTS) {
SendBlockReason.SelectAgent
} else {
SendBlockReason.SelectModel
}
}
if (state.selectedEndpoint == EndpointConstants.AGENTS && !state.agentsEnabled) {
return SendBlockReason.AgentsUnavailable
}
return null
}
/**
* Fallback for when readiness didn't resolve within the timeout. At this point the
* async model list is most likely in its final shape, so we can confidently flag
* stale selections that aren't in the available models.
*/
private fun sendReadinessTimeoutReason(state: ChatUiState): SendBlockReason {
if (state.selectedEndpoint == EndpointConstants.AGENTS) {
return SendBlockReason.AgentNotAvailable
}
val modelsForEndpoint = state.availableModels[state.selectedEndpoint].orEmpty()
val selectedModel = state.selectedModel
return if (selectedModel != null && selectedModel !in modelsForEndpoint) {
SendBlockReason.ModelNotAvailable
} else {
SendBlockReason.ModelLoadFailed
}
}
/** Opens the model-selector sheet. Called when the user taps the model chip. */
fun openModelSheet() {
_uiState.update { it.copy(showModelSheet = true) }
}
/** Dismisses the model-selector sheet. Called on sheet dismiss and model selection. */
fun dismissModelSheet() {
_uiState.update { it.copy(showModelSheet = false) }
}
private fun loadUserProfile() {
@ -1395,6 +1553,10 @@ class ChatViewModel(
_uiState.value = _uiState.value.copy(error = null)
}
fun dismissSendBlockReason() {
_uiState.value = _uiState.value.copy(sendBlockReason = null)
}
override fun onCleared() {
super.onCleared()
voiceDelegate.release()

View file

@ -0,0 +1,21 @@
package com.garfiec.librechat.feature.chat.viewmodel
import androidx.compose.runtime.Composable
import com.garfiec.librechat.feature.chat.resources.Res
import com.garfiec.librechat.feature.chat.resources.send_block_agent_not_available
import com.garfiec.librechat.feature.chat.resources.send_block_agents_unavailable
import com.garfiec.librechat.feature.chat.resources.send_block_model_load_failed
import com.garfiec.librechat.feature.chat.resources.send_block_model_not_available
import com.garfiec.librechat.feature.chat.resources.send_block_select_agent
import com.garfiec.librechat.feature.chat.resources.send_block_select_model
import org.jetbrains.compose.resources.stringResource
@Composable
fun SendBlockReason.asString(): String = when (this) {
SendBlockReason.SelectAgent -> stringResource(Res.string.send_block_select_agent)
SendBlockReason.SelectModel -> stringResource(Res.string.send_block_select_model)
SendBlockReason.AgentsUnavailable -> stringResource(Res.string.send_block_agents_unavailable)
SendBlockReason.AgentNotAvailable -> stringResource(Res.string.send_block_agent_not_available)
SendBlockReason.ModelNotAvailable -> stringResource(Res.string.send_block_model_not_available)
SendBlockReason.ModelLoadFailed -> stringResource(Res.string.send_block_model_load_failed)
}

View file

@ -8,8 +8,11 @@ import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.AgentRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.EModelEndpoint
import com.garfiec.librechat.core.model.mcp.McpServer
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.request.AddedConversation
import com.garfiec.librechat.core.ui.components.ModelParameters
import com.garfiec.librechat.feature.chat.model.McpServerDisplayData
@ -24,6 +27,7 @@ class ModelSelectionDelegate(
private val agentRepository: AgentRepository,
private val mcpRepository: McpRepository,
private val settingsDataStore: SettingsDataStore,
private val permissionGate: PermissionGate,
) {
// --- Model Comparison ---
@ -250,6 +254,12 @@ class ModelSelectionDelegate(
fun loadAgents() {
stateHandle.scope.launch {
// Skip the fetch entirely when the role denies AGENTS.USE; otherwise
// the server would return 403 and we'd have to decide whether it's a
// genuine 403 (rate limit, tenancy) vs. permission denial.
if (permissionGate.awaitRole()?.hasAccess(PermissionType.AGENTS, Permission.USE) == false) {
return@launch
}
when (val result = agentRepository.getAgents()) {
is Result.Success -> {
stateHandle.update { copy(agents = result.data) }

View file

@ -60,6 +60,10 @@ fun IosChatInput(
onAttachFiles: () -> Unit = {},
onTakePhoto: () -> Unit = {},
onPickPhotos: () -> Unit = {},
webSearchEnabled: Boolean = true,
runCodeEnabled: Boolean = true,
fileSearchEnabled: Boolean = true,
mcpServersEnabled: Boolean = true,
) {
val snackbarHostState = remember { SnackbarHostState() }
var showToolsSheet by remember { mutableStateOf(false) }
@ -208,6 +212,10 @@ fun IosChatInput(
selectedModelDisplay = selectedModelDisplay,
onDismiss = { showToolsSheet = false },
isCodeInterpreterAvailable = isCodeInterpreterAvailable,
webSearchEnabled = webSearchEnabled,
runCodeEnabled = runCodeEnabled,
fileSearchEnabled = fileSearchEnabled,
mcpServersEnabled = mcpServersEnabled,
)
}
}

View file

@ -30,6 +30,8 @@ actual val chatPlatformModule: Module = module {
shareRepository = get(),
mcpRepository = get(),
userRepository = get(),
roleRepository = get(),
permissionGate = get(),
connectivityObserver = get(),
serverDataStore = get(),
settingsDataStore = get(),

View file

@ -79,6 +79,7 @@ import com.garfiec.librechat.feature.chat.util.openPhotoPicker
import com.garfiec.librechat.feature.chat.util.readClipboardImage
import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
import com.garfiec.librechat.feature.chat.viewmodel.asString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -134,7 +135,6 @@ actual fun ChatScreen(
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
var showOptionsMenu by remember { mutableStateOf(false) }
var showModelSheet by remember { mutableStateOf(false) }
var showPresetPicker by remember { mutableStateOf(false) }
var showSavePresetDialog by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
@ -157,6 +157,8 @@ actual fun ChatScreen(
}
}
val sendBlockMessage = uiState.sendBlockReason?.asString()
Scaffold(
modifier = modifier.imePadding(),
snackbarHost = { SnackbarHost(snackbarHostState) },
@ -176,7 +178,7 @@ actual fun ChatScreen(
// Model selector only in active chat (not landing page)
ModelSelectorButton(
modelName = displayModel,
onClick = { showModelSheet = true },
onClick = viewModel::openModelSheet,
)
}
},
@ -191,8 +193,8 @@ actual fun ChatScreen(
}
},
actions = {
// Temp chat toggle — only on landing page
if (isLandingPage) {
// Temp chat toggle — only on landing page, gated on TEMPORARY_CHAT.USE
if (isLandingPage && uiState.temporaryChatEnabled) {
TempChatToggle(
isTemporary = uiState.isTemporaryChat,
onToggle = viewModel::toggleTemporaryChat,
@ -245,8 +247,8 @@ actual fun ChatScreen(
Icon(Icons.Outlined.SaveAs, contentDescription = null)
},
)
// Prompts Library
if (onNavigateToPromptsLibrary != null) {
// Prompts Library — gated on PROMPTS.USE
if (onNavigateToPromptsLibrary != null && uiState.promptsEnabled) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.prompts_library)) },
onClick = {
@ -258,33 +260,35 @@ actual fun ChatScreen(
},
)
}
// Compare Models
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.compare_models),
modifier = Modifier.weight(1f),
)
if (uiState.comparisonState.isEnabled) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(Res.string.cd_comparison_enabled),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
// Compare Models — gated on MULTI_CONVO.USE
if (uiState.multiConvoEnabled) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.compare_models),
modifier = Modifier.weight(1f),
)
if (uiState.comparisonState.isEnabled) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(Res.string.cd_comparison_enabled),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
},
onClick = {
showOptionsMenu = false
viewModel.toggleComparison()
},
leadingIcon = {
Icon(Icons.Outlined.Compare, contentDescription = null)
},
)
},
onClick = {
showOptionsMenu = false
viewModel.toggleComparison()
},
leadingIcon = {
Icon(Icons.Outlined.Compare, contentDescription = null)
},
)
}
// Conversation-specific actions
if (uiState.conversationId != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
@ -481,7 +485,7 @@ actual fun ChatScreen(
onStartRecording = viewModel::startRecording,
onStopRecording = viewModel::stopRecording,
onOpenModelParameters = viewModel::showModelParameters,
onOpenModelSelector = { showModelSheet = true },
onOpenModelSelector = viewModel::openModelSheet,
selectedModelDisplay = displayModel,
isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable,
attachedFiles = attachedFiles,
@ -516,13 +520,17 @@ actual fun ChatScreen(
}
}
},
webSearchEnabled = uiState.webSearchEnabled,
runCodeEnabled = uiState.runCodeEnabled,
fileSearchEnabled = uiState.fileSearchEnabled,
mcpServersEnabled = uiState.mcpServersEnabled,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
// Model selector bottom sheet
if (showModelSheet) {
if (uiState.showModelSheet) {
ModelSelectorSheet(
endpointConfigs = uiState.endpointConfigs,
availableModels = uiState.availableModels,
@ -531,10 +539,26 @@ actual fun ChatScreen(
selectedModel = uiState.selectedModel,
onModelSelect = { endpoint, model ->
viewModel.onModelSelected(endpoint, model)
showModelSheet = false
// Clear any pending scaffold-level snackbar for the same error so it
// doesn't flash behind the sheet's close animation. Harmless no-op when
// error is already null.
viewModel.dismissError()
viewModel.dismissSendBlockReason()
viewModel.dismissModelSheet()
},
onDismiss = {
viewModel.dismissError()
viewModel.dismissSendBlockReason()
viewModel.dismissModelSheet()
},
onDismiss = { showModelSheet = false },
serverUrl = uiState.serverUrl,
// Send-block reasons take precedence: when set, the sheet was auto-opened
// to help the user resolve the block, so surface that context inline.
errorMessage = sendBlockMessage ?: uiState.error,
onErrorDismiss = {
viewModel.dismissSendBlockReason()
viewModel.dismissError()
},
)
}

View file

@ -6,6 +6,7 @@ import com.garfiec.librechat.core.data.datastore.ServerDataStore
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.MessageRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.TagRepository
@ -25,6 +26,7 @@ class ConversationsModuleVerificationTest {
ShareRepository::class,
SearchRepository::class,
ConfigRepository::class,
RoleRepository::class,
ServerDataStore::class,
),
)

View file

@ -2,6 +2,7 @@ package com.garfiec.librechat.feature.conversations.viewmodel
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.TagRepository
@ -17,6 +18,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
@ -39,6 +41,7 @@ class ConversationListViewModelTest {
private val shareRepository = mockk<ShareRepository>(relaxed = true)
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
private val roleRepository = mockk<RoleRepository>(relaxed = true)
private lateinit var viewModel: ConversationListViewModel
@ -73,6 +76,7 @@ class ConversationListViewModelTest {
coEvery { conversationRepository.syncFavoritesFromServer() } returns Result.Success(Unit)
every { tagRepository.observeTags() } returns flowOf(testTags)
coEvery { tagRepository.refreshTags() } returns Result.Success(Unit)
every { roleRepository.userPermissions } returns MutableStateFlow(null)
}
@After
@ -87,6 +91,7 @@ class ConversationListViewModelTest {
shareRepository = shareRepository,
conversationExporter = conversationExporter,
conversationImporter = conversationImporter,
roleRepository = roleRepository,
)
@Test

View file

@ -57,6 +57,7 @@ fun ConversationActions(
onBookmarkToggle: () -> Unit = {},
isBookmarked: Boolean = false,
showShareAction: Boolean = false,
bookmarksEnabled: Boolean = true,
) {
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
@ -88,15 +89,17 @@ fun ConversationActions(
},
)
ActionRow(
icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark),
iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
onClick = {
onDismiss()
onBookmarkToggle()
},
)
if (bookmarksEnabled) {
ActionRow(
icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark),
iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
onClick = {
onDismiss()
onBookmarkToggle()
},
)
}
ActionRow(
icon = Icons.Default.BookmarkBorder,

View file

@ -41,6 +41,7 @@ fun ConversationItem(
onClick: () -> Unit,
onActionsClick: () -> Unit,
modifier: Modifier = Modifier,
bookmarksEnabled: Boolean = true,
) {
val relativeTime = remember(data.updatedAt) {
data.updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: ""
@ -134,7 +135,7 @@ fun ConversationItem(
}
}
if (data.isBookmarked) {
if (bookmarksEnabled && data.isBookmarked) {
Icon(
imageVector = Icons.Default.Bookmark,
contentDescription = stringResource(Res.string.cd_bookmarked),

View file

@ -292,6 +292,7 @@ fun ConversationListScreen(
displayData.conversationId,
)
},
bookmarksEnabled = uiState.bookmarksEnabled,
)
HorizontalDivider(
modifier = Modifier.padding(start = 52.dp),
@ -328,6 +329,7 @@ fun ConversationListScreen(
conversation = selectedConversation!!,
onDismiss = { selectedConversation = null },
isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()),
bookmarksEnabled = uiState.bookmarksEnabled,
onBookmarkToggle = {
selectedConversation?.let { convo ->
viewModel.toggleFavorite(convo)

View file

@ -8,12 +8,16 @@ import com.garfiec.librechat.core.common.extensions.toInstantOrNull
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.SearchRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.model.Conversation
import com.garfiec.librechat.core.model.ConversationTag
import com.garfiec.librechat.core.model.SAVED_TAG
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive
import com.garfiec.librechat.feature.conversations.components.ConversationDisplayData
import com.garfiec.librechat.feature.conversations.components.toDisplayData
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
@ -43,6 +47,8 @@ data class ConversationListUiState(
val selectedTags: Set<String> = emptySet(),
val searchQuery: String = "",
val isSearching: Boolean = false,
// Role-permission gate — default permissive until role loads.
val bookmarksEnabled: Boolean = true,
)
sealed interface ConversationListEvent {
@ -60,6 +66,7 @@ class ConversationListViewModel(
private val shareRepository: ShareRepository,
private val conversationExporter: ConversationExporter,
private val conversationImporter: ConversationImporter,
private val roleRepository: RoleRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationListUiState())
@ -76,6 +83,20 @@ class ConversationListViewModel(
loadConversations()
observeConversations()
observeTags()
observePermissions()
}
private fun observePermissions() {
viewModelScope.launch {
roleRepository.userPermissions.collect { role ->
_uiState.value = _uiState.value.copy(
bookmarksEnabled = role.hasAccessOrPermissive(
PermissionType.BOOKMARKS,
Permission.USE,
),
)
}
}
}
private fun observeConversations() {

View file

@ -13,9 +13,11 @@ import com.garfiec.librechat.core.data.repository.KeyRepository
import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.repository.MemoryRepository
import com.garfiec.librechat.core.data.repository.PresetRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.SpeechRepository
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.feature.settings.util.ContentReader
import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner
import com.garfiec.librechat.feature.settings.viewmodel.delegate.SpeechSettingsFactory
@ -34,6 +36,8 @@ class SettingsModuleVerificationTest {
ConversationRepository::class,
McpRepository::class,
MemoryRepository::class,
RoleRepository::class,
PermissionGate::class,
SpeechRepository::class,
BalanceRepository::class,
ShareRepository::class,

View file

@ -14,9 +14,11 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.KeyRepository
import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.repository.MemoryRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.SpeechRepository
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.feature.settings.util.ContentReader
import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner
@ -59,6 +61,8 @@ class SettingsViewModelTest {
private val balanceRepository = mockk<BalanceRepository>(relaxed = true)
private val shareRepository = mockk<ShareRepository>(relaxed = true)
private val keyRepository = mockk<KeyRepository>(relaxed = true)
private val roleRepository = mockk<RoleRepository>(relaxed = true)
private val permissionGate = mockk<PermissionGate>(relaxed = true)
private val testUser = User(
email = "test@example.com",
@ -107,6 +111,11 @@ class SettingsViewModelTest {
coEvery { memoryRepository.getMemories() } returns Result.Success(emptyList())
coEvery { speechRepository.getVoices() } returns Result.Success(emptyList())
coEvery { balanceRepository.getBalance() } returns Result.Error(message = "Not available")
// Permissive-null defaults so existing tests continue to exercise the
// same load paths via the `?: != false` idiom.
every { roleRepository.userPermissions } returns MutableStateFlow(null)
coEvery { permissionGate.awaitRole() } returns null
}
@After
@ -129,6 +138,8 @@ class SettingsViewModelTest {
balanceRepository = balanceRepository,
shareRepository = shareRepository,
keyRepository = keyRepository,
roleRepository = roleRepository,
permissionGate = permissionGate,
)
@Test

View file

@ -146,26 +146,31 @@ fun DataSettingsContent(
)
}
// Memories section
item(key = "memories_header") {
SectionHeader(stringResource(Res.string.section_memories))
}
item(key = "memories_settings") {
MemoriesSettingsSection(
memories = uiState.memories,
memoriesEnabled = uiState.memoriesEnabled,
showMemoryDialog = uiState.showMemoryDialog,
editingMemory = uiState.editingMemory,
onToggleEnable = viewModel::toggleMemoriesEnabled,
onAddMemory = viewModel::showAddMemoryDialog,
onEditMemory = viewModel::showEditMemoryDialog,
onDeleteMemory = viewModel::deleteMemory,
onDismissDialog = viewModel::dismissMemoryDialog,
onSaveMemory = viewModel::saveMemory,
)
// Memories section — hidden entirely when the server's MEMORIES.USE role
// permission is denied. The user-level opt-out (`memoriesEnabled`) stays
// independent and only hides the list inside this section.
if (uiState.serverMemoriesEnabled) {
item(key = "memories_header") {
SectionHeader(stringResource(Res.string.section_memories))
}
item(key = "memories_settings") {
MemoriesSettingsSection(
memories = uiState.memories,
memoriesEnabled = uiState.memoriesEnabled,
showMemoryDialog = uiState.showMemoryDialog,
editingMemory = uiState.editingMemory,
onToggleEnable = viewModel::toggleMemoriesEnabled,
onAddMemory = viewModel::showAddMemoryDialog,
onEditMemory = viewModel::showEditMemoryDialog,
onDeleteMemory = viewModel::deleteMemory,
onDismissDialog = viewModel::dismissMemoryDialog,
onSaveMemory = viewModel::saveMemory,
)
}
}
// MCP section
// MCP section — always shown, but the section body degrades to
// "not available" and the "+ Add" button disappears when role denies.
item(key = "mcp_header") {
SectionHeader(stringResource(Res.string.section_mcp_servers))
}
@ -175,6 +180,8 @@ fun DataSettingsContent(
connectionStatus = uiState.mcpConnectionStatus,
reinitializingServers = uiState.mcpReinitializingServers,
error = uiState.mcpError,
mcpServersEnabled = uiState.mcpServersEnabled,
mcpServersCreateEnabled = uiState.mcpServersCreateEnabled,
onAddServer = viewModel::showAddMcpServerDialog,
onEditServer = viewModel::showEditMcpServerDialog,
onDeleteServer = viewModel::deleteMcpServer,

View file

@ -44,6 +44,8 @@ internal fun McpSettingsSection(
modifier: Modifier = Modifier,
reinitializingServers: Set<String> = emptySet(),
error: String? = null,
mcpServersEnabled: Boolean = true,
mcpServersCreateEnabled: Boolean = true,
) {
Column(modifier = modifier) {
Column(
@ -52,7 +54,13 @@ internal fun McpSettingsSection(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (error != null) {
if (!mcpServersEnabled) {
Text(
text = stringResource(Res.string.mcp_not_available),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (error != null) {
Text(
text = stringResource(Res.string.mcp_not_available),
style = MaterialTheme.typography.bodyMedium,
@ -77,11 +85,14 @@ internal fun McpSettingsSection(
}
}
if (error == null) {
if (mcpServersEnabled && error == null) {
Spacer(modifier = Modifier.height(4.dp))
}
if (error == null) {
// "+ Add MCP Server" stays behind both gates:
// USE must be allowed (otherwise the whole section is degraded) and
// CREATE must be allowed (separate sub-action).
if (mcpServersEnabled && error == null && mcpServersCreateEnabled) {
OutlinedButton(
onClick = onAddServer,
modifier = Modifier.fillMaxWidth(),

View file

@ -18,8 +18,10 @@ import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.KeyRepository
import com.garfiec.librechat.core.data.repository.McpRepository
import com.garfiec.librechat.core.data.repository.MemoryRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.ShareRepository
import com.garfiec.librechat.core.data.repository.UserRepository
import com.garfiec.librechat.core.data.util.PermissionGate
import com.garfiec.librechat.core.model.Memory
import com.garfiec.librechat.core.model.User
import com.garfiec.librechat.core.model.mcp.McpApiKeyConfig
@ -27,6 +29,9 @@ import com.garfiec.librechat.core.model.mcp.McpOAuthConfig
import com.garfiec.librechat.core.model.mcp.McpServer
import com.garfiec.librechat.core.model.mcp.McpServerStatus
import com.garfiec.librechat.core.model.mcp.McpServerType
import com.garfiec.librechat.core.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive
import com.garfiec.librechat.core.model.speech.TtsVoice
import com.garfiec.librechat.feature.settings.model.SharedLinkDisplayData
import com.garfiec.librechat.feature.settings.model.UserDisplayData
@ -159,6 +164,14 @@ data class SettingsUiState(
val showAvatars: Boolean = true,
val showBubbles: Boolean = false,
val latexRenderer: LatexRenderer = LatexRenderer.KATEX,
// Role-permission gates. `serverMemoriesEnabled` is the SERVER-level MEMORIES.USE
// gate and is orthogonal to [memoriesEnabled], which is the user's own opt-out
// stored on their profile (`user.personalization.memories`).
val mcpServersEnabled: Boolean = true,
val mcpServersCreateEnabled: Boolean = true,
val serverMemoriesEnabled: Boolean = true,
val remoteAgentsEnabled: Boolean = true,
val remoteAgentsCreateEnabled: Boolean = true,
)
private fun User.toDisplayData() = UserDisplayData(
@ -194,6 +207,8 @@ class SettingsViewModel(
private val balanceRepository: BalanceRepository,
shareRepository: ShareRepository,
keyRepository: KeyRepository,
private val roleRepository: RoleRepository,
private val permissionGate: PermissionGate,
) : ViewModel() {
/** Raw state for everything not driven by DataStore flows. */
@ -367,11 +382,48 @@ class SettingsViewModel(
init {
loadUser()
mcpDelegate.loadMcpServers()
memoryDelegate.loadMemories()
speechDelegate.loadVoices()
loadBalance()
speechDelegate.loadDeviceVoices()
loadRoleGatedData()
observePermissionFlags()
}
/**
* Gated loads share a single 5-second role-await budget so offline/timeout launches
* don't serialize into N×5s. `role?.hasAccess(...) != false` preserves permissive
* default: null role true, missing type/action true.
*/
private fun loadRoleGatedData() {
viewModelScope.launch {
val role = permissionGate.awaitRole()
if (role?.hasAccess(PermissionType.MCP_SERVERS, Permission.USE) != false) {
mcpDelegate.loadMcpServers()
}
if (role?.hasAccess(PermissionType.MEMORIES, Permission.USE) != false) {
memoryDelegate.loadMemories()
}
}
}
/**
* Continuous collector the 5 role-driven flags on SettingsUiState stay in sync
* with the current role. Permissive while role is null.
*/
private fun observePermissionFlags() {
viewModelScope.launch {
roleRepository.userPermissions.collect { role ->
_uiState.update {
it.copy(
mcpServersEnabled = role.hasAccessOrPermissive(PermissionType.MCP_SERVERS, Permission.USE),
mcpServersCreateEnabled = role.hasAccessOrPermissive(PermissionType.MCP_SERVERS, Permission.CREATE),
serverMemoriesEnabled = role.hasAccessOrPermissive(PermissionType.MEMORIES, Permission.USE),
remoteAgentsEnabled = role.hasAccessOrPermissive(PermissionType.REMOTE_AGENTS, Permission.USE),
remoteAgentsCreateEnabled = role.hasAccessOrPermissive(PermissionType.REMOTE_AGENTS, Permission.CREATE),
)
}
}
}
}
// ── Account & user profile ─────────────────────────────────────

View file

@ -219,8 +219,9 @@ fun DrawerContent(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
// Favorites section
if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) {
// Favorites section — hidden entirely when BOOKMARKS.USE is denied so
// any locally-cached favorites from a prior permissive session don't leak.
if (uiState.bookmarksEnabled && uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) {
item(key = "favorites_header") {
Row(
modifier = Modifier
@ -258,6 +259,7 @@ fun DrawerContent(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data) },
showBookmarkToggle = uiState.bookmarksEnabled,
)
}
@ -304,6 +306,7 @@ fun DrawerContent(
data = data,
onClick = { onConversationClick(data.conversationId) },
onToggleFavorite = { onToggleFavorite(data) },
showBookmarkToggle = uiState.bookmarksEnabled,
)
}
}
@ -332,11 +335,13 @@ fun DrawerContent(
color = MaterialTheme.colorScheme.outlineVariant,
)
DrawerFooterItem(
icon = Icons.Default.SmartToy,
label = stringResource(Res.string.agents),
onClick = onAgentsClick,
)
if (uiState.agentsEnabled) {
DrawerFooterItem(
icon = Icons.Default.SmartToy,
label = stringResource(Res.string.agents),
onClick = onAgentsClick,
)
}
DrawerFooterItem(
icon = Icons.Default.Folder,
label = stringResource(Res.string.files),
@ -358,6 +363,7 @@ private fun DrawerConversationItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
onToggleFavorite: () -> Unit = {},
showBookmarkToggle: Boolean = true,
) {
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
@ -455,23 +461,25 @@ private fun DrawerConversationItem(
}
}
Icon(
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (data.isFavorite) {
stringResource(Res.string.remove_bookmark)
} else {
stringResource(Res.string.bookmark)
},
modifier = Modifier
.size(32.dp)
.clickable(onClick = onToggleFavorite)
.padding(8.dp),
tint = if (data.isFavorite) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
if (showBookmarkToggle) {
Icon(
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (data.isFavorite) {
stringResource(Res.string.remove_bookmark)
} else {
stringResource(Res.string.bookmark)
},
modifier = Modifier
.size(32.dp)
.clickable(onClick = onToggleFavorite)
.padding(8.dp),
tint = if (data.isFavorite) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}

View file

@ -30,4 +30,7 @@ data class DrawerUiState(
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = true,
// Role-permission gates — default permissive.
val agentsEnabled: Boolean = true,
val bookmarksEnabled: Boolean = true,
)

View file

@ -10,10 +10,15 @@ import com.garfiec.librechat.core.data.repository.AuthRepository
import com.garfiec.librechat.core.data.repository.BannerRepository
import com.garfiec.librechat.core.data.repository.ConfigRepository
import com.garfiec.librechat.core.data.repository.ConversationRepository
import com.garfiec.librechat.core.data.repository.RoleRepository
import com.garfiec.librechat.core.data.repository.TagRepository
import com.garfiec.librechat.core.data.util.SessionTaskRunner
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.model.permissions.Permission
import com.garfiec.librechat.core.model.permissions.PermissionType
import com.garfiec.librechat.core.model.permissions.hasAccessOrPermissive
import com.garfiec.librechat.core.network.client.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -30,6 +35,8 @@ class NavHostViewModel(
bannerRepository: BannerRepository,
configRepository: ConfigRepository,
private val conversationRepository: ConversationRepository,
private val roleRepository: RoleRepository,
private val sessionTaskRunner: SessionTaskRunner,
private val tagRepository: TagRepository,
private val tokenManager: TokenManager,
private val settingsDataStore: SettingsDataStore,
@ -78,6 +85,21 @@ class NavHostViewModel(
_selectedSettingsCategory.value = category
}
/**
* Role-permission flags for drawer UI. Permissive-default (`?: true`) until the
* role loads; once it does, denied permissions flip the flags off and the drawer
* re-composes to hide the corresponding surfaces.
*/
private val drawerPermissionFlags: StateFlow<DrawerPermissionFlags> =
roleRepository.userPermissions
.map { role ->
DrawerPermissionFlags(
agentsEnabled = role.hasAccessOrPermissive(PermissionType.AGENTS, Permission.USE),
bookmarksEnabled = role.hasAccessOrPermissive(PermissionType.BOOKMARKS, Permission.USE),
)
}
.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerPermissionFlags())
val drawerUiState: StateFlow<DrawerUiState> = combine(
combine(
conversationListStateHolder.groupedConversations,
@ -94,7 +116,8 @@ class NavHostViewModel(
) { refreshing, loadingMore, hasMore ->
Triple(refreshing, loadingMore, hasMore)
},
) { data, (refreshing, loadingMore, hasMore) ->
drawerPermissionFlags,
) { data, (refreshing, loadingMore, hasMore), perms ->
DrawerUiState(
groupedConversations = data.grouped.map { (group, convos) ->
group to convos.map { it.toDrawerDisplayData(data.activeId) }
@ -104,9 +127,16 @@ class NavHostViewModel(
isRefreshing = refreshing,
isLoadingMore = loadingMore,
hasMore = hasMore,
agentsEnabled = perms.agentsEnabled,
bookmarksEnabled = perms.bookmarksEnabled,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState())
private data class DrawerPermissionFlags(
val agentsEnabled: Boolean = true,
val bookmarksEnabled: Boolean = true,
)
init {
viewModelScope.launch {
try {
@ -114,8 +144,10 @@ class NavHostViewModel(
_isLoggedIn.value = loggedIn
if (loggedIn) {
versionCheckStateHolder.checkBackendVersion()
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
// Session tasks for the cold-start case (role fetch, tag refresh,
// favorites sync). Runs on the application scope so tasks outlive
// this VM's scope. Fresh logins fire these from AuthRepositoryImpl.
sessionTaskRunner.runAll()
}
} catch (e: Exception) {
Logger.w(e) { "Failed to check auth state on init" }
@ -131,10 +163,9 @@ class NavHostViewModel(
fun onAuthComplete() {
_isLoggedIn.value = true
conversationListStateHolder.refreshConversations()
viewModelScope.launch {
launch { tagRepository.refreshTags() }
launch { conversationRepository.syncFavoritesFromServer() }
}
// Session tasks (role fetch, tag refresh, favorites sync) already fired from
// AuthRepositoryImpl on the preceding login/OAuth/2FA success, so we don't
// re-run them here — that was the source of the double-fetch on fresh login.
bannerStateHolder.fetchBanners()
versionCheckStateHolder.checkBackendVersion()
}

View file

@ -19,6 +19,7 @@ import com.garfiec.librechat.core.network.api.MemoriesApi
import com.garfiec.librechat.core.network.api.MessagesApi
import com.garfiec.librechat.core.network.api.PresetsApi
import com.garfiec.librechat.core.network.api.PromptsApi
import com.garfiec.librechat.core.network.api.RolesApi
import com.garfiec.librechat.core.network.api.SearchApi
import com.garfiec.librechat.core.network.api.ShareApi
import com.garfiec.librechat.core.network.api.SpeechApi
@ -115,6 +116,7 @@ val iosSharedModule = module {
singleOf(::AgentsApi)
singleOf(::PresetsApi)
singleOf(::PromptsApi)
singleOf(::RolesApi)
singleOf(::TagsApi)
singleOf(::ShareApi)
singleOf(::SearchApi)