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

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