Files
Hermes-ui/packages/client/src/stores/hermes/app.ts
T
ekko f61a1d9454 [codex] fix auth startup and profile model defaults (#966)
* docs release 0.6.0 changelog

* fix auth startup and profile model defaults
2026-05-24 14:00:31 +08:00

366 lines
12 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
checkHealth,
fetchAvailableModels,
addCustomModel as persistCustomModel,
removeCustomModel as deletePersistedCustomModel,
updateDefaultModel,
updateModelVisibility,
triggerUpdate,
updateModelAlias,
type AvailableModelGroup,
type AvailableModelsResponse,
type ProfileAvailableModels,
type ModelVisibility,
type ModelVisibilityRule,
} from '@/api/hermes/system'
import { hasApiKey } from '@/api/client'
const WEB_UI_VERSION = __APP_VERSION__
const SIDEBAR_COLLAPSED_KEY = 'hermes_sidebar_collapsed'
const ACTIVE_PROFILE_STORAGE_KEY = 'hermes_active_profile_name'
const MODELS_CACHE_TTL_MS = 30000
export const useAppStore = defineStore('app', () => {
const sidebarOpen = ref(false)
// Desktop-only collapsed state (icon-rail mode). Persisted to localStorage.
const sidebarCollapsed = ref(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1')
const connected = ref(false)
const serverVersion = ref(WEB_UI_VERSION)
const latestVersion = ref('')
const updateAvailable = ref(false)
const clientOutdated = ref(false)
const updating = ref(false)
const modelGroups = ref<AvailableModelGroup[]>([])
const profileModelGroups = ref<ProfileAvailableModels[]>([])
const selectedModel = ref('')
const selectedProvider = ref('')
const customModels = ref<Record<string, string[]>>({})
const modelAliases = ref<Record<string, Record<string, string>>>({})
const modelVisibility = ref<ModelVisibility>({})
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
const nodeVersion = ref('')
// Settings
const streamEnabled = ref(true)
const sessionPersistence = ref(true)
const maxTokens = ref(4096)
let modelsLoadPromise: Promise<void> | null = null
let modelsLastRequestedAt = 0
async function doUpdate(): Promise<boolean> {
updating.value = true
try {
const res = await triggerUpdate()
if (res.success) {
updateAvailable.value = false
await checkConnection()
}
return res.success
} catch (err) {
console.error('Failed to update Hermes Web UI:', err)
return false
} finally {
updating.value = false
}
}
async function checkConnection() {
try {
const res = await checkHealth()
connected.value = res.status === 'ok'
if (res.webui_version) serverVersion.value = res.webui_version
clientOutdated.value = !!res.webui_version && res.webui_version !== WEB_UI_VERSION
if (res.webui_latest) latestVersion.value = res.webui_latest
updateAvailable.value = !!res.webui_update_available
if (res.node_version) nodeVersion.value = res.node_version
} catch {
connected.value = false
clientOutdated.value = false
}
}
function applyAvailableModelsResponse(res: AvailableModelsResponse) {
modelGroups.value = res.groups
profileModelGroups.value = res.profiles || []
modelAliases.value = res.model_aliases || {}
modelVisibility.value = res.model_visibility || {}
customModels.value = res.custom_models || {}
const activeProfileName = localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY) || ''
const activeProfileModels = activeProfileName
? profileModelGroups.value.find(entry => entry.profile === activeProfileName)
: undefined
const defaultSource = activeProfileModels || res
const defaultGroups = defaultSource.groups || []
const defaultModel = defaultSource.default || ''
const defaultProvider = defaultSource.default_provider || ''
const explicitGroup = defaultGroups.find(g => g.provider === defaultProvider && g.models.includes(defaultModel))
const inferredGroup = defaultGroups.find(g => g.models.includes(defaultModel))
const fallbackGroup = defaultGroups.find(g => g.models.length > 0)
const providerGroup = defaultProvider ? defaultGroups.find(g => g.provider === defaultProvider) : undefined
const allProvider = defaultProvider ? res.allProviders.find(g => g.provider === defaultProvider) : undefined
const providerCatalog = providerGroup?.available_models?.length
? providerGroup.available_models
: allProvider?.available_models?.length
? allProvider.available_models
: allProvider?.models || []
const visibilityRule = defaultProvider ? modelVisibility.value[defaultProvider] : undefined
const hiddenByVisibility = !!(
defaultModel &&
visibilityRule?.mode === 'include' &&
!visibilityRule.models.includes(defaultModel) &&
(providerCatalog.length === 0 || providerCatalog.includes(defaultModel))
)
const unlistedDefault = !!(
defaultModel &&
defaultProvider &&
providerGroup &&
!providerGroup.models.includes(defaultModel) &&
!hiddenByVisibility
)
if (explicitGroup || inferredGroup) {
const selectedGroup = explicitGroup || inferredGroup!
selectedModel.value = defaultModel
selectedProvider.value = selectedGroup.provider
} else if (unlistedDefault) {
selectedModel.value = defaultModel
selectedProvider.value = defaultProvider
customModels.value = {
...customModels.value,
[defaultProvider]: Array.from(new Set([...(customModels.value[defaultProvider] || []), defaultModel])),
}
} else if (fallbackGroup) {
selectedModel.value = fallbackGroup.models[0]
selectedProvider.value = fallbackGroup.provider
} else {
selectedModel.value = ''
selectedProvider.value = ''
}
}
async function loadModels(force = false) {
if (!hasApiKey()) return
if (!force && modelsLoadPromise) return modelsLoadPromise
if (!force && modelsLastRequestedAt > 0 && Date.now() - modelsLastRequestedAt < MODELS_CACHE_TTL_MS) return
modelsLastRequestedAt = Date.now()
modelsLoadPromise = (async () => {
try {
const res = await fetchAvailableModels()
applyAvailableModelsResponse(res)
} catch {
// ignore
} finally {
modelsLoadPromise = null
}
})()
return modelsLoadPromise
}
async function waitForModelsForRun(timeoutMs = 15000) {
if (!hasApiKey()) return
const pending = modelsLoadPromise || (modelsLastRequestedAt === 0 ? loadModels() : null)
if (!pending) return
await Promise.race([
pending,
new Promise<void>(resolve => setTimeout(resolve, timeoutMs)),
])
}
async function reloadModels() {
return loadModels(true)
}
function getModelAlias(modelId: string, provider?: string): string {
if (provider) return modelAliases.value[provider]?.[modelId] || ''
for (const aliases of Object.values(modelAliases.value)) {
if (aliases[modelId]) return aliases[modelId]
}
return ''
}
function displayModelName(modelId: string, provider?: string): string {
return getModelAlias(modelId, provider) || modelId
}
function removeModelFromGroupList(groups: AvailableModelGroup[], provider: string, modelId: string): AvailableModelGroup[] {
return groups.map(group => {
if (group.provider !== provider) return group
return {
...group,
models: group.models.filter(model => model !== modelId),
available_models: group.available_models?.filter(model => model !== modelId),
}
})
}
function removeModelFromLoadedGroups(provider: string, modelId: string) {
modelGroups.value = removeModelFromGroupList(modelGroups.value, provider, modelId)
profileModelGroups.value = profileModelGroups.value.map(profileEntry => ({
...profileEntry,
groups: removeModelFromGroupList(profileEntry.groups, provider, modelId),
}))
}
async function setModelAlias(modelId: string, provider: string, alias: string) {
const cleanAlias = alias.trim()
await updateModelAlias({ provider, model: modelId, alias: cleanAlias })
const next = { ...modelAliases.value }
const providerAliases = { ...(next[provider] || {}) }
if (cleanAlias) {
providerAliases[modelId] = cleanAlias
next[provider] = providerAliases
} else {
delete providerAliases[modelId]
if (Object.keys(providerAliases).length > 0) next[provider] = providerAliases
else delete next[provider]
}
modelAliases.value = next
}
async function switchModel(modelId: string, providerOverride?: string) {
try {
// Find the group containing this model to get provider info
const group = modelGroups.value.find(g => g.models.includes(modelId))
const provider = providerOverride || group?.provider || ''
await updateDefaultModel({ default: modelId, provider })
selectedModel.value = modelId
selectedProvider.value = provider || ''
// Track as custom if not already in the server-fetched list
if (provider && !modelGroups.value.find(g => g.provider === provider)?.models.includes(modelId)) {
const res = await persistCustomModel({ provider, model: modelId })
customModels.value = res.custom_models || {}
}
} catch (err: any) {
console.error('Failed to switch model:', err)
}
}
async function removeCustomModel(modelId: string, provider: string) {
const providerModels = customModels.value[provider] || []
if (!providerModels.includes(modelId)) return
const nextCustomModels = { ...customModels.value }
const remaining = providerModels.filter(m => m !== modelId)
if (remaining.length > 0) nextCustomModels[provider] = remaining
else delete nextCustomModels[provider]
try {
const res = await deletePersistedCustomModel({ provider, model: modelId })
customModels.value = res.custom_models || nextCustomModels
} catch (err) {
console.error('Failed to remove custom model:', err)
customModels.value = nextCustomModels
}
removeModelFromLoadedGroups(provider, modelId)
if (selectedModel.value === modelId && selectedProvider.value === provider) {
const providerGroup = modelGroups.value.find(g => g.provider === provider && g.models.length > 0)
const fallbackGroup = providerGroup || modelGroups.value.find(g => g.models.length > 0)
if (fallbackGroup) {
await switchModel(fallbackGroup.models[0], fallbackGroup.provider)
} else {
selectedModel.value = ''
selectedProvider.value = ''
}
}
}
function getProviderVisibility(provider: string): ModelVisibilityRule {
return modelVisibility.value[provider] || { mode: 'all', models: [] }
}
function isModelVisible(provider: string, model: string): boolean {
const rule = getProviderVisibility(provider)
return rule.mode !== 'include' || rule.models.includes(model)
}
async function setModelVisibility(provider: string, rule: ModelVisibilityRule) {
const res = await updateModelVisibility({ provider, mode: rule.mode, models: rule.models })
modelVisibility.value = res.model_visibility || {}
await reloadModels()
}
function startHealthPolling(interval = 30000) {
stopHealthPolling()
checkConnection()
healthPollTimer.value = setInterval(checkConnection, interval)
}
function stopHealthPolling() {
if (healthPollTimer.value) {
clearInterval(healthPollTimer.value)
healthPollTimer.value = undefined
}
}
function reloadClient() {
const url = new URL(window.location.href)
url.searchParams.set('__hwui_reload', Date.now().toString())
window.location.replace(url.toString())
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function closeSidebar() {
sidebarOpen.value = false
}
function toggleSidebarCollapsed() {
sidebarCollapsed.value = !sidebarCollapsed.value
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, sidebarCollapsed.value ? '1' : '0')
} catch {
// ignore quota errors — fallback to in-memory only
}
}
return {
sidebarOpen,
sidebarCollapsed,
toggleSidebar,
closeSidebar,
toggleSidebarCollapsed,
connected,
serverVersion,
latestVersion,
nodeVersion,
updateAvailable,
clientOutdated,
updating,
doUpdate,
reloadClient,
modelGroups,
profileModelGroups,
customModels,
modelAliases,
modelVisibility,
selectedModel,
selectedProvider,
streamEnabled,
sessionPersistence,
maxTokens,
checkConnection,
loadModels,
waitForModelsForRun,
reloadModels,
applyAvailableModelsResponse,
switchModel,
removeCustomModel,
getModelAlias,
displayModelName,
setModelAlias,
getProviderVisibility,
isModelVisible,
setModelVisibility,
startHealthPolling,
stopHealthPolling,
}
})