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:
commit
89a0dd7bd5
61 changed files with 1714 additions and 193 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.garfiec.librechat.core.model.permissions
|
||||
|
||||
enum class Permission(val serverKey: String) {
|
||||
USE("USE"),
|
||||
CREATE("CREATE"),
|
||||
SHARE("SHARE"),
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue