diff --git a/package.json b/package.json index ed5c46b..85658c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.5.27", + "version": "0.5.28", "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration", "repository": { "type": "git", @@ -120,4 +120,4 @@ "vue-tsc": "^3.2.8", "ws": "^8.20.0" } -} +} \ No newline at end of file diff --git a/packages/client/src/data/changelog.ts b/packages/client/src/data/changelog.ts index f75decb..e8dfbb4 100644 --- a/packages/client/src/data/changelog.ts +++ b/packages/client/src/data/changelog.ts @@ -6,7 +6,7 @@ export interface ChangelogEntry { export const changelog: ChangelogEntry[] = [ { - version: '0.5.27', + version: '0.5.28', date: '2026-05-17', changes: [ 'changelog.new_0_5_27_1', diff --git a/packages/client/src/stores/hermes/app.ts b/packages/client/src/stores/hermes/app.ts index 8e32ee0..485ca82 100644 --- a/packages/client/src/stores/hermes/app.ts +++ b/packages/client/src/stores/hermes/app.ts @@ -44,7 +44,7 @@ export const useAppStore = defineStore('app', () => { const sessionPersistence = ref(true) const maxTokens = ref(4096) let modelsLoadPromise: Promise | null = null - let modelsLoadedAt = 0 + let modelsLastRequestedAt = 0 async function doUpdate(): Promise { updating.value = true @@ -134,12 +134,12 @@ export const useAppStore = defineStore('app', () => { async function loadModels(force = false) { if (!hasApiKey()) return if (!force && modelsLoadPromise) return modelsLoadPromise - if (!force && modelGroups.value.length > 0 && Date.now() - modelsLoadedAt < MODELS_CACHE_TTL_MS) return + if (!force && modelsLastRequestedAt > 0 && Date.now() - modelsLastRequestedAt < MODELS_CACHE_TTL_MS) return + modelsLastRequestedAt = Date.now() modelsLoadPromise = (async () => { try { const res = await fetchAvailableModels() applyAvailableModelsResponse(res) - modelsLoadedAt = Date.now() } catch { // ignore } finally { @@ -149,6 +149,16 @@ export const useAppStore = defineStore('app', () => { 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(resolve => setTimeout(resolve, timeoutMs)), + ]) + } + async function reloadModels() { return loadModels(true) } @@ -300,6 +310,7 @@ export const useAppStore = defineStore('app', () => { maxTokens, checkConnection, loadModels, + waitForModelsForRun, reloadModels, applyAvailableModelsResponse, switchModel, diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts index 059909e..5c41a91 100644 --- a/packages/client/src/stores/hermes/chat.ts +++ b/packages/client/src/stores/hermes/chat.ts @@ -910,7 +910,7 @@ export const useChatStore = defineStore('chat', () => { } const appStore = useAppStore() - await appStore.loadModels() + await appStore.waitForModelsForRun() const sessionModel = activeSession.value?.model || appStore.selectedModel const isBridgeSource = activeSession.value?.source === 'cli' const sessionProvider = activeSession.value?.provider || appStore.selectedProvider diff --git a/tests/client/app-store.test.ts b/tests/client/app-store.test.ts index c11d202..bac461d 100644 --- a/tests/client/app-store.test.ts +++ b/tests/client/app-store.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' const mockSystemApi = vi.hoisted(() => ({ @@ -23,6 +23,10 @@ describe('App Store', () => { window.localStorage.clear() }) + afterEach(() => { + vi.useRealTimers() + }) + it('persists desktop sidebar collapsed state to localStorage', () => { const store = useAppStore() @@ -224,6 +228,40 @@ describe('App Store', () => { expect(store.displayModelName('unknown', 'deepseek')).toBe('unknown') }) + it('does not refetch available models within the cache window after an empty response', async () => { + mockSystemApi.fetchAvailableModels.mockResolvedValue({ + default: '', + default_provider: '', + groups: [], + allProviders: [], + }) + const store = useAppStore() + + await store.loadModels() + await store.loadModels() + + expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1) + }) + + it('waits only up to the run timeout for the first available models request', async () => { + vi.useFakeTimers() + mockSystemApi.fetchAvailableModels.mockReturnValue(new Promise(() => {})) + const store = useAppStore() + let resolved = false + + const waitPromise = store.waitForModelsForRun(15000).then(() => { + resolved = true + }) + + expect(mockSystemApi.fetchAvailableModels).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(14999) + expect(resolved).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await waitPromise + expect(resolved).toBe(true) + expect(store.modelGroups).toEqual([]) + }) + it('keeps aliases scoped to their provider when model IDs overlap', async () => { mockSystemApi.fetchAvailableModels.mockResolvedValue({ default: 'shared-model',