perf(model): share catalog store and show pinned models immediately (#1202)
This commit is contained in:
10 files changed
+853
-61
No files matched your search
@@ -0,0 +1,228 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
data class ModelCatalogState(
|
||||
val scope: DataScope? = null,
|
||||
val providers: List<ModelProvider> = emptyList(),
|
||||
val hasLoaded: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val error: NetworkError? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared in-memory catalog cache partitioned by [DataScope].
|
||||
* Deduplicates in-flight catalog loads and shares parsed [ModelProvider]s
|
||||
* across Chat and Model screens.
|
||||
*/
|
||||
class ModelCatalogStore(
|
||||
private val repository: ModelOptionsRepository = ModelOptionsRepository(),
|
||||
private val getCurrentScope: () -> DataScope? = { AuthManager.currentDataScope() },
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
private val ttlMs: Long = 60_000L,
|
||||
) {
|
||||
private val _state = MutableStateFlow(ModelCatalogState())
|
||||
val state: StateFlow<ModelCatalogState> = _state.asStateFlow()
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var lastLoadedTime: Long = 0L
|
||||
private var activeResult: CompletableDeferred<NetworkResult<ModelOptionsResponse>>? = null
|
||||
private val inFlightFetches = mutableListOf<Job>()
|
||||
private var activeIsForced: Boolean = false
|
||||
private var activeGeneration: Long = 0L
|
||||
|
||||
fun attachScopeObserver(
|
||||
externalScope: CoroutineScope,
|
||||
dataScopeFlow: Flow<DataScope?>,
|
||||
) {
|
||||
externalScope.launch {
|
||||
dataScopeFlow.collect { newScope ->
|
||||
onScopeChanged(newScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onScopeChanged(newScope: DataScope?) {
|
||||
mutex.withLock {
|
||||
if (_state.value.scope != newScope) {
|
||||
resetForScopeLocked(newScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates all work belonging to the previous data scope.
|
||||
*
|
||||
* Unlike force-refresh supersession, a scope switch really does make the
|
||||
* old network work invalid, so those fetches are cancelled.
|
||||
*/
|
||||
private fun resetForScopeLocked(newScope: DataScope?) {
|
||||
inFlightFetches.forEach { it.cancel() }
|
||||
inFlightFetches.clear()
|
||||
|
||||
activeResult?.cancel()
|
||||
activeResult = null
|
||||
activeIsForced = false
|
||||
activeGeneration++
|
||||
lastLoadedTime = 0L
|
||||
_state.value = ModelCatalogState(scope = newScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one physical catalog request for the current logical load.
|
||||
*
|
||||
* Multiple callers await [resultGate]. A forced refresh may supersede a
|
||||
* normal physical request without cancelling that gate, so callers already
|
||||
* waiting on the normal request transparently receive the forced result.
|
||||
*/
|
||||
private fun startFetchLocked(
|
||||
refresh: Boolean,
|
||||
requestScope: DataScope?,
|
||||
generation: Long,
|
||||
resultGate: CompletableDeferred<NetworkResult<ModelOptionsResponse>>,
|
||||
) {
|
||||
inFlightFetches.removeAll { it.isCompleted }
|
||||
inFlightFetches +=
|
||||
scope.launch {
|
||||
val result =
|
||||
try {
|
||||
repository.load(refresh = refresh)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
NetworkResult.Failure(
|
||||
NetworkError.Unknown(t.message ?: "Unknown error", t),
|
||||
)
|
||||
}
|
||||
|
||||
mutex.withLock {
|
||||
// A newer forced request or scope switch owns publication.
|
||||
if (
|
||||
generation != activeGeneration ||
|
||||
_state.value.scope != requestScope ||
|
||||
activeResult !== resultGate
|
||||
) {
|
||||
return@withLock
|
||||
}
|
||||
|
||||
activeResult = null
|
||||
activeIsForced = false
|
||||
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
lastLoadedTime = clock()
|
||||
_state.update {
|
||||
it.copy(
|
||||
providers = result.data.providers.orEmpty(),
|
||||
hasLoaded = true,
|
||||
isRefreshing = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isRefreshing = false,
|
||||
error = result.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Complete only from the authoritative generation.
|
||||
// Every consumer of this logical load receives the same result.
|
||||
resultGate.complete(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ensureLoaded(forceRefresh: Boolean = false): NetworkResult<ModelOptionsResponse> {
|
||||
val currentScope = getCurrentScope()
|
||||
|
||||
val resultGate =
|
||||
mutex.withLock {
|
||||
// Scope switch check
|
||||
if (_state.value.scope != currentScope) {
|
||||
resetForScopeLocked(currentScope)
|
||||
}
|
||||
|
||||
val now = clock()
|
||||
val isFresh = _state.value.hasLoaded && (now - lastLoadedTime < ttlMs)
|
||||
|
||||
if (!forceRefresh && isFresh) {
|
||||
return NetworkResult.Success(ModelOptionsResponse(_state.value.providers))
|
||||
}
|
||||
|
||||
val existing = activeResult
|
||||
if (existing?.isActive == true) {
|
||||
if (forceRefresh && !activeIsForced) {
|
||||
// Supersede the physical request, but deliberately keep
|
||||
// the shared result gate alive. Existing consumers must
|
||||
// not be cancelled just because another screen refreshed.
|
||||
val generation = ++activeGeneration
|
||||
activeIsForced = true
|
||||
_state.update { it.copy(isRefreshing = true) }
|
||||
startFetchLocked(
|
||||
refresh = true,
|
||||
requestScope = currentScope,
|
||||
generation = generation,
|
||||
resultGate = existing,
|
||||
)
|
||||
}
|
||||
|
||||
existing
|
||||
} else {
|
||||
val generation = ++activeGeneration
|
||||
val newResult = CompletableDeferred<NetworkResult<ModelOptionsResponse>>()
|
||||
|
||||
activeResult = newResult
|
||||
activeIsForced = forceRefresh
|
||||
_state.update { it.copy(isRefreshing = true) }
|
||||
|
||||
startFetchLocked(
|
||||
refresh = forceRefresh,
|
||||
requestScope = currentScope,
|
||||
generation = generation,
|
||||
resultGate = newResult,
|
||||
)
|
||||
|
||||
newResult
|
||||
}
|
||||
}
|
||||
|
||||
return resultGate.await()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val shared by lazy {
|
||||
val store = ModelCatalogStore()
|
||||
store.attachScopeObserver(
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||
AuthManager.dataScopeFlow,
|
||||
)
|
||||
store
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.model.ModelCapabilities
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -46,10 +51,11 @@ class ChatModelSwitchDelegate(
|
||||
private val handleSlashCommand: (command: String) -> Unit,
|
||||
private val fetchContextUsage: () -> Unit,
|
||||
private val onModelSwitchInitiated: () -> Unit = {},
|
||||
private val dataScopeFlow: StateFlow<DataScope?> = AuthManager.dataScopeFlow,
|
||||
private val getModelOptionsCall: suspend (
|
||||
refresh: Boolean,
|
||||
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
|
||||
{ refresh -> safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh) } },
|
||||
{ refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) },
|
||||
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
|
||||
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
|
||||
) {
|
||||
@@ -62,6 +68,43 @@ class ChatModelSwitchDelegate(
|
||||
private var unconfirmedTargetModel: String? = null
|
||||
private var modelSwitchSequence: Long = 0L
|
||||
private var cachedModelOptions: List<ModelProvider> = emptyList()
|
||||
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
|
||||
private var modelOptionsJob: Job? = null
|
||||
private var modelOptionsJobScope: DataScope? = null
|
||||
|
||||
fun attachScopeObserver(externalScope: CoroutineScope) {
|
||||
externalScope.launch {
|
||||
dataScopeFlow.collect { newScope ->
|
||||
if (cachedModelOptionsScope == newScope) return@collect
|
||||
|
||||
// A model catalog belongs to exactly one DataScope. Never let
|
||||
// models or capability flags bleed across profile/server/auth
|
||||
// boundaries while the new scope is reconnecting.
|
||||
cachedModelOptions = emptyList()
|
||||
cachedModelOptionsScope = newScope
|
||||
fastRejectedModels.clear()
|
||||
|
||||
if (modelOptionsJobScope != newScope) {
|
||||
modelOptionsJob?.cancel()
|
||||
modelOptionsJob = null
|
||||
modelOptionsJobScope = null
|
||||
}
|
||||
|
||||
val shouldReload = newScope != null && uiState.value.showModelPicker
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = emptyList(),
|
||||
modelPickerLoading = shouldReload,
|
||||
currentModelCapabilities = null,
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldReload) {
|
||||
loadModelOptions(refresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isModelPickerCommand(command: String): Boolean {
|
||||
val trimmed = command.trim()
|
||||
@@ -75,18 +118,13 @@ class ChatModelSwitchDelegate(
|
||||
}
|
||||
|
||||
fun preloadModelOptions() {
|
||||
scope.launch(ioDispatcher) {
|
||||
val result = getModelOptionsCall(false)
|
||||
if (result is NetworkResult.Success) {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
uiState.update { it.copy(modelPickerPinned = getPinnedModels()) }
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
}
|
||||
loadModelOptions(refresh = false)
|
||||
}
|
||||
|
||||
fun openModelPicker() {
|
||||
val hasCached = cachedModelOptions.isNotEmpty()
|
||||
val currentScope = dataScopeFlow.value
|
||||
val hasCached =
|
||||
cachedModelOptionsScope == currentScope && cachedModelOptions.isNotEmpty()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
showModelPicker = true,
|
||||
@@ -96,36 +134,77 @@ class ChatModelSwitchDelegate(
|
||||
)
|
||||
}
|
||||
if (!hasCached) {
|
||||
refreshModelOptions()
|
||||
loadModelOptions(refresh = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshModelOptions() {
|
||||
loadModelOptions(refresh = true)
|
||||
}
|
||||
|
||||
private fun loadModelOptions(refresh: Boolean) {
|
||||
val requestScope = dataScopeFlow.value
|
||||
|
||||
if (cachedModelOptionsScope != requestScope) {
|
||||
cachedModelOptions = emptyList()
|
||||
cachedModelOptionsScope = requestScope
|
||||
fastRejectedModels.clear()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = emptyList(),
|
||||
currentModelCapabilities = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse only work belonging to this exact DataScope. A reconnect into
|
||||
// another profile/server must not inherit the previous preload.
|
||||
if (modelOptionsJob?.isActive == true) {
|
||||
if (modelOptionsJobScope == requestScope && !refresh) return
|
||||
modelOptionsJob?.cancel()
|
||||
}
|
||||
|
||||
uiState.update { it.copy(modelPickerLoading = true) }
|
||||
scope.launch(ioDispatcher) {
|
||||
val result = getModelOptionsCall(true)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = cachedModelOptions,
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
modelOptionsJobScope = requestScope
|
||||
modelOptionsJob =
|
||||
scope.launch {
|
||||
val result = withContext(ioDispatcher) { getModelOptionsCall(refresh) }
|
||||
|
||||
// The scope may have changed while IO was in flight. Never
|
||||
// publish that old scope's result into the current picker.
|
||||
if (dataScopeFlow.value != requestScope) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerLoading = false,
|
||||
errorMessage = "Failed to load models: ${result.error.message}",
|
||||
)
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
cachedModelOptions = result.data.providers.orEmpty()
|
||||
cachedModelOptionsScope = requestScope
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerProviders = cachedModelOptions,
|
||||
modelPickerPinned = getPinnedModels(),
|
||||
modelPickerLoading = false,
|
||||
)
|
||||
}
|
||||
syncCurrentModelCapabilities()
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
uiState.update {
|
||||
it.copy(
|
||||
modelPickerLoading = false,
|
||||
errorMessage =
|
||||
if (it.showModelPicker || refresh) {
|
||||
"Failed to load models: ${result.error.message}"
|
||||
} else {
|
||||
it.errorMessage
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeModelPicker() {
|
||||
@@ -402,7 +481,13 @@ class ChatModelSwitchDelegate(
|
||||
fun getModelCapabilities(
|
||||
providerSlug: String,
|
||||
modelName: String,
|
||||
): ModelCapabilities? = cachedModelOptions.find { it.slug == providerSlug }?.capabilities?.get(modelName)
|
||||
): ModelCapabilities? {
|
||||
if (cachedModelOptionsScope != dataScopeFlow.value) return null
|
||||
return cachedModelOptions
|
||||
.find { it.slug == providerSlug }
|
||||
?.capabilities
|
||||
?.get(modelName)
|
||||
}
|
||||
|
||||
fun getCurrentModelCapabilities(): ModelCapabilities? {
|
||||
val label = uiState.value.currentSessionModel ?: return null
|
||||
@@ -425,7 +510,7 @@ class ChatModelSwitchDelegate(
|
||||
} else {
|
||||
val provider = label.substring(0, idx)
|
||||
val model = label.substring(idx + 1)
|
||||
val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model)
|
||||
val baseCaps = getModelCapabilities(provider, model)
|
||||
if (fastRejectedModels.contains(label) && baseCaps?.fast == true) {
|
||||
baseCaps.copy(fast = false)
|
||||
} else {
|
||||
|
||||
@@ -479,7 +479,9 @@ class ChatViewModel(
|
||||
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
|
||||
fetchContextUsage = { fetchContextUsage() },
|
||||
onModelSwitchInitiated = { onModelSwitchInitiated() },
|
||||
)
|
||||
).apply {
|
||||
attachScopeObserver(viewModelScope)
|
||||
}
|
||||
|
||||
private val credentialPromptsDelegate =
|
||||
ChatCredentialPromptsDelegate(
|
||||
|
||||
@@ -104,11 +104,13 @@ fun ModelScreen(
|
||||
onRefresh = { viewModel.loadAll(refresh = true) },
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() -> {
|
||||
state.isLoading && state.mainModelProvider.isEmpty() && state.providers.isEmpty() &&
|
||||
state.pinnedModels.isEmpty() -> {
|
||||
SkeletonListState(modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
|
||||
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() -> {
|
||||
state.errorMessage != null && state.providers.isEmpty() && state.mainModelProvider.isEmpty() &&
|
||||
state.pinnedModels.isEmpty() -> {
|
||||
ErrorState(
|
||||
message = state.errorMessage ?: "",
|
||||
onRetry = { viewModel.loadAll() },
|
||||
@@ -119,7 +121,8 @@ fun ModelScreen(
|
||||
!state.isLoading &&
|
||||
!state.catalogLoading &&
|
||||
state.providers.isEmpty() &&
|
||||
state.mainModelProvider.isEmpty() -> {
|
||||
state.mainModelProvider.isEmpty() &&
|
||||
state.pinnedModels.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.model_empty_title),
|
||||
subtitle = stringResource(R.string.model_empty_desc),
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||
import com.m57.hermescontrol.data.ws.ModelOptionsRepository
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -69,14 +70,28 @@ data class ModelUiState(
|
||||
)
|
||||
|
||||
class ModelViewModel(
|
||||
private val modelOptionsRepository: ModelOptionsRepository = ModelOptionsRepository(),
|
||||
private val catalogStore: ModelCatalogStore = ModelCatalogStore.shared,
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
constructor(repository: ModelOptionsRepository) : this(ModelCatalogStore(repository = repository))
|
||||
|
||||
private val _uiState = MutableStateFlow(ModelUiState())
|
||||
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
_uiState.update { it.copy(pinnedModels = AuthManager.getPinnedModels()) }
|
||||
viewModelScope.launch {
|
||||
catalogStore.state.collect { catalogState ->
|
||||
// Empty state is meaningful: it is how ModelCatalogStore
|
||||
// invalidates the previous DataScope. Never ignore it.
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
providers = catalogState.providers,
|
||||
catalogLoading = catalogState.isRefreshing,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAll(refresh: Boolean = false) {
|
||||
@@ -100,10 +115,10 @@ class ModelViewModel(
|
||||
safeApiCall { ApiClient.hermesApi.getMoaModels() }
|
||||
}
|
||||
|
||||
// Phase 2: Launch model options concurrently
|
||||
// Phase 2: Launch model options concurrently via shared catalog store
|
||||
val optionsDeferred =
|
||||
async(Dispatchers.IO) {
|
||||
modelOptionsRepository.load(refresh)
|
||||
async {
|
||||
catalogStore.ensureLoaded(forceRefresh = refresh)
|
||||
}
|
||||
|
||||
// Await Phase 1 calls (~40-190ms total) and update UI immediately with available state
|
||||
@@ -160,12 +175,9 @@ class ModelViewModel(
|
||||
val optionsResult = optionsDeferred.await()
|
||||
when (optionsResult) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
catalogLoading = false,
|
||||
providers = optionsResult.data.providers.orEmpty(),
|
||||
)
|
||||
}
|
||||
// ModelCatalogStore.state is the single source of truth.
|
||||
// Do not independently republish a result here because a
|
||||
// DataScope switch may have invalidated it meanwhile.
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
|
||||
@@ -102,7 +102,7 @@ fun ModelPickerDialog(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
if (isLoading) {
|
||||
if (isLoading && providers.isEmpty() && pinnedModels.isEmpty()) {
|
||||
LoadingState(
|
||||
subtitle = stringResource(R.string.model_picker_loading),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
Reference in new issue
Block a user