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.MessageRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PresetRepository
|
import com.garfiec.librechat.core.data.repository.PresetRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepository
|
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.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
import com.garfiec.librechat.core.data.repository.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
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.AgentsApi
|
||||||
import com.garfiec.librechat.core.network.api.ApiKeysApi
|
import com.garfiec.librechat.core.network.api.ApiKeysApi
|
||||||
import com.garfiec.librechat.core.network.api.AuthApi
|
import com.garfiec.librechat.core.network.api.AuthApi
|
||||||
|
|
@ -155,11 +159,15 @@ class KoinGraphVerificationTest {
|
||||||
MessageRepository::class,
|
MessageRepository::class,
|
||||||
PresetRepository::class,
|
PresetRepository::class,
|
||||||
PromptRepository::class,
|
PromptRepository::class,
|
||||||
|
RoleRepository::class,
|
||||||
SearchRepository::class,
|
SearchRepository::class,
|
||||||
ShareRepository::class,
|
ShareRepository::class,
|
||||||
SpeechRepository::class,
|
SpeechRepository::class,
|
||||||
TagRepository::class,
|
TagRepository::class,
|
||||||
UserRepository::class,
|
UserRepository::class,
|
||||||
|
PermissionGate::class,
|
||||||
|
SessionTask::class,
|
||||||
|
SessionTaskRunner::class,
|
||||||
// feature:auth platform provides
|
// feature:auth platform provides
|
||||||
OAuthLauncher::class,
|
OAuthLauncher::class,
|
||||||
// feature:files platform provides
|
// 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.datastore.TokenDataStore
|
||||||
import com.garfiec.librechat.core.data.db.LibreChatDatabase
|
import com.garfiec.librechat.core.data.db.LibreChatDatabase
|
||||||
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
|
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.data.repository.SessionCacheCleaner
|
||||||
import com.garfiec.librechat.core.network.client.SecureTokenStorage
|
import com.garfiec.librechat.core.network.client.SecureTokenStorage
|
||||||
import com.garfiec.librechat.core.network.client.TokenManager
|
import com.garfiec.librechat.core.network.client.TokenManager
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.dsl.binds
|
import org.koin.dsl.binds
|
||||||
|
|
@ -41,5 +43,11 @@ actual val dataPlatformModule: Module = module {
|
||||||
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
|
} binds arrayOf(TokenManager::class, SecureTokenStorage::class)
|
||||||
|
|
||||||
// --- Session Cache Cleaner ---
|
// --- 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.MessagesApi
|
||||||
import com.garfiec.librechat.core.network.api.PresetsApi
|
import com.garfiec.librechat.core.network.api.PresetsApi
|
||||||
import com.garfiec.librechat.core.network.api.PromptsApi
|
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.SearchApi
|
||||||
import com.garfiec.librechat.core.network.api.ShareApi
|
import com.garfiec.librechat.core.network.api.ShareApi
|
||||||
import com.garfiec.librechat.core.network.api.SpeechApi
|
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 com.garfiec.librechat.core.network.sse.SseClient
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.koin.test.verify.verify
|
import org.koin.test.verify.verify
|
||||||
|
|
@ -43,6 +45,7 @@ class DataModuleVerificationTest {
|
||||||
HttpClient::class,
|
HttpClient::class,
|
||||||
Json::class,
|
Json::class,
|
||||||
CoroutineDispatcher::class,
|
CoroutineDispatcher::class,
|
||||||
|
CoroutineScope::class,
|
||||||
ConnectivityObserver::class,
|
ConnectivityObserver::class,
|
||||||
SseClient::class,
|
SseClient::class,
|
||||||
AuthApi::class,
|
AuthApi::class,
|
||||||
|
|
@ -55,6 +58,7 @@ class DataModuleVerificationTest {
|
||||||
AgentsApi::class,
|
AgentsApi::class,
|
||||||
PresetsApi::class,
|
PresetsApi::class,
|
||||||
PromptsApi::class,
|
PromptsApi::class,
|
||||||
|
RolesApi::class,
|
||||||
TagsApi::class,
|
TagsApi::class,
|
||||||
ShareApi::class,
|
ShareApi::class,
|
||||||
ConfigApi::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.common.di.KoinQualifiers
|
||||||
import com.garfiec.librechat.core.data.datastore.ConfigCacheDataStore
|
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.ServerDataStore
|
||||||
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
|
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
|
||||||
import com.garfiec.librechat.core.data.datastore.ThemeDataStore
|
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.PresetRepositoryImpl
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepository
|
import com.garfiec.librechat.core.data.repository.PromptRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepositoryImpl
|
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.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SearchRepositoryImpl
|
import com.garfiec.librechat.core.data.repository.SearchRepositoryImpl
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
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.TagRepositoryImpl
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
import com.garfiec.librechat.core.data.repository.UserRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepositoryImpl
|
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 com.garfiec.librechat.core.network.client.ServerUrlProvider
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
|
|
@ -81,6 +91,7 @@ val dataModule = module {
|
||||||
} bind ServerUrlProvider::class
|
} bind ServerUrlProvider::class
|
||||||
singleOf(::ThemeDataStore)
|
singleOf(::ThemeDataStore)
|
||||||
singleOf(::ConfigCacheDataStore)
|
singleOf(::ConfigCacheDataStore)
|
||||||
|
singleOf(::RoleCacheDataStore)
|
||||||
singleOf(::SettingsDataStore)
|
singleOf(::SettingsDataStore)
|
||||||
|
|
||||||
// --- Repositories (special wiring) ---
|
// --- Repositories (special wiring) ---
|
||||||
|
|
@ -91,6 +102,33 @@ val dataModule = module {
|
||||||
userApi = get(),
|
userApi = get(),
|
||||||
tokenManager = get(),
|
tokenManager = get(),
|
||||||
sessionCacheCleaner = 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.Result
|
||||||
import com.garfiec.librechat.core.common.result.safeApiCall
|
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.LoginOutcome
|
||||||
import com.garfiec.librechat.core.model.User
|
import com.garfiec.librechat.core.model.User
|
||||||
import com.garfiec.librechat.core.model.response.TwoFactorSetupResponse
|
import com.garfiec.librechat.core.model.response.TwoFactorSetupResponse
|
||||||
|
|
@ -14,8 +15,18 @@ class AuthRepositoryImpl(
|
||||||
private val userApi: UserApi,
|
private val userApi: UserApi,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val sessionCacheCleaner: SessionCacheCleaner,
|
private val sessionCacheCleaner: SessionCacheCleaner,
|
||||||
|
private val sessionTaskRunner: SessionTaskRunner,
|
||||||
) : AuthRepository {
|
) : 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> {
|
override suspend fun login(email: String, password: String): Result<LoginOutcome> {
|
||||||
return safeApiCall {
|
return safeApiCall {
|
||||||
val result = authApi.login(email, password)
|
val result = authApi.login(email, password)
|
||||||
|
|
@ -34,7 +45,7 @@ class AuthRepositoryImpl(
|
||||||
?: throw IllegalStateException("Login succeeded but user was null in response"),
|
?: throw IllegalStateException("Login succeeded but user was null in response"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}.fireSessionTasksIfLoggedIn { outcome -> outcome is LoginOutcome.Success }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loginWithOAuthToken(refreshToken: String): Result<User> {
|
override suspend fun loginWithOAuthToken(refreshToken: String): Result<User> {
|
||||||
|
|
@ -50,7 +61,7 @@ class AuthRepositoryImpl(
|
||||||
)
|
)
|
||||||
// Fetch the user profile
|
// Fetch the user profile
|
||||||
userApi.getUser()
|
userApi.getUser()
|
||||||
}
|
}.fireSessionTasksIfLoggedIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun verifyTwoFactor(tempToken: String, code: String): Result<User> {
|
override suspend fun verifyTwoFactor(tempToken: String, code: String): Result<User> {
|
||||||
|
|
@ -61,7 +72,7 @@ class AuthRepositoryImpl(
|
||||||
refreshToken = result.refreshToken ?: "",
|
refreshToken = result.refreshToken ?: "",
|
||||||
)
|
)
|
||||||
result.response.user ?: throw IllegalStateException("No user in 2FA response")
|
result.response.user ?: throw IllegalStateException("No user in 2FA response")
|
||||||
}
|
}.fireSessionTasksIfLoggedIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun register(
|
override suspend fun register(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.garfiec.librechat.core.data.repository
|
package com.garfiec.librechat.core.data.repository
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subdirectories under the platform cache root that are cleared on logout.
|
* 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]
|
* 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(
|
class CommonSessionCacheCleaner(
|
||||||
private val cacheRoot: String,
|
private val cacheRoot: String,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
|
private val applicationScope: CoroutineScope,
|
||||||
) : SessionCacheCleaner {
|
) : SessionCacheCleaner {
|
||||||
override fun clearSessionCaches() {
|
override fun clearSessionCaches() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -37,5 +42,8 @@ class CommonSessionCacheCleaner(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w(e) { "Failed to clear session caches on logout" }
|
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.datastore.ServerUrlKeychainFallback
|
||||||
import com.garfiec.librechat.core.data.db.LibreChatDatabase
|
import com.garfiec.librechat.core.data.db.LibreChatDatabase
|
||||||
import com.garfiec.librechat.core.data.repository.CommonSessionCacheCleaner
|
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.data.repository.SessionCacheCleaner
|
||||||
import com.garfiec.librechat.core.network.client.SecureTokenStorage
|
import com.garfiec.librechat.core.network.client.SecureTokenStorage
|
||||||
import com.garfiec.librechat.core.network.client.TokenManager
|
import com.garfiec.librechat.core.network.client.TokenManager
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
|
|
@ -69,6 +71,10 @@ actual val dataPlatformModule: Module = module {
|
||||||
NSUserDomainMask,
|
NSUserDomainMask,
|
||||||
true,
|
true,
|
||||||
).firstOrNull() as? String ?: error("Unable to resolve NSCachesDirectory")
|
).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.MessagesApi
|
||||||
import com.garfiec.librechat.core.network.api.PresetsApi
|
import com.garfiec.librechat.core.network.api.PresetsApi
|
||||||
import com.garfiec.librechat.core.network.api.PromptsApi
|
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.SearchApi
|
||||||
import com.garfiec.librechat.core.network.api.ShareApi
|
import com.garfiec.librechat.core.network.api.ShareApi
|
||||||
import com.garfiec.librechat.core.network.api.SpeechApi
|
import com.garfiec.librechat.core.network.api.SpeechApi
|
||||||
|
|
@ -131,6 +132,7 @@ val networkModule = module {
|
||||||
singleOf(::MessagesApi)
|
singleOf(::MessagesApi)
|
||||||
singleOf(::PresetsApi)
|
singleOf(::PresetsApi)
|
||||||
singleOf(::PromptsApi)
|
singleOf(::PromptsApi)
|
||||||
|
singleOf(::RolesApi)
|
||||||
singleOf(::SearchApi)
|
singleOf(::SearchApi)
|
||||||
singleOf(::ShareApi)
|
singleOf(::ShareApi)
|
||||||
singleOf(::SpeechApi)
|
singleOf(::SpeechApi)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ fun ErrorBanner(
|
||||||
message: String,
|
message: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onRetry: (() -> Unit)? = null,
|
onRetry: (() -> Unit)? = null,
|
||||||
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
retryLabel: String = "Retry",
|
||||||
|
dismissLabel: String = "Dismiss",
|
||||||
|
errorContentDescription: String = "Error",
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -42,7 +46,7 @@ fun ErrorBanner(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Warning,
|
imageVector = Icons.Default.Warning,
|
||||||
contentDescription = "Error",
|
contentDescription = errorContentDescription,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
@ -54,7 +58,12 @@ fun ErrorBanner(
|
||||||
)
|
)
|
||||||
if (onRetry != null) {
|
if (onRetry != null) {
|
||||||
TextButton(onClick = onRetry) {
|
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.AgentRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.McpRepository
|
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.junit.Test
|
||||||
import org.koin.test.verify.verify
|
import org.koin.test.verify.verify
|
||||||
|
|
||||||
|
|
@ -19,6 +21,8 @@ class AgentsModuleVerificationTest {
|
||||||
AgentRepository::class,
|
AgentRepository::class,
|
||||||
ConfigRepository::class,
|
ConfigRepository::class,
|
||||||
McpRepository::class,
|
McpRepository::class,
|
||||||
|
RoleRepository::class,
|
||||||
|
PermissionGate::class,
|
||||||
ServerDataStore::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.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
||||||
import com.garfiec.librechat.core.data.repository.AgentRepository
|
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.Agent
|
||||||
import com.garfiec.librechat.core.model.AgentCategory
|
import com.garfiec.librechat.core.model.AgentCategory
|
||||||
import com.garfiec.librechat.core.model.PaginatedAgents
|
import com.garfiec.librechat.core.model.PaginatedAgents
|
||||||
|
|
@ -13,6 +15,7 @@ import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
|
@ -29,6 +32,8 @@ class AgentMarketplaceViewModelTest {
|
||||||
|
|
||||||
private val agentRepository = mockk<AgentRepository>(relaxed = true)
|
private val agentRepository = mockk<AgentRepository>(relaxed = true)
|
||||||
private val serverDataStore = mockk<ServerDataStore>(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
|
private lateinit var viewModel: AgentMarketplaceViewModel
|
||||||
|
|
||||||
|
|
@ -47,6 +52,12 @@ class AgentMarketplaceViewModelTest {
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
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"
|
every { serverDataStore.getBaseUrl() } returns "https://chat.example.com"
|
||||||
coEvery {
|
coEvery {
|
||||||
agentRepository.getAgentsPaginated(
|
agentRepository.getAgentsPaginated(
|
||||||
|
|
@ -67,6 +78,8 @@ class AgentMarketplaceViewModelTest {
|
||||||
private fun createViewModel() = AgentMarketplaceViewModel(
|
private fun createViewModel() = AgentMarketplaceViewModel(
|
||||||
agentRepository = agentRepository,
|
agentRepository = agentRepository,
|
||||||
serverDataStore = serverDataStore,
|
serverDataStore = serverDataStore,
|
||||||
|
roleRepository = roleRepository,
|
||||||
|
permissionGate = permissionGate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@
|
||||||
<string name="search_agents_hint">Search agents\u2026</string>
|
<string name="search_agents_hint">Search agents\u2026</string>
|
||||||
<string name="no_agents_found">No agents found</string>
|
<string name="no_agents_found">No agents found</string>
|
||||||
<string name="no_agents_available">No agents available</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="try_different_search">Try a different search term</string>
|
||||||
<string name="check_back_later">Check back later for new agents</string>
|
<string name="check_back_later">Check back later for new agents</string>
|
||||||
<string name="error_unknown">An unknown error occurred</string>
|
<string name="error_unknown">An unknown error occurred</string>
|
||||||
|
|
|
||||||
|
|
@ -97,13 +97,15 @@ fun AgentMarketplaceScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
if (uiState.agentsEnabled && uiState.agentsCreateEnabled) {
|
||||||
onClick = onCreateAgent,
|
FloatingActionButton(
|
||||||
) {
|
onClick = onCreateAgent,
|
||||||
Icon(
|
) {
|
||||||
imageVector = Icons.Default.Add,
|
Icon(
|
||||||
contentDescription = stringResource(Res.string.cd_create_agent),
|
imageVector = Icons.Default.Add,
|
||||||
)
|
contentDescription = stringResource(Res.string.cd_create_agent),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
|
@ -114,6 +116,17 @@ fun AgentMarketplaceScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.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()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Search bar
|
// Search bar
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
import com.garfiec.librechat.core.data.datastore.ServerDataStore
|
||||||
import com.garfiec.librechat.core.data.repository.AgentRepository
|
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.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 com.garfiec.librechat.feature.agents.AgentCardDisplayData
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -28,11 +33,17 @@ data class AgentMarketplaceUiState(
|
||||||
val categories: List<String> = emptyList(),
|
val categories: List<String> = emptyList(),
|
||||||
val hasMore: Boolean = true,
|
val hasMore: Boolean = true,
|
||||||
val currentPage: Int = 1,
|
val currentPage: Int = 1,
|
||||||
|
// Role-permission gates — default permissive.
|
||||||
|
val agentsEnabled: Boolean = true,
|
||||||
|
val agentsCreateEnabled: Boolean = true,
|
||||||
|
val marketplaceEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
class AgentMarketplaceViewModel(
|
class AgentMarketplaceViewModel(
|
||||||
private val agentRepository: AgentRepository,
|
private val agentRepository: AgentRepository,
|
||||||
private val serverDataStore: ServerDataStore,
|
private val serverDataStore: ServerDataStore,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
|
private val permissionGate: PermissionGate,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AgentMarketplaceUiState())
|
private val _uiState = MutableStateFlow(AgentMarketplaceUiState())
|
||||||
|
|
@ -64,8 +75,32 @@ class AgentMarketplaceViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadAgents()
|
// Categories endpoint is ungated server-side — load unconditionally.
|
||||||
loadCategories()
|
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() {
|
fun loadAgents() {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ fun ChatInput(
|
||||||
onOpenModelSelector: () -> Unit = {},
|
onOpenModelSelector: () -> Unit = {},
|
||||||
selectedModelDisplay: String? = null,
|
selectedModelDisplay: String? = null,
|
||||||
isCodeInterpreterAvailable: Boolean = true,
|
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 cdOpenToolsMenu = stringResource(Res.string.cd_open_tools_menu)
|
||||||
val cdPasteImage = stringResource(Res.string.cd_paste_image)
|
val cdPasteImage = stringResource(Res.string.cd_paste_image)
|
||||||
|
|
@ -396,6 +400,10 @@ fun ChatInput(
|
||||||
selectedModelDisplay = selectedModelDisplay,
|
selectedModelDisplay = selectedModelDisplay,
|
||||||
onDismiss = { showToolsSheet = false },
|
onDismiss = { showToolsSheet = false },
|
||||||
isCodeInterpreterAvailable = isCodeInterpreterAvailable,
|
isCodeInterpreterAvailable = isCodeInterpreterAvailable,
|
||||||
|
webSearchEnabled = webSearchEnabled,
|
||||||
|
runCodeEnabled = runCodeEnabled,
|
||||||
|
fileSearchEnabled = fileSearchEnabled,
|
||||||
|
mcpServersEnabled = mcpServersEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ actual val chatPlatformModule: Module = module {
|
||||||
shareRepository = get(),
|
shareRepository = get(),
|
||||||
mcpRepository = get(),
|
mcpRepository = get(),
|
||||||
userRepository = get(),
|
userRepository = get(),
|
||||||
|
roleRepository = get(),
|
||||||
|
permissionGate = get(),
|
||||||
connectivityObserver = get(),
|
connectivityObserver = get(),
|
||||||
serverDataStore = get(),
|
serverDataStore = get(),
|
||||||
settingsDataStore = 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.util.MessageNode
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
||||||
|
import com.garfiec.librechat.feature.chat.viewmodel.asString
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
@ -136,7 +137,6 @@ actual fun ChatScreen(
|
||||||
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
var showPresetPicker by remember { mutableStateOf(false) }
|
var showPresetPicker by remember { mutableStateOf(false) }
|
||||||
var showSavePresetDialog by remember { mutableStateOf(false) }
|
var showSavePresetDialog by remember { mutableStateOf(false) }
|
||||||
var showModelSheet by remember { mutableStateOf(false) }
|
|
||||||
var showSecondaryModelSheet by remember { mutableStateOf(false) }
|
var showSecondaryModelSheet by remember { mutableStateOf(false) }
|
||||||
var activeComparisonTab by remember { mutableStateOf(0) }
|
var activeComparisonTab by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
|
@ -259,6 +259,8 @@ actual fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sendBlockMessage = uiState.sendBlockReason?.asString()
|
||||||
|
|
||||||
// Stream resume on foreground
|
// Stream resume on foreground
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { viewModel.onPause() }
|
LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { viewModel.onPause() }
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onResume() }
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.onResume() }
|
||||||
|
|
@ -320,9 +322,11 @@ actual fun ChatScreen(
|
||||||
onOpenDrawer = onOpenDrawer,
|
onOpenDrawer = onOpenDrawer,
|
||||||
onOpenSearch = viewModel::openSearch,
|
onOpenSearch = viewModel::openSearch,
|
||||||
onOpenPromptsLibrary = onNavigateToPromptsLibrary,
|
onOpenPromptsLibrary = onNavigateToPromptsLibrary,
|
||||||
|
promptsEnabled = uiState.promptsEnabled,
|
||||||
|
multiConvoEnabled = uiState.multiConvoEnabled,
|
||||||
isTemporaryChat = uiState.isTemporaryChat,
|
isTemporaryChat = uiState.isTemporaryChat,
|
||||||
onToggleTemporaryChat = viewModel::toggleTemporaryChat,
|
onToggleTemporaryChat = viewModel::toggleTemporaryChat,
|
||||||
showTempChatToggle = uiState.conversationId == null,
|
showTempChatToggle = uiState.conversationId == null && uiState.temporaryChatEnabled,
|
||||||
isComparisonEnabled = uiState.comparisonState.isEnabled,
|
isComparisonEnabled = uiState.comparisonState.isEnabled,
|
||||||
onToggleComparison = viewModel::toggleComparison,
|
onToggleComparison = viewModel::toggleComparison,
|
||||||
conversationId = uiState.conversationId,
|
conversationId = uiState.conversationId,
|
||||||
|
|
@ -521,7 +525,7 @@ actual fun ChatScreen(
|
||||||
primaryModelSelector = {
|
primaryModelSelector = {
|
||||||
ModelSelectorButton(
|
ModelSelectorButton(
|
||||||
modelName = displayModel,
|
modelName = displayModel,
|
||||||
onClick = { showModelSheet = true },
|
onClick = viewModel::openModelSheet,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
secondaryModelSelector = {
|
secondaryModelSelector = {
|
||||||
|
|
@ -644,7 +648,7 @@ actual fun ChatScreen(
|
||||||
if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) {
|
if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) {
|
||||||
showSecondaryModelSheet = true
|
showSecondaryModelSheet = true
|
||||||
} else {
|
} else {
|
||||||
showModelSheet = true
|
viewModel.openModelSheet()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectedModelDisplay = if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) {
|
selectedModelDisplay = if (uiState.comparisonState.isEnabled && activeComparisonTab == 1) {
|
||||||
|
|
@ -655,6 +659,10 @@ actual fun ChatScreen(
|
||||||
displayModel
|
displayModel
|
||||||
},
|
},
|
||||||
isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable,
|
isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable,
|
||||||
|
webSearchEnabled = uiState.webSearchEnabled,
|
||||||
|
runCodeEnabled = uiState.runCodeEnabled,
|
||||||
|
fileSearchEnabled = uiState.fileSearchEnabled,
|
||||||
|
mcpServersEnabled = uiState.mcpServersEnabled,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -728,7 +736,7 @@ actual fun ChatScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showModelSheet) {
|
if (uiState.showModelSheet) {
|
||||||
ModelSelectorSheet(
|
ModelSelectorSheet(
|
||||||
endpointConfigs = uiState.endpointConfigs,
|
endpointConfigs = uiState.endpointConfigs,
|
||||||
availableModels = uiState.availableModels,
|
availableModels = uiState.availableModels,
|
||||||
|
|
@ -737,10 +745,26 @@ actual fun ChatScreen(
|
||||||
selectedModel = uiState.selectedModel,
|
selectedModel = uiState.selectedModel,
|
||||||
onModelSelect = { endpoint, model ->
|
onModelSelect = { endpoint, model ->
|
||||||
viewModel.onModelSelected(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,
|
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,
|
modifier: Modifier = Modifier,
|
||||||
onOpenSearch: () -> Unit = {},
|
onOpenSearch: () -> Unit = {},
|
||||||
onOpenPromptsLibrary: (() -> Unit)? = null,
|
onOpenPromptsLibrary: (() -> Unit)? = null,
|
||||||
|
promptsEnabled: Boolean = true,
|
||||||
|
multiConvoEnabled: Boolean = true,
|
||||||
isTemporaryChat: Boolean = false,
|
isTemporaryChat: Boolean = false,
|
||||||
onToggleTemporaryChat: () -> Unit = {},
|
onToggleTemporaryChat: () -> Unit = {},
|
||||||
showTempChatToggle: Boolean = false,
|
showTempChatToggle: Boolean = false,
|
||||||
|
|
@ -870,7 +896,7 @@ private fun ChatTopBar(
|
||||||
Icon(Icons.Outlined.SaveAs, contentDescription = null)
|
Icon(Icons.Outlined.SaveAs, contentDescription = null)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (onOpenPromptsLibrary != null) {
|
if (onOpenPromptsLibrary != null && promptsEnabled) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(Res.string.prompts_library)) },
|
text = { Text(stringResource(Res.string.prompts_library)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -882,32 +908,34 @@ private fun ChatTopBar(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
if (multiConvoEnabled) {
|
||||||
text = {
|
DropdownMenuItem(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
text = {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = stringResource(Res.string.compare_models),
|
Text(
|
||||||
modifier = Modifier.weight(1f),
|
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 (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 = {
|
||||||
onClick = {
|
showOverflowMenu = false
|
||||||
showOverflowMenu = false
|
onToggleComparison()
|
||||||
onToggleComparison()
|
},
|
||||||
},
|
leadingIcon = {
|
||||||
leadingIcon = {
|
Icon(Icons.Outlined.Compare, contentDescription = null)
|
||||||
Icon(Icons.Outlined.Compare, contentDescription = null)
|
},
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
if (conversationId != null) {
|
if (conversationId != null) {
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
Text(
|
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.MessageRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PresetRepository
|
import com.garfiec.librechat.core.data.repository.PresetRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepository
|
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.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
import com.garfiec.librechat.core.data.repository.UserRepository
|
||||||
|
import com.garfiec.librechat.core.data.util.PermissionGate
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.koin.test.verify.verify
|
import org.koin.test.verify.verify
|
||||||
|
|
||||||
|
|
@ -37,6 +39,8 @@ class ChatModuleVerificationTest {
|
||||||
FileRepository::class,
|
FileRepository::class,
|
||||||
PresetRepository::class,
|
PresetRepository::class,
|
||||||
PromptRepository::class,
|
PromptRepository::class,
|
||||||
|
RoleRepository::class,
|
||||||
|
PermissionGate::class,
|
||||||
ShareRepository::class,
|
ShareRepository::class,
|
||||||
SpeechRepository::class,
|
SpeechRepository::class,
|
||||||
McpRepository::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 -->
|
<!-- Slash command menu -->
|
||||||
<string name="slash_command_title">/%1$s</string>
|
<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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ fun ChatToolsBottomSheet(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isCodeInterpreterAvailable: Boolean = true,
|
isCodeInterpreterAvailable: Boolean = true,
|
||||||
|
webSearchEnabled: Boolean = true,
|
||||||
|
runCodeEnabled: Boolean = true,
|
||||||
|
fileSearchEnabled: Boolean = true,
|
||||||
|
mcpServersEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
var showMcpServers by remember { mutableStateOf(false) }
|
var showMcpServers by remember { mutableStateOf(false) }
|
||||||
|
|
@ -204,15 +208,17 @@ fun ChatToolsBottomSheet(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Tool toggle items
|
// Tool toggle items
|
||||||
ToolToggleRow(
|
if (webSearchEnabled) {
|
||||||
icon = Icons.Default.Search,
|
ToolToggleRow(
|
||||||
title = stringResource(Res.string.tool_web_search),
|
icon = Icons.Default.Search,
|
||||||
subtitle = stringResource(Res.string.tool_web_search_desc),
|
title = stringResource(Res.string.tool_web_search),
|
||||||
isEnabled = ToolConstants.WEB_SEARCH in enabledTools,
|
subtitle = stringResource(Res.string.tool_web_search_desc),
|
||||||
onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) },
|
isEnabled = ToolConstants.WEB_SEARCH in enabledTools,
|
||||||
)
|
onToggle = { onToggleTool(ToolConstants.WEB_SEARCH) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isCodeInterpreterAvailable) {
|
if (isCodeInterpreterAvailable && runCodeEnabled) {
|
||||||
ToolToggleRow(
|
ToolToggleRow(
|
||||||
icon = Icons.Default.Code,
|
icon = Icons.Default.Code,
|
||||||
title = stringResource(Res.string.tool_code),
|
title = stringResource(Res.string.tool_code),
|
||||||
|
|
@ -222,16 +228,18 @@ fun ChatToolsBottomSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolToggleRow(
|
if (fileSearchEnabled) {
|
||||||
icon = Icons.Default.FindInPage,
|
ToolToggleRow(
|
||||||
title = stringResource(Res.string.tool_file_search),
|
icon = Icons.Default.FindInPage,
|
||||||
subtitle = stringResource(Res.string.tool_file_search_desc),
|
title = stringResource(Res.string.tool_file_search),
|
||||||
isEnabled = ToolConstants.FILE_SEARCH in enabledTools,
|
subtitle = stringResource(Res.string.tool_file_search_desc),
|
||||||
onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) },
|
isEnabled = ToolConstants.FILE_SEARCH in enabledTools,
|
||||||
)
|
onToggle = { onToggleTool(ToolConstants.FILE_SEARCH) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MCP section
|
// MCP section — hidden entirely when role denies MCP_SERVERS.USE.
|
||||||
if (mcpServers.isNotEmpty()) {
|
if (mcpServersEnabled && mcpServers.isNotEmpty()) {
|
||||||
val anyMcpSelected = selectedMcpServerNames.isNotEmpty()
|
val anyMcpSelected = selectedMcpServerNames.isNotEmpty()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import coil3.compose.AsyncImage
|
||||||
import com.garfiec.librechat.core.common.EndpointConstants
|
import com.garfiec.librechat.core.common.EndpointConstants
|
||||||
import com.garfiec.librechat.core.model.Agent
|
import com.garfiec.librechat.core.model.Agent
|
||||||
import com.garfiec.librechat.core.model.EndpointConfig
|
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.endpointIconPainter
|
||||||
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
|
import com.garfiec.librechat.core.ui.components.isMonochromeEndpointIcon
|
||||||
import com.garfiec.librechat.feature.chat.resources.*
|
import com.garfiec.librechat.feature.chat.resources.*
|
||||||
|
|
@ -96,6 +97,8 @@ fun ModelSelectorSheet(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
serverUrl: String = "",
|
serverUrl: String = "",
|
||||||
|
errorMessage: String? = null,
|
||||||
|
onErrorDismiss: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
|
@ -144,20 +147,28 @@ fun ModelSelectorSheet(
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = 32.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(
|
||||||
text = stringResource(Res.string.select_a_model),
|
text = stringResource(Res.string.select_a_model),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(bottom = 16.dp),
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
onValueChange = { searchQuery = it },
|
onValueChange = { searchQuery = it },
|
||||||
placeholder = { Text("Search models...") },
|
placeholder = { Text("Search models...") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (searchQuery.isNotEmpty()) {
|
if (searchQuery.isNotEmpty()) {
|
||||||
|
|
@ -173,7 +184,11 @@ fun ModelSelectorSheet(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
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)
|
// "My Agents" group (shown first, like the web frontend)
|
||||||
if (filteredAgents.isNotEmpty()) {
|
if (filteredAgents.isNotEmpty()) {
|
||||||
val agentsExpanded = expandedGroups[EndpointConstants.AGENTS] != false
|
val agentsExpanded = expandedGroups[EndpointConstants.AGENTS] != false
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,10 @@ fun PromptsLibraryScreen(
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(onClick = { onNavigateToEditor(null) }) {
|
if (uiState.promptsCreateEnabled) {
|
||||||
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.cd_create_prompt))
|
FloatingActionButton(onClick = { onNavigateToEditor(null) }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = stringResource(Res.string.cd_create_prompt))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
|
@ -255,6 +257,7 @@ fun PromptsLibraryScreen(
|
||||||
group = group,
|
group = group,
|
||||||
onClick = { viewModel.selectGroup(group.id) },
|
onClick = { viewModel.selectGroup(group.id) },
|
||||||
onShare = { viewModel.showShareDialog(group.id) },
|
onShare = { viewModel.showShareDialog(group.id) },
|
||||||
|
showShareButton = uiState.promptsShareEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -281,6 +284,7 @@ private fun PromptGroupItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
showShareButton: Boolean = true,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -321,12 +325,14 @@ private fun PromptGroupItem(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onShare) {
|
if (showShareButton) {
|
||||||
Icon(
|
IconButton(onClick = onShare) {
|
||||||
imageVector = Icons.Default.Share,
|
Icon(
|
||||||
contentDescription = stringResource(Res.string.cd_share),
|
imageVector = Icons.Default.Share,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentDescription = stringResource(Res.string.cd_share),
|
||||||
)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val promptText = group.promptText
|
val promptText = group.promptText
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@ import androidx.lifecycle.viewModelScope
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepository
|
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.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.AddPromptToGroupRequest
|
||||||
import com.garfiec.librechat.core.model.request.CreatePromptData
|
import com.garfiec.librechat.core.model.request.CreatePromptData
|
||||||
import com.garfiec.librechat.core.model.request.CreatePromptGroupData
|
import com.garfiec.librechat.core.model.request.CreatePromptGroupData
|
||||||
|
|
@ -42,10 +47,15 @@ data class PromptsUiState(
|
||||||
val showVariableDialog: Boolean = false,
|
val showVariableDialog: Boolean = false,
|
||||||
val variablePromptTemplate: String = "",
|
val variablePromptTemplate: String = "",
|
||||||
val variableNames: List<String> = emptyList(),
|
val variableNames: List<String> = emptyList(),
|
||||||
|
// Role-permission gates — default permissive.
|
||||||
|
val promptsCreateEnabled: Boolean = true,
|
||||||
|
val promptsShareEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PromptsViewModel(
|
class PromptsViewModel(
|
||||||
private val promptRepository: PromptRepository,
|
private val promptRepository: PromptRepository,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
|
private val permissionGate: PermissionGate,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PromptsUiState())
|
private val _uiState = MutableStateFlow(PromptsUiState())
|
||||||
|
|
@ -55,7 +65,29 @@ class PromptsViewModel(
|
||||||
private var rawGroups: List<PromptGroup> = emptyList()
|
private var rawGroups: List<PromptGroup> = emptyList()
|
||||||
|
|
||||||
init {
|
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() {
|
fun loadGroups() {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,19 @@ import com.garfiec.librechat.feature.chat.util.MessageNode
|
||||||
|
|
||||||
enum class ChatScreenState { LANDING, LOADING, ACTIVE }
|
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].
|
* Consolidated chat-related user preferences from [SettingsDataStore].
|
||||||
* Exposed as a single [StateFlow] to reduce the number of individual subscriptions
|
* 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 agents: List<Agent> = emptyList(),
|
||||||
val conversationId: String? = null,
|
val conversationId: String? = null,
|
||||||
val error: 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 presets: List<PresetDisplayData> = emptyList(),
|
||||||
val availablePrompts: List<PromptMentionDisplayData> = emptyList(),
|
val availablePrompts: List<PromptMentionDisplayData> = emptyList(),
|
||||||
val editingMessageId: String? = null,
|
val editingMessageId: String? = null,
|
||||||
|
|
@ -131,8 +147,25 @@ data class ChatUiState(
|
||||||
* The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled],
|
* The UI navigates to Chat(id) and then clears this via [ChatViewModel.onPendingNavigationHandled],
|
||||||
* which also resets this ViewModel to a clean landing state. */
|
* which also resets this ViewModel to a clean landing state. */
|
||||||
val pendingNavigationConversationId: String? = null,
|
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
|
// Model comparison state
|
||||||
val comparisonState: ComparisonState = ComparisonState(),
|
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
|
* 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.
|
// the actual capabilities list is authoritative.
|
||||||
return agentsConfig?.capabilities?.contains(ToolConstants.EXECUTE_CODE) ?: true
|
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.MessageRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PresetRepository
|
import com.garfiec.librechat.core.data.repository.PresetRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PromptRepository
|
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.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
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.Attachment
|
||||||
import com.garfiec.librechat.core.model.EModelEndpoint
|
import com.garfiec.librechat.core.model.EModelEndpoint
|
||||||
import com.garfiec.librechat.core.model.Message
|
import com.garfiec.librechat.core.model.Message
|
||||||
import com.garfiec.librechat.core.model.Preset
|
import com.garfiec.librechat.core.model.Preset
|
||||||
import com.garfiec.librechat.core.model.StreamEvent
|
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.model.request.EphemeralAgent
|
||||||
import com.garfiec.librechat.core.ui.components.ModelParameters
|
import com.garfiec.librechat.core.ui.components.ModelParameters
|
||||||
import com.garfiec.librechat.feature.chat.components.AttachedFile
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
initialConversationId: String? = null,
|
initialConversationId: String? = null,
|
||||||
private val agentRepository: AgentRepository,
|
private val agentRepository: AgentRepository,
|
||||||
|
|
@ -71,6 +79,8 @@ class ChatViewModel(
|
||||||
shareRepository: ShareRepository,
|
shareRepository: ShareRepository,
|
||||||
mcpRepository: McpRepository,
|
mcpRepository: McpRepository,
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
|
private val permissionGate: PermissionGate,
|
||||||
private val connectivityObserver: ConnectivityObserver,
|
private val connectivityObserver: ConnectivityObserver,
|
||||||
serverDataStore: ServerDataStore,
|
serverDataStore: ServerDataStore,
|
||||||
private val settingsDataStore: SettingsDataStore,
|
private val settingsDataStore: SettingsDataStore,
|
||||||
|
|
@ -93,7 +103,7 @@ class ChatViewModel(
|
||||||
ConversationActionsDelegate(stateHandle, conversationRepository, shareRepository)
|
ConversationActionsDelegate(stateHandle, conversationRepository, shareRepository)
|
||||||
private val presetPromptDelegate = PresetPromptDelegate(stateHandle, presetRepository, promptRepository)
|
private val presetPromptDelegate = PresetPromptDelegate(stateHandle, presetRepository, promptRepository)
|
||||||
private val modelDelegate =
|
private val modelDelegate =
|
||||||
ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore)
|
ModelSelectionDelegate(stateHandle, configRepository, agentRepository, mcpRepository, settingsDataStore, permissionGate)
|
||||||
|
|
||||||
// --- Delegate-owned flows exposed to the UI ---
|
// --- Delegate-owned flows exposed to the UI ---
|
||||||
val attachedFiles: StateFlow<List<AttachedFile>> get() = fileDelegate.attachedFiles
|
val attachedFiles: StateFlow<List<AttachedFile>> get() = fileDelegate.attachedFiles
|
||||||
|
|
@ -153,6 +163,11 @@ class ChatViewModel(
|
||||||
companion object {
|
companion object {
|
||||||
/** Minimum interval between streaming UI state updates to avoid recomposition spam. */
|
/** Minimum interval between streaming UI state updates to avoid recomposition spam. */
|
||||||
private const val STREAMING_UI_UPDATE_INTERVAL_MS = 50L
|
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. */
|
/** True when the current stream is from an edit, regenerate, or continue operation. */
|
||||||
|
|
@ -272,12 +287,26 @@ class ChatViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
presetPromptDelegate.loadPresets()
|
presetPromptDelegate.loadPresets()
|
||||||
presetPromptDelegate.loadAvailablePrompts()
|
|
||||||
modelDelegate.loadMcpServers()
|
|
||||||
loadUserProfile()
|
loadUserProfile()
|
||||||
modelDelegate.loadAgents()
|
loadFlags()
|
||||||
loadSharedLinksEnabled()
|
|
||||||
voiceDelegate.loadSpeechConfig()
|
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 ──────────────────────────────────────────────
|
// ── Core chat flow ──────────────────────────────────────────────
|
||||||
|
|
@ -426,12 +455,12 @@ class ChatViewModel(
|
||||||
if (fileDelegate.hasPendingUploads()) {
|
if (fileDelegate.hasPendingUploads()) {
|
||||||
Logger.d { "sendMessage: waiting for pending upload(s) to complete" }
|
Logger.d { "sendMessage: waiting for pending upload(s) to complete" }
|
||||||
fileDelegate.pendingUploadSendJob = viewModelScope.launch {
|
fileDelegate.pendingUploadSendJob = viewModelScope.launch {
|
||||||
fileDelegate.waitForUploadsAndSend(text) { doSendMessage(it) }
|
fileDelegate.waitForUploadsAndSend(text) { runWhenSendReady { doSendMessage(it) } }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
doSendMessage(text)
|
runWhenSendReady { doSendMessage(text) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -637,10 +666,12 @@ class ChatViewModel(
|
||||||
|
|
||||||
val originalMessage = _uiState.value.messages.find { it.messageId == messageId } ?: return
|
val originalMessage = _uiState.value.messages.find { it.messageId == messageId } ?: return
|
||||||
|
|
||||||
if (originalMessage.isCreatedByUser) {
|
runWhenSendReady {
|
||||||
editUserMessage(originalMessage, newText)
|
if (originalMessage.isCreatedByUser) {
|
||||||
} else {
|
editUserMessage(originalMessage, newText)
|
||||||
editAiMessage(originalMessage, newText)
|
} else {
|
||||||
|
editAiMessage(originalMessage, newText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -717,6 +748,10 @@ class ChatViewModel(
|
||||||
it.messageId == aiMessage.parentMessageId
|
it.messageId == aiMessage.parentMessageId
|
||||||
} ?: return
|
} ?: return
|
||||||
|
|
||||||
|
runWhenSendReady { regenerateMessageNow(parentUserMessage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun regenerateMessageNow(parentUserMessage: Message) {
|
||||||
isEditOrRegenerate = true
|
isEditOrRegenerate = true
|
||||||
prepareForStreaming()
|
prepareForStreaming()
|
||||||
|
|
||||||
|
|
@ -1114,6 +1149,10 @@ class ChatViewModel(
|
||||||
it.messageId == lastAiMessage.message.parentMessageId
|
it.messageId == lastAiMessage.message.parentMessageId
|
||||||
} ?: return
|
} ?: return
|
||||||
|
|
||||||
|
runWhenSendReady { continueGenerationNow(lastAiMessage.message, parentUserMessage) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun continueGenerationNow(lastAiMessage: Message, parentUserMessage: Message) {
|
||||||
isEditOrRegenerate = true
|
isEditOrRegenerate = true
|
||||||
prepareForStreaming()
|
prepareForStreaming()
|
||||||
|
|
||||||
|
|
@ -1132,7 +1171,7 @@ class ChatViewModel(
|
||||||
parentMessageId = parentUserMessage.parentMessageId,
|
parentMessageId = parentUserMessage.parentMessageId,
|
||||||
agentId = if (isAgentContinue) _uiState.value.selectedModel else null,
|
agentId = if (isAgentContinue) _uiState.value.selectedModel else null,
|
||||||
overrideParentMessageId = parentUserMessage.messageId,
|
overrideParentMessageId = parentUserMessage.messageId,
|
||||||
responseMessageId = lastAiMessage.message.messageId,
|
responseMessageId = lastAiMessage.messageId,
|
||||||
isEdited = true,
|
isEdited = true,
|
||||||
isRegenerate = true,
|
isRegenerate = true,
|
||||||
isContinued = 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 {
|
viewModelScope.launch {
|
||||||
configRepository.startupConfig.collect { config ->
|
configRepository.startupConfig.collect { config ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.update {
|
||||||
sharedLinksEnabled = config?.sharedLinksEnabled ?: false,
|
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() {
|
private fun loadUserProfile() {
|
||||||
|
|
@ -1395,6 +1553,10 @@ class ChatViewModel(
|
||||||
_uiState.value = _uiState.value.copy(error = null)
|
_uiState.value = _uiState.value.copy(error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissSendBlockReason() {
|
||||||
|
_uiState.value = _uiState.value.copy(sendBlockReason = null)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
voiceDelegate.release()
|
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.AgentRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.McpRepository
|
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.EModelEndpoint
|
||||||
import com.garfiec.librechat.core.model.mcp.McpServer
|
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.model.request.AddedConversation
|
||||||
import com.garfiec.librechat.core.ui.components.ModelParameters
|
import com.garfiec.librechat.core.ui.components.ModelParameters
|
||||||
import com.garfiec.librechat.feature.chat.model.McpServerDisplayData
|
import com.garfiec.librechat.feature.chat.model.McpServerDisplayData
|
||||||
|
|
@ -24,6 +27,7 @@ class ModelSelectionDelegate(
|
||||||
private val agentRepository: AgentRepository,
|
private val agentRepository: AgentRepository,
|
||||||
private val mcpRepository: McpRepository,
|
private val mcpRepository: McpRepository,
|
||||||
private val settingsDataStore: SettingsDataStore,
|
private val settingsDataStore: SettingsDataStore,
|
||||||
|
private val permissionGate: PermissionGate,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// --- Model Comparison ---
|
// --- Model Comparison ---
|
||||||
|
|
@ -250,6 +254,12 @@ class ModelSelectionDelegate(
|
||||||
|
|
||||||
fun loadAgents() {
|
fun loadAgents() {
|
||||||
stateHandle.scope.launch {
|
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()) {
|
when (val result = agentRepository.getAgents()) {
|
||||||
is Result.Success -> {
|
is Result.Success -> {
|
||||||
stateHandle.update { copy(agents = result.data) }
|
stateHandle.update { copy(agents = result.data) }
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ fun IosChatInput(
|
||||||
onAttachFiles: () -> Unit = {},
|
onAttachFiles: () -> Unit = {},
|
||||||
onTakePhoto: () -> Unit = {},
|
onTakePhoto: () -> Unit = {},
|
||||||
onPickPhotos: () -> Unit = {},
|
onPickPhotos: () -> Unit = {},
|
||||||
|
webSearchEnabled: Boolean = true,
|
||||||
|
runCodeEnabled: Boolean = true,
|
||||||
|
fileSearchEnabled: Boolean = true,
|
||||||
|
mcpServersEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showToolsSheet by remember { mutableStateOf(false) }
|
var showToolsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
@ -208,6 +212,10 @@ fun IosChatInput(
|
||||||
selectedModelDisplay = selectedModelDisplay,
|
selectedModelDisplay = selectedModelDisplay,
|
||||||
onDismiss = { showToolsSheet = false },
|
onDismiss = { showToolsSheet = false },
|
||||||
isCodeInterpreterAvailable = isCodeInterpreterAvailable,
|
isCodeInterpreterAvailable = isCodeInterpreterAvailable,
|
||||||
|
webSearchEnabled = webSearchEnabled,
|
||||||
|
runCodeEnabled = runCodeEnabled,
|
||||||
|
fileSearchEnabled = fileSearchEnabled,
|
||||||
|
mcpServersEnabled = mcpServersEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ actual val chatPlatformModule: Module = module {
|
||||||
shareRepository = get(),
|
shareRepository = get(),
|
||||||
mcpRepository = get(),
|
mcpRepository = get(),
|
||||||
userRepository = get(),
|
userRepository = get(),
|
||||||
|
roleRepository = get(),
|
||||||
|
permissionGate = get(),
|
||||||
connectivityObserver = get(),
|
connectivityObserver = get(),
|
||||||
serverDataStore = get(),
|
serverDataStore = get(),
|
||||||
settingsDataStore = 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.util.readClipboardImage
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
import com.garfiec.librechat.feature.chat.viewmodel.ChatScreenState
|
||||||
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
import com.garfiec.librechat.feature.chat.viewmodel.ChatViewModel
|
||||||
|
import com.garfiec.librechat.feature.chat.viewmodel.asString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
@ -134,7 +135,6 @@ actual fun ChatScreen(
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
var showOptionsMenu by remember { mutableStateOf(false) }
|
var showOptionsMenu by remember { mutableStateOf(false) }
|
||||||
var showModelSheet by remember { mutableStateOf(false) }
|
|
||||||
var showPresetPicker by remember { mutableStateOf(false) }
|
var showPresetPicker by remember { mutableStateOf(false) }
|
||||||
var showSavePresetDialog by remember { mutableStateOf(false) }
|
var showSavePresetDialog by remember { mutableStateOf(false) }
|
||||||
var showRenameDialog by remember { mutableStateOf(false) }
|
var showRenameDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -157,6 +157,8 @@ actual fun ChatScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sendBlockMessage = uiState.sendBlockReason?.asString()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.imePadding(),
|
modifier = modifier.imePadding(),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|
@ -176,7 +178,7 @@ actual fun ChatScreen(
|
||||||
// Model selector only in active chat (not landing page)
|
// Model selector only in active chat (not landing page)
|
||||||
ModelSelectorButton(
|
ModelSelectorButton(
|
||||||
modelName = displayModel,
|
modelName = displayModel,
|
||||||
onClick = { showModelSheet = true },
|
onClick = viewModel::openModelSheet,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -191,8 +193,8 @@ actual fun ChatScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// Temp chat toggle — only on landing page
|
// Temp chat toggle — only on landing page, gated on TEMPORARY_CHAT.USE
|
||||||
if (isLandingPage) {
|
if (isLandingPage && uiState.temporaryChatEnabled) {
|
||||||
TempChatToggle(
|
TempChatToggle(
|
||||||
isTemporary = uiState.isTemporaryChat,
|
isTemporary = uiState.isTemporaryChat,
|
||||||
onToggle = viewModel::toggleTemporaryChat,
|
onToggle = viewModel::toggleTemporaryChat,
|
||||||
|
|
@ -245,8 +247,8 @@ actual fun ChatScreen(
|
||||||
Icon(Icons.Outlined.SaveAs, contentDescription = null)
|
Icon(Icons.Outlined.SaveAs, contentDescription = null)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Prompts Library
|
// Prompts Library — gated on PROMPTS.USE
|
||||||
if (onNavigateToPromptsLibrary != null) {
|
if (onNavigateToPromptsLibrary != null && uiState.promptsEnabled) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(Res.string.prompts_library)) },
|
text = { Text(stringResource(Res.string.prompts_library)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|
@ -258,33 +260,35 @@ actual fun ChatScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Compare Models
|
// Compare Models — gated on MULTI_CONVO.USE
|
||||||
DropdownMenuItem(
|
if (uiState.multiConvoEnabled) {
|
||||||
text = {
|
DropdownMenuItem(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
text = {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = stringResource(Res.string.compare_models),
|
Text(
|
||||||
modifier = Modifier.weight(1f),
|
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),
|
|
||||||
)
|
)
|
||||||
|
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 = {
|
||||||
onClick = {
|
showOptionsMenu = false
|
||||||
showOptionsMenu = false
|
viewModel.toggleComparison()
|
||||||
viewModel.toggleComparison()
|
},
|
||||||
},
|
leadingIcon = {
|
||||||
leadingIcon = {
|
Icon(Icons.Outlined.Compare, contentDescription = null)
|
||||||
Icon(Icons.Outlined.Compare, contentDescription = null)
|
},
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
// Conversation-specific actions
|
// Conversation-specific actions
|
||||||
if (uiState.conversationId != null) {
|
if (uiState.conversationId != null) {
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
@ -481,7 +485,7 @@ actual fun ChatScreen(
|
||||||
onStartRecording = viewModel::startRecording,
|
onStartRecording = viewModel::startRecording,
|
||||||
onStopRecording = viewModel::stopRecording,
|
onStopRecording = viewModel::stopRecording,
|
||||||
onOpenModelParameters = viewModel::showModelParameters,
|
onOpenModelParameters = viewModel::showModelParameters,
|
||||||
onOpenModelSelector = { showModelSheet = true },
|
onOpenModelSelector = viewModel::openModelSheet,
|
||||||
selectedModelDisplay = displayModel,
|
selectedModelDisplay = displayModel,
|
||||||
isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable,
|
isCodeInterpreterAvailable = uiState.isCodeInterpreterAvailable,
|
||||||
attachedFiles = attachedFiles,
|
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),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model selector bottom sheet
|
// Model selector bottom sheet
|
||||||
if (showModelSheet) {
|
if (uiState.showModelSheet) {
|
||||||
ModelSelectorSheet(
|
ModelSelectorSheet(
|
||||||
endpointConfigs = uiState.endpointConfigs,
|
endpointConfigs = uiState.endpointConfigs,
|
||||||
availableModels = uiState.availableModels,
|
availableModels = uiState.availableModels,
|
||||||
|
|
@ -531,10 +539,26 @@ actual fun ChatScreen(
|
||||||
selectedModel = uiState.selectedModel,
|
selectedModel = uiState.selectedModel,
|
||||||
onModelSelect = { endpoint, model ->
|
onModelSelect = { endpoint, model ->
|
||||||
viewModel.onModelSelected(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,
|
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.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||||
import com.garfiec.librechat.core.data.repository.MessageRepository
|
import com.garfiec.librechat.core.data.repository.MessageRepository
|
||||||
|
import com.garfiec.librechat.core.data.repository.RoleRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SearchRepository
|
import com.garfiec.librechat.core.data.repository.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
import com.garfiec.librechat.core.data.repository.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
|
|
@ -25,6 +26,7 @@ class ConversationsModuleVerificationTest {
|
||||||
ShareRepository::class,
|
ShareRepository::class,
|
||||||
SearchRepository::class,
|
SearchRepository::class,
|
||||||
ConfigRepository::class,
|
ConfigRepository::class,
|
||||||
|
RoleRepository::class,
|
||||||
ServerDataStore::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.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
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.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
import com.garfiec.librechat.core.data.repository.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
|
|
@ -17,6 +18,7 @@ import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
|
@ -39,6 +41,7 @@ class ConversationListViewModelTest {
|
||||||
private val shareRepository = mockk<ShareRepository>(relaxed = true)
|
private val shareRepository = mockk<ShareRepository>(relaxed = true)
|
||||||
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
|
private val conversationExporter = mockk<ConversationExporter>(relaxed = true)
|
||||||
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
|
private val conversationImporter = mockk<ConversationImporter>(relaxed = true)
|
||||||
|
private val roleRepository = mockk<RoleRepository>(relaxed = true)
|
||||||
|
|
||||||
private lateinit var viewModel: ConversationListViewModel
|
private lateinit var viewModel: ConversationListViewModel
|
||||||
|
|
||||||
|
|
@ -73,6 +76,7 @@ class ConversationListViewModelTest {
|
||||||
coEvery { conversationRepository.syncFavoritesFromServer() } returns Result.Success(Unit)
|
coEvery { conversationRepository.syncFavoritesFromServer() } returns Result.Success(Unit)
|
||||||
every { tagRepository.observeTags() } returns flowOf(testTags)
|
every { tagRepository.observeTags() } returns flowOf(testTags)
|
||||||
coEvery { tagRepository.refreshTags() } returns Result.Success(Unit)
|
coEvery { tagRepository.refreshTags() } returns Result.Success(Unit)
|
||||||
|
every { roleRepository.userPermissions } returns MutableStateFlow(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|
@ -87,6 +91,7 @@ class ConversationListViewModelTest {
|
||||||
shareRepository = shareRepository,
|
shareRepository = shareRepository,
|
||||||
conversationExporter = conversationExporter,
|
conversationExporter = conversationExporter,
|
||||||
conversationImporter = conversationImporter,
|
conversationImporter = conversationImporter,
|
||||||
|
roleRepository = roleRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ fun ConversationActions(
|
||||||
onBookmarkToggle: () -> Unit = {},
|
onBookmarkToggle: () -> Unit = {},
|
||||||
isBookmarked: Boolean = false,
|
isBookmarked: Boolean = false,
|
||||||
showShareAction: Boolean = false,
|
showShareAction: Boolean = false,
|
||||||
|
bookmarksEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showRenameDialog by remember { mutableStateOf(false) }
|
var showRenameDialog by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||||
|
|
@ -88,15 +89,17 @@ fun ConversationActions(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
ActionRow(
|
if (bookmarksEnabled) {
|
||||||
icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
ActionRow(
|
||||||
label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark),
|
icon = if (isBookmarked) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||||
iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
label = if (isBookmarked) stringResource(Res.string.remove_bookmark) else stringResource(Res.string.bookmark),
|
||||||
onClick = {
|
iconTint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
onDismiss()
|
onClick = {
|
||||||
onBookmarkToggle()
|
onDismiss()
|
||||||
},
|
onBookmarkToggle()
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ActionRow(
|
ActionRow(
|
||||||
icon = Icons.Default.BookmarkBorder,
|
icon = Icons.Default.BookmarkBorder,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ fun ConversationItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onActionsClick: () -> Unit,
|
onActionsClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
bookmarksEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val relativeTime = remember(data.updatedAt) {
|
val relativeTime = remember(data.updatedAt) {
|
||||||
data.updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: ""
|
data.updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: ""
|
||||||
|
|
@ -134,7 +135,7 @@ fun ConversationItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.isBookmarked) {
|
if (bookmarksEnabled && data.isBookmarked) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Bookmark,
|
imageVector = Icons.Default.Bookmark,
|
||||||
contentDescription = stringResource(Res.string.cd_bookmarked),
|
contentDescription = stringResource(Res.string.cd_bookmarked),
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,7 @@ fun ConversationListScreen(
|
||||||
displayData.conversationId,
|
displayData.conversationId,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
bookmarksEnabled = uiState.bookmarksEnabled,
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(start = 52.dp),
|
modifier = Modifier.padding(start = 52.dp),
|
||||||
|
|
@ -328,6 +329,7 @@ fun ConversationListScreen(
|
||||||
conversation = selectedConversation!!,
|
conversation = selectedConversation!!,
|
||||||
onDismiss = { selectedConversation = null },
|
onDismiss = { selectedConversation = null },
|
||||||
isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()),
|
isBookmarked = SAVED_TAG in (selectedConversation?.tags ?: emptyList()),
|
||||||
|
bookmarksEnabled = uiState.bookmarksEnabled,
|
||||||
onBookmarkToggle = {
|
onBookmarkToggle = {
|
||||||
selectedConversation?.let { convo ->
|
selectedConversation?.let { convo ->
|
||||||
viewModel.toggleFavorite(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.extensions.toRelativeDateGroup
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
import com.garfiec.librechat.core.common.result.Result
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
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.SearchRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ShareRepository
|
import com.garfiec.librechat.core.data.repository.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
import com.garfiec.librechat.core.data.repository.TagRepository
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
import com.garfiec.librechat.core.model.ConversationTag
|
import com.garfiec.librechat.core.model.ConversationTag
|
||||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
import com.garfiec.librechat.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.ConversationDisplayData
|
||||||
import com.garfiec.librechat.feature.conversations.components.toDisplayData
|
import com.garfiec.librechat.feature.conversations.components.toDisplayData
|
||||||
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
import com.garfiec.librechat.feature.conversations.export.ConversationExporter
|
||||||
|
|
@ -43,6 +47,8 @@ data class ConversationListUiState(
|
||||||
val selectedTags: Set<String> = emptySet(),
|
val selectedTags: Set<String> = emptySet(),
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
val isSearching: Boolean = false,
|
val isSearching: Boolean = false,
|
||||||
|
// Role-permission gate — default permissive until role loads.
|
||||||
|
val bookmarksEnabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface ConversationListEvent {
|
sealed interface ConversationListEvent {
|
||||||
|
|
@ -60,6 +66,7 @@ class ConversationListViewModel(
|
||||||
private val shareRepository: ShareRepository,
|
private val shareRepository: ShareRepository,
|
||||||
private val conversationExporter: ConversationExporter,
|
private val conversationExporter: ConversationExporter,
|
||||||
private val conversationImporter: ConversationImporter,
|
private val conversationImporter: ConversationImporter,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ConversationListUiState())
|
private val _uiState = MutableStateFlow(ConversationListUiState())
|
||||||
|
|
@ -76,6 +83,20 @@ class ConversationListViewModel(
|
||||||
loadConversations()
|
loadConversations()
|
||||||
observeConversations()
|
observeConversations()
|
||||||
observeTags()
|
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() {
|
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.McpRepository
|
||||||
import com.garfiec.librechat.core.data.repository.MemoryRepository
|
import com.garfiec.librechat.core.data.repository.MemoryRepository
|
||||||
import com.garfiec.librechat.core.data.repository.PresetRepository
|
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.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
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.ContentReader
|
||||||
import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner
|
import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner
|
||||||
import com.garfiec.librechat.feature.settings.viewmodel.delegate.SpeechSettingsFactory
|
import com.garfiec.librechat.feature.settings.viewmodel.delegate.SpeechSettingsFactory
|
||||||
|
|
@ -34,6 +36,8 @@ class SettingsModuleVerificationTest {
|
||||||
ConversationRepository::class,
|
ConversationRepository::class,
|
||||||
McpRepository::class,
|
McpRepository::class,
|
||||||
MemoryRepository::class,
|
MemoryRepository::class,
|
||||||
|
RoleRepository::class,
|
||||||
|
PermissionGate::class,
|
||||||
SpeechRepository::class,
|
SpeechRepository::class,
|
||||||
BalanceRepository::class,
|
BalanceRepository::class,
|
||||||
ShareRepository::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.KeyRepository
|
||||||
import com.garfiec.librechat.core.data.repository.McpRepository
|
import com.garfiec.librechat.core.data.repository.McpRepository
|
||||||
import com.garfiec.librechat.core.data.repository.MemoryRepository
|
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.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
import com.garfiec.librechat.core.data.repository.SpeechRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
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.core.model.User
|
||||||
import com.garfiec.librechat.feature.settings.util.ContentReader
|
import com.garfiec.librechat.feature.settings.util.ContentReader
|
||||||
import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner
|
import com.garfiec.librechat.feature.settings.util.PlatformCacheCleaner
|
||||||
|
|
@ -59,6 +61,8 @@ class SettingsViewModelTest {
|
||||||
private val balanceRepository = mockk<BalanceRepository>(relaxed = true)
|
private val balanceRepository = mockk<BalanceRepository>(relaxed = true)
|
||||||
private val shareRepository = mockk<ShareRepository>(relaxed = true)
|
private val shareRepository = mockk<ShareRepository>(relaxed = true)
|
||||||
private val keyRepository = mockk<KeyRepository>(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(
|
private val testUser = User(
|
||||||
email = "test@example.com",
|
email = "test@example.com",
|
||||||
|
|
@ -107,6 +111,11 @@ class SettingsViewModelTest {
|
||||||
coEvery { memoryRepository.getMemories() } returns Result.Success(emptyList())
|
coEvery { memoryRepository.getMemories() } returns Result.Success(emptyList())
|
||||||
coEvery { speechRepository.getVoices() } returns Result.Success(emptyList())
|
coEvery { speechRepository.getVoices() } returns Result.Success(emptyList())
|
||||||
coEvery { balanceRepository.getBalance() } returns Result.Error(message = "Not available")
|
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
|
@After
|
||||||
|
|
@ -129,6 +138,8 @@ class SettingsViewModelTest {
|
||||||
balanceRepository = balanceRepository,
|
balanceRepository = balanceRepository,
|
||||||
shareRepository = shareRepository,
|
shareRepository = shareRepository,
|
||||||
keyRepository = keyRepository,
|
keyRepository = keyRepository,
|
||||||
|
roleRepository = roleRepository,
|
||||||
|
permissionGate = permissionGate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -146,26 +146,31 @@ fun DataSettingsContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memories section
|
// Memories section — hidden entirely when the server's MEMORIES.USE role
|
||||||
item(key = "memories_header") {
|
// permission is denied. The user-level opt-out (`memoriesEnabled`) stays
|
||||||
SectionHeader(stringResource(Res.string.section_memories))
|
// independent and only hides the list inside this section.
|
||||||
}
|
if (uiState.serverMemoriesEnabled) {
|
||||||
item(key = "memories_settings") {
|
item(key = "memories_header") {
|
||||||
MemoriesSettingsSection(
|
SectionHeader(stringResource(Res.string.section_memories))
|
||||||
memories = uiState.memories,
|
}
|
||||||
memoriesEnabled = uiState.memoriesEnabled,
|
item(key = "memories_settings") {
|
||||||
showMemoryDialog = uiState.showMemoryDialog,
|
MemoriesSettingsSection(
|
||||||
editingMemory = uiState.editingMemory,
|
memories = uiState.memories,
|
||||||
onToggleEnable = viewModel::toggleMemoriesEnabled,
|
memoriesEnabled = uiState.memoriesEnabled,
|
||||||
onAddMemory = viewModel::showAddMemoryDialog,
|
showMemoryDialog = uiState.showMemoryDialog,
|
||||||
onEditMemory = viewModel::showEditMemoryDialog,
|
editingMemory = uiState.editingMemory,
|
||||||
onDeleteMemory = viewModel::deleteMemory,
|
onToggleEnable = viewModel::toggleMemoriesEnabled,
|
||||||
onDismissDialog = viewModel::dismissMemoryDialog,
|
onAddMemory = viewModel::showAddMemoryDialog,
|
||||||
onSaveMemory = viewModel::saveMemory,
|
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") {
|
item(key = "mcp_header") {
|
||||||
SectionHeader(stringResource(Res.string.section_mcp_servers))
|
SectionHeader(stringResource(Res.string.section_mcp_servers))
|
||||||
}
|
}
|
||||||
|
|
@ -175,6 +180,8 @@ fun DataSettingsContent(
|
||||||
connectionStatus = uiState.mcpConnectionStatus,
|
connectionStatus = uiState.mcpConnectionStatus,
|
||||||
reinitializingServers = uiState.mcpReinitializingServers,
|
reinitializingServers = uiState.mcpReinitializingServers,
|
||||||
error = uiState.mcpError,
|
error = uiState.mcpError,
|
||||||
|
mcpServersEnabled = uiState.mcpServersEnabled,
|
||||||
|
mcpServersCreateEnabled = uiState.mcpServersCreateEnabled,
|
||||||
onAddServer = viewModel::showAddMcpServerDialog,
|
onAddServer = viewModel::showAddMcpServerDialog,
|
||||||
onEditServer = viewModel::showEditMcpServerDialog,
|
onEditServer = viewModel::showEditMcpServerDialog,
|
||||||
onDeleteServer = viewModel::deleteMcpServer,
|
onDeleteServer = viewModel::deleteMcpServer,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ internal fun McpSettingsSection(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
reinitializingServers: Set<String> = emptySet(),
|
reinitializingServers: Set<String> = emptySet(),
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
|
mcpServersEnabled: Boolean = true,
|
||||||
|
mcpServersCreateEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -52,7 +54,13 @@ internal fun McpSettingsSection(
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(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(
|
||||||
text = stringResource(Res.string.mcp_not_available),
|
text = stringResource(Res.string.mcp_not_available),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|
@ -77,11 +85,14 @@ internal fun McpSettingsSection(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error == null) {
|
if (mcpServersEnabled && error == null) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
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(
|
OutlinedButton(
|
||||||
onClick = onAddServer,
|
onClick = onAddServer,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.KeyRepository
|
||||||
import com.garfiec.librechat.core.data.repository.McpRepository
|
import com.garfiec.librechat.core.data.repository.McpRepository
|
||||||
import com.garfiec.librechat.core.data.repository.MemoryRepository
|
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.ShareRepository
|
||||||
import com.garfiec.librechat.core.data.repository.UserRepository
|
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.Memory
|
||||||
import com.garfiec.librechat.core.model.User
|
import com.garfiec.librechat.core.model.User
|
||||||
import com.garfiec.librechat.core.model.mcp.McpApiKeyConfig
|
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.McpServer
|
||||||
import com.garfiec.librechat.core.model.mcp.McpServerStatus
|
import com.garfiec.librechat.core.model.mcp.McpServerStatus
|
||||||
import com.garfiec.librechat.core.model.mcp.McpServerType
|
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.core.model.speech.TtsVoice
|
||||||
import com.garfiec.librechat.feature.settings.model.SharedLinkDisplayData
|
import com.garfiec.librechat.feature.settings.model.SharedLinkDisplayData
|
||||||
import com.garfiec.librechat.feature.settings.model.UserDisplayData
|
import com.garfiec.librechat.feature.settings.model.UserDisplayData
|
||||||
|
|
@ -159,6 +164,14 @@ data class SettingsUiState(
|
||||||
val showAvatars: Boolean = true,
|
val showAvatars: Boolean = true,
|
||||||
val showBubbles: Boolean = false,
|
val showBubbles: Boolean = false,
|
||||||
val latexRenderer: LatexRenderer = LatexRenderer.KATEX,
|
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(
|
private fun User.toDisplayData() = UserDisplayData(
|
||||||
|
|
@ -194,6 +207,8 @@ class SettingsViewModel(
|
||||||
private val balanceRepository: BalanceRepository,
|
private val balanceRepository: BalanceRepository,
|
||||||
shareRepository: ShareRepository,
|
shareRepository: ShareRepository,
|
||||||
keyRepository: KeyRepository,
|
keyRepository: KeyRepository,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
|
private val permissionGate: PermissionGate,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/** Raw state for everything not driven by DataStore flows. */
|
/** Raw state for everything not driven by DataStore flows. */
|
||||||
|
|
@ -367,11 +382,48 @@ class SettingsViewModel(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadUser()
|
loadUser()
|
||||||
mcpDelegate.loadMcpServers()
|
|
||||||
memoryDelegate.loadMemories()
|
|
||||||
speechDelegate.loadVoices()
|
speechDelegate.loadVoices()
|
||||||
loadBalance()
|
loadBalance()
|
||||||
speechDelegate.loadDeviceVoices()
|
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 ─────────────────────────────────────
|
// ── Account & user profile ─────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -219,8 +219,9 @@ fun DrawerContent(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
// Favorites section
|
// Favorites section — hidden entirely when BOOKMARKS.USE is denied so
|
||||||
if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) {
|
// 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") {
|
item(key = "favorites_header") {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -258,6 +259,7 @@ fun DrawerContent(
|
||||||
data = data,
|
data = data,
|
||||||
onClick = { onConversationClick(data.conversationId) },
|
onClick = { onConversationClick(data.conversationId) },
|
||||||
onToggleFavorite = { onToggleFavorite(data) },
|
onToggleFavorite = { onToggleFavorite(data) },
|
||||||
|
showBookmarkToggle = uiState.bookmarksEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,6 +306,7 @@ fun DrawerContent(
|
||||||
data = data,
|
data = data,
|
||||||
onClick = { onConversationClick(data.conversationId) },
|
onClick = { onConversationClick(data.conversationId) },
|
||||||
onToggleFavorite = { onToggleFavorite(data) },
|
onToggleFavorite = { onToggleFavorite(data) },
|
||||||
|
showBookmarkToggle = uiState.bookmarksEnabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,11 +335,13 @@ fun DrawerContent(
|
||||||
color = MaterialTheme.colorScheme.outlineVariant,
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
DrawerFooterItem(
|
if (uiState.agentsEnabled) {
|
||||||
icon = Icons.Default.SmartToy,
|
DrawerFooterItem(
|
||||||
label = stringResource(Res.string.agents),
|
icon = Icons.Default.SmartToy,
|
||||||
onClick = onAgentsClick,
|
label = stringResource(Res.string.agents),
|
||||||
)
|
onClick = onAgentsClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
DrawerFooterItem(
|
DrawerFooterItem(
|
||||||
icon = Icons.Default.Folder,
|
icon = Icons.Default.Folder,
|
||||||
label = stringResource(Res.string.files),
|
label = stringResource(Res.string.files),
|
||||||
|
|
@ -358,6 +363,7 @@ private fun DrawerConversationItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onToggleFavorite: () -> Unit = {},
|
onToggleFavorite: () -> Unit = {},
|
||||||
|
showBookmarkToggle: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
|
val iconPainter = data.endpoint?.let { endpointIconPainter(it) }
|
||||||
|
|
||||||
|
|
@ -455,23 +461,25 @@ private fun DrawerConversationItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Icon(
|
if (showBookmarkToggle) {
|
||||||
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
Icon(
|
||||||
contentDescription = if (data.isFavorite) {
|
imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
|
||||||
stringResource(Res.string.remove_bookmark)
|
contentDescription = if (data.isFavorite) {
|
||||||
} else {
|
stringResource(Res.string.remove_bookmark)
|
||||||
stringResource(Res.string.bookmark)
|
} else {
|
||||||
},
|
stringResource(Res.string.bookmark)
|
||||||
modifier = Modifier
|
},
|
||||||
.size(32.dp)
|
modifier = Modifier
|
||||||
.clickable(onClick = onToggleFavorite)
|
.size(32.dp)
|
||||||
.padding(8.dp),
|
.clickable(onClick = onToggleFavorite)
|
||||||
tint = if (data.isFavorite) {
|
.padding(8.dp),
|
||||||
MaterialTheme.colorScheme.primary
|
tint = if (data.isFavorite) {
|
||||||
} else {
|
MaterialTheme.colorScheme.primary
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
} else {
|
||||||
},
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,7 @@ data class DrawerUiState(
|
||||||
val isRefreshing: Boolean = false,
|
val isRefreshing: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val hasMore: Boolean = true,
|
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.BannerRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
import com.garfiec.librechat.core.data.repository.ConversationRepository
|
||||||
|
import com.garfiec.librechat.core.data.repository.RoleRepository
|
||||||
import com.garfiec.librechat.core.data.repository.TagRepository
|
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.Banner
|
||||||
import com.garfiec.librechat.core.model.Conversation
|
import com.garfiec.librechat.core.model.Conversation
|
||||||
import com.garfiec.librechat.core.model.SAVED_TAG
|
import com.garfiec.librechat.core.model.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 com.garfiec.librechat.core.network.client.TokenManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
@ -30,6 +35,8 @@ class NavHostViewModel(
|
||||||
bannerRepository: BannerRepository,
|
bannerRepository: BannerRepository,
|
||||||
configRepository: ConfigRepository,
|
configRepository: ConfigRepository,
|
||||||
private val conversationRepository: ConversationRepository,
|
private val conversationRepository: ConversationRepository,
|
||||||
|
private val roleRepository: RoleRepository,
|
||||||
|
private val sessionTaskRunner: SessionTaskRunner,
|
||||||
private val tagRepository: TagRepository,
|
private val tagRepository: TagRepository,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val settingsDataStore: SettingsDataStore,
|
private val settingsDataStore: SettingsDataStore,
|
||||||
|
|
@ -78,6 +85,21 @@ class NavHostViewModel(
|
||||||
_selectedSettingsCategory.value = category
|
_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(
|
val drawerUiState: StateFlow<DrawerUiState> = combine(
|
||||||
combine(
|
combine(
|
||||||
conversationListStateHolder.groupedConversations,
|
conversationListStateHolder.groupedConversations,
|
||||||
|
|
@ -94,7 +116,8 @@ class NavHostViewModel(
|
||||||
) { refreshing, loadingMore, hasMore ->
|
) { refreshing, loadingMore, hasMore ->
|
||||||
Triple(refreshing, loadingMore, hasMore)
|
Triple(refreshing, loadingMore, hasMore)
|
||||||
},
|
},
|
||||||
) { data, (refreshing, loadingMore, hasMore) ->
|
drawerPermissionFlags,
|
||||||
|
) { data, (refreshing, loadingMore, hasMore), perms ->
|
||||||
DrawerUiState(
|
DrawerUiState(
|
||||||
groupedConversations = data.grouped.map { (group, convos) ->
|
groupedConversations = data.grouped.map { (group, convos) ->
|
||||||
group to convos.map { it.toDrawerDisplayData(data.activeId) }
|
group to convos.map { it.toDrawerDisplayData(data.activeId) }
|
||||||
|
|
@ -104,9 +127,16 @@ class NavHostViewModel(
|
||||||
isRefreshing = refreshing,
|
isRefreshing = refreshing,
|
||||||
isLoadingMore = loadingMore,
|
isLoadingMore = loadingMore,
|
||||||
hasMore = hasMore,
|
hasMore = hasMore,
|
||||||
|
agentsEnabled = perms.agentsEnabled,
|
||||||
|
bookmarksEnabled = perms.bookmarksEnabled,
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState())
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState())
|
||||||
|
|
||||||
|
private data class DrawerPermissionFlags(
|
||||||
|
val agentsEnabled: Boolean = true,
|
||||||
|
val bookmarksEnabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
@ -114,8 +144,10 @@ class NavHostViewModel(
|
||||||
_isLoggedIn.value = loggedIn
|
_isLoggedIn.value = loggedIn
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
versionCheckStateHolder.checkBackendVersion()
|
versionCheckStateHolder.checkBackendVersion()
|
||||||
launch { tagRepository.refreshTags() }
|
// Session tasks for the cold-start case (role fetch, tag refresh,
|
||||||
launch { conversationRepository.syncFavoritesFromServer() }
|
// 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) {
|
} catch (e: Exception) {
|
||||||
Logger.w(e) { "Failed to check auth state on init" }
|
Logger.w(e) { "Failed to check auth state on init" }
|
||||||
|
|
@ -131,10 +163,9 @@ class NavHostViewModel(
|
||||||
fun onAuthComplete() {
|
fun onAuthComplete() {
|
||||||
_isLoggedIn.value = true
|
_isLoggedIn.value = true
|
||||||
conversationListStateHolder.refreshConversations()
|
conversationListStateHolder.refreshConversations()
|
||||||
viewModelScope.launch {
|
// Session tasks (role fetch, tag refresh, favorites sync) already fired from
|
||||||
launch { tagRepository.refreshTags() }
|
// AuthRepositoryImpl on the preceding login/OAuth/2FA success, so we don't
|
||||||
launch { conversationRepository.syncFavoritesFromServer() }
|
// re-run them here — that was the source of the double-fetch on fresh login.
|
||||||
}
|
|
||||||
bannerStateHolder.fetchBanners()
|
bannerStateHolder.fetchBanners()
|
||||||
versionCheckStateHolder.checkBackendVersion()
|
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.MessagesApi
|
||||||
import com.garfiec.librechat.core.network.api.PresetsApi
|
import com.garfiec.librechat.core.network.api.PresetsApi
|
||||||
import com.garfiec.librechat.core.network.api.PromptsApi
|
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.SearchApi
|
||||||
import com.garfiec.librechat.core.network.api.ShareApi
|
import com.garfiec.librechat.core.network.api.ShareApi
|
||||||
import com.garfiec.librechat.core.network.api.SpeechApi
|
import com.garfiec.librechat.core.network.api.SpeechApi
|
||||||
|
|
@ -115,6 +116,7 @@ val iosSharedModule = module {
|
||||||
singleOf(::AgentsApi)
|
singleOf(::AgentsApi)
|
||||||
singleOf(::PresetsApi)
|
singleOf(::PresetsApi)
|
||||||
singleOf(::PromptsApi)
|
singleOf(::PromptsApi)
|
||||||
|
singleOf(::RolesApi)
|
||||||
singleOf(::TagsApi)
|
singleOf(::TagsApi)
|
||||||
singleOf(::ShareApi)
|
singleOf(::ShareApi)
|
||||||
singleOf(::SearchApi)
|
singleOf(::SearchApi)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue