perf(model): share catalog store and show pinned models immediately (#1202)

This commit is contained in:
m57 authored and GitHub committed 2026-09-20 12:31:35 +03:00
1 parent bfe98eb0cb
commit 83fb738ccd
10 files changed
+853 -61

No files matched your search

@@ -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(),