2026-04-11 15:59:14 +08:00
|
|
|
import { defineStore } from 'pinia'
|
|
|
|
|
import { ref } from 'vue'
|
2026-05-11 16:18:13 +02:00
|
|
|
import {
|
|
|
|
|
checkHealth,
|
|
|
|
|
fetchAvailableModels,
|
|
|
|
|
updateDefaultModel,
|
|
|
|
|
updateModelVisibility,
|
|
|
|
|
triggerUpdate,
|
|
|
|
|
updateModelAlias,
|
|
|
|
|
type AvailableModelGroup,
|
|
|
|
|
type AvailableModelsResponse,
|
|
|
|
|
type ModelVisibility,
|
|
|
|
|
type ModelVisibilityRule,
|
|
|
|
|
} from '@/api/hermes/system'
|
2026-04-16 13:51:42 +08:00
|
|
|
|
|
|
|
|
const WEB_UI_VERSION = __APP_VERSION__
|
2026-04-11 15:59:14 +08:00
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
const SIDEBAR_COLLAPSED_KEY = 'hermes_sidebar_collapsed'
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
export const useAppStore = defineStore('app', () => {
|
2026-04-15 09:12:54 +08:00
|
|
|
const sidebarOpen = ref(false)
|
2026-04-18 00:00:24 +08:00
|
|
|
// Desktop-only collapsed state (icon-rail mode). Persisted to localStorage.
|
|
|
|
|
const sidebarCollapsed = ref(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1')
|
2026-04-15 09:12:54 +08:00
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
const connected = ref(false)
|
2026-04-16 13:51:42 +08:00
|
|
|
const serverVersion = ref(WEB_UI_VERSION)
|
|
|
|
|
const latestVersion = ref('')
|
|
|
|
|
const updateAvailable = ref(false)
|
2026-05-12 03:03:07 +02:00
|
|
|
const clientOutdated = ref(false)
|
2026-04-16 13:51:42 +08:00
|
|
|
const updating = ref(false)
|
2026-04-12 23:23:50 +08:00
|
|
|
const modelGroups = ref<AvailableModelGroup[]>([])
|
|
|
|
|
const selectedModel = ref('')
|
2026-04-19 15:05:05 +08:00
|
|
|
const selectedProvider = ref('')
|
2026-04-24 08:49:45 +08:00
|
|
|
const customModels = ref<Record<string, string[]>>({})
|
2026-05-11 16:18:13 +02:00
|
|
|
const modelAliases = ref<Record<string, Record<string, string>>>({})
|
2026-05-11 15:24:45 +02:00
|
|
|
const modelVisibility = ref<ModelVisibility>({})
|
2026-04-11 15:59:14 +08:00
|
|
|
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
2026-04-23 12:57:42 +08:00
|
|
|
const nodeVersion = ref('')
|
2026-04-11 15:59:14 +08:00
|
|
|
|
|
|
|
|
// Settings
|
|
|
|
|
const streamEnabled = ref(true)
|
|
|
|
|
const sessionPersistence = ref(true)
|
|
|
|
|
const maxTokens = ref(4096)
|
|
|
|
|
|
2026-04-16 13:51:42 +08:00
|
|
|
async function doUpdate(): Promise<boolean> {
|
|
|
|
|
updating.value = true
|
|
|
|
|
try {
|
|
|
|
|
const res = await triggerUpdate()
|
|
|
|
|
if (res.success) {
|
|
|
|
|
updateAvailable.value = false
|
|
|
|
|
await checkConnection()
|
|
|
|
|
}
|
|
|
|
|
return res.success
|
2026-05-10 14:18:52 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to update Hermes Web UI:', err)
|
|
|
|
|
return false
|
2026-04-16 13:51:42 +08:00
|
|
|
} finally {
|
|
|
|
|
updating.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
async function checkConnection() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await checkHealth()
|
2026-04-13 20:08:32 +08:00
|
|
|
connected.value = res.status === 'ok'
|
2026-04-16 13:51:42 +08:00
|
|
|
if (res.webui_version) serverVersion.value = res.webui_version
|
2026-05-12 03:03:07 +02:00
|
|
|
clientOutdated.value = !!res.webui_version && res.webui_version !== WEB_UI_VERSION
|
2026-04-16 13:51:42 +08:00
|
|
|
if (res.webui_latest) latestVersion.value = res.webui_latest
|
|
|
|
|
updateAvailable.value = !!res.webui_update_available
|
2026-04-23 12:57:42 +08:00
|
|
|
if (res.node_version) nodeVersion.value = res.node_version
|
2026-04-11 15:59:14 +08:00
|
|
|
} catch {
|
|
|
|
|
connected.value = false
|
2026-05-12 03:03:07 +02:00
|
|
|
clientOutdated.value = false
|
2026-04-11 15:59:14 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 15:24:45 +02:00
|
|
|
function applyAvailableModelsResponse(res: AvailableModelsResponse) {
|
|
|
|
|
modelGroups.value = res.groups
|
2026-05-11 16:18:13 +02:00
|
|
|
modelAliases.value = res.model_aliases || {}
|
2026-05-11 15:24:45 +02:00
|
|
|
modelVisibility.value = res.model_visibility || {}
|
2026-05-11 16:18:13 +02:00
|
|
|
|
|
|
|
|
const defaultModel = res.default || ''
|
|
|
|
|
const defaultProvider = res.default_provider || ''
|
|
|
|
|
const explicitGroup = res.groups.find(g => g.provider === defaultProvider && g.models.includes(defaultModel))
|
|
|
|
|
const inferredGroup = res.groups.find(g => g.models.includes(defaultModel))
|
2026-05-11 15:24:45 +02:00
|
|
|
const fallbackGroup = res.groups.find(g => g.models.length > 0)
|
2026-05-11 16:18:13 +02:00
|
|
|
|
|
|
|
|
const providerGroup = defaultProvider ? res.groups.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
|
|
|
|
|
customModels.value = {}
|
|
|
|
|
} else if (unlistedDefault) {
|
|
|
|
|
selectedModel.value = defaultModel
|
|
|
|
|
selectedProvider.value = defaultProvider
|
|
|
|
|
customModels.value = { [defaultProvider]: [defaultModel] }
|
|
|
|
|
} else if (fallbackGroup) {
|
|
|
|
|
selectedModel.value = fallbackGroup.models[0]
|
|
|
|
|
selectedProvider.value = fallbackGroup.provider
|
|
|
|
|
customModels.value = {}
|
|
|
|
|
} else {
|
|
|
|
|
selectedModel.value = ''
|
|
|
|
|
selectedProvider.value = ''
|
|
|
|
|
customModels.value = {}
|
|
|
|
|
}
|
2026-05-11 15:24:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
async function loadModels() {
|
|
|
|
|
try {
|
2026-04-24 23:30:34 +08:00
|
|
|
const res = await fetchAvailableModels()
|
2026-05-11 15:24:45 +02:00
|
|
|
applyAvailableModelsResponse(res)
|
2026-04-11 15:59:14 +08:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 16:18:13 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 23:23:50 +08:00
|
|
|
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
|
2026-04-19 15:05:05 +08:00
|
|
|
selectedProvider.value = provider || ''
|
2026-04-24 08:49:45 +08:00
|
|
|
// Track as custom if not already in the server-fetched list
|
|
|
|
|
if (provider && !modelGroups.value.find(g => g.provider === provider)?.models.includes(modelId)) {
|
|
|
|
|
if (!customModels.value[provider]) customModels.value[provider] = []
|
|
|
|
|
if (!customModels.value[provider].includes(modelId)) {
|
|
|
|
|
customModels.value[provider] = [...customModels.value[provider], modelId]
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-12 23:23:50 +08:00
|
|
|
} catch (err: any) {
|
|
|
|
|
console.error('Failed to switch model:', err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 16:18:13 +02:00
|
|
|
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]
|
|
|
|
|
customModels.value = nextCustomModels
|
|
|
|
|
|
|
|
|
|
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 = ''
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-11 15:24:45 +02:00
|
|
|
|
|
|
|
|
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 loadModels()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
function startHealthPolling(interval = 30000) {
|
|
|
|
|
stopHealthPolling()
|
|
|
|
|
checkConnection()
|
|
|
|
|
healthPollTimer.value = setInterval(checkConnection, interval)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stopHealthPolling() {
|
|
|
|
|
if (healthPollTimer.value) {
|
|
|
|
|
clearInterval(healthPollTimer.value)
|
|
|
|
|
healthPollTimer.value = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 03:03:07 +02:00
|
|
|
function reloadClient() {
|
|
|
|
|
const url = new URL(window.location.href)
|
|
|
|
|
url.searchParams.set('__hwui_reload', Date.now().toString())
|
|
|
|
|
window.location.replace(url.toString())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 09:12:54 +08:00
|
|
|
function toggleSidebar() {
|
|
|
|
|
sidebarOpen.value = !sidebarOpen.value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeSidebar() {
|
|
|
|
|
sidebarOpen.value = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 00:00:24 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 15:59:14 +08:00
|
|
|
return {
|
2026-04-15 09:12:54 +08:00
|
|
|
sidebarOpen,
|
2026-04-18 00:00:24 +08:00
|
|
|
sidebarCollapsed,
|
2026-04-15 09:12:54 +08:00
|
|
|
toggleSidebar,
|
|
|
|
|
closeSidebar,
|
2026-04-18 00:00:24 +08:00
|
|
|
toggleSidebarCollapsed,
|
2026-04-11 15:59:14 +08:00
|
|
|
connected,
|
|
|
|
|
serverVersion,
|
2026-04-16 13:51:42 +08:00
|
|
|
latestVersion,
|
2026-04-23 12:57:42 +08:00
|
|
|
nodeVersion,
|
2026-04-16 13:51:42 +08:00
|
|
|
updateAvailable,
|
2026-05-12 03:03:07 +02:00
|
|
|
clientOutdated,
|
2026-04-16 13:51:42 +08:00
|
|
|
updating,
|
|
|
|
|
doUpdate,
|
2026-05-12 03:03:07 +02:00
|
|
|
reloadClient,
|
2026-04-12 23:23:50 +08:00
|
|
|
modelGroups,
|
2026-04-24 08:49:45 +08:00
|
|
|
customModels,
|
2026-05-11 16:18:13 +02:00
|
|
|
modelAliases,
|
2026-05-11 15:24:45 +02:00
|
|
|
modelVisibility,
|
2026-04-12 23:23:50 +08:00
|
|
|
selectedModel,
|
2026-04-19 15:05:05 +08:00
|
|
|
selectedProvider,
|
2026-04-11 15:59:14 +08:00
|
|
|
streamEnabled,
|
|
|
|
|
sessionPersistence,
|
|
|
|
|
maxTokens,
|
|
|
|
|
checkConnection,
|
|
|
|
|
loadModels,
|
2026-05-11 15:24:45 +02:00
|
|
|
applyAvailableModelsResponse,
|
2026-04-12 23:23:50 +08:00
|
|
|
switchModel,
|
2026-05-11 16:18:13 +02:00
|
|
|
removeCustomModel,
|
|
|
|
|
getModelAlias,
|
|
|
|
|
displayModelName,
|
|
|
|
|
setModelAlias,
|
2026-05-11 15:24:45 +02:00
|
|
|
getProviderVisibility,
|
|
|
|
|
isModelVisible,
|
|
|
|
|
setModelVisibility,
|
2026-04-11 15:59:14 +08:00
|
|
|
startHealthPolling,
|
|
|
|
|
stopHealthPolling,
|
|
|
|
|
}
|
|
|
|
|
})
|