diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index fba5caeb91..baa0899e21 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -55,7 +55,7 @@ import { isCurrentGatewaySwitch, registerGatewaySwitchLifecycle } from '@/store/gateway-switch' -import { checkLocalRuntimeUpdate, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' +import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import { notify, notifyError } from '@/store/notifications' import { loadPoolLimits } from '@/store/pool-limits' import { @@ -710,8 +710,6 @@ export function useGatewayBoot({ // that were running before a reload — the backend registry is the // authority; this just resumes following it. watchLocalRuntimeJobs() - // One-per-session engine-update pointer (enabled runtimes only). - void checkLocalRuntimeUpdate() } catch (err) { const mayPublishFailure = !cancelled && (switchToken === null ? !$gatewaySwitching.get() : isCurrentGatewaySwitch(switchToken)) diff --git a/apps/desktop/src/app/settings/local-models-settings.test.tsx b/apps/desktop/src/app/settings/local-models-settings.test.tsx index b176091a1e..5fdee3924b 100644 --- a/apps/desktop/src/app/settings/local-models-settings.test.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.test.tsx @@ -3,7 +3,7 @@ import { MemoryRouter, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/i18n' -import { $localRuntimeJobs } from '@/store/local-runtime-jobs' +import { $localRuntimeInstallStarting, $localRuntimeJobs } from '@/store/local-runtime-jobs' import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' import { LocalModelsSettings } from './local-models-settings' @@ -137,6 +137,100 @@ afterEach(() => { }) describe('LocalModelsSettings', () => { + it.each(['starting', 'running'])( + 'keeps the runtime update view visible with no staged models while %s', + async phase => { + mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, update_available: true }) + + const jobs: LocalRuntimeJob[] = + phase === 'running' + ? [ + { + job_id: 'engine-update', + kind: 'runtime-install', + target: 'target', + model_id: null, + status: 'running', + phase: 'downloading', + detail: 'Downloading engine archive', + total_bytes: 100, + done_bytes: 40, + percent: 40, + error: null + } + ] + : [] + + mocked.getLocalModelsJobs.mockResolvedValue({ jobs }) + $localRuntimeJobs.set(jobs) + $localRuntimeInstallStarting.set(phase === 'starting') + renderPane() + await screen.findByText('Qwen3.6 27B') + const setup = screen.queryByRole('button', { name: /set up for me/i }) + + const detail = + phase === 'running' + ? screen.queryByText('Downloading engine archive') + : screen.queryByRole('button', { name: /update engine/i }) + + act(() => $localRuntimeInstallStarting.set(false)) + expect(setup).toBeNull() + expect(detail).toBeTruthy() + } + ) + it('keeps a failed explicit update visible with a direct retry and no staged models', async () => { + mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, update_available: true }) + $localRuntimeInstallStarting.set(true) + const view = renderPane() + await screen.findByRole('button', { name: /update engine/i }) + + const failed: LocalRuntimeJob = { + job_id: 'failed-update', + total_bytes: null, + done_bytes: 0, + kind: 'runtime-install', + target: 'target', + model_id: null, + status: 'error', + phase: 'download', + detail: '', + error: 'Engine archive unavailable' + } + + mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [failed] }) + act(() => { + $localRuntimeJobs.set([failed]) + $localRuntimeInstallStarting.set(false) + }) + expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull() + expect(screen.getByText('Engine archive unavailable')).toBeTruthy() + view.unmount() + renderPane() + const retry = await screen.findByRole('button', { name: /update engine/i }) + expect((retry as HTMLButtonElement).disabled).toBe(false) + mocked.installLocalRuntime.mockResolvedValue({ backend: 'cpu', job_id: 'retry', tag: 'next' }) + fireEvent.click(retry) + await waitFor(() => expect(mocked.installLocalRuntime).toHaveBeenCalledTimes(1)) + }) + it('starts runtime installation only once while the request is pending', async () => { + let finish!: (value: { backend: string; job_id: string; tag: string }) => void + mocked.installLocalRuntime.mockImplementation( + () => + new Promise(resolve => { + finish = resolve + }) + ) + await renderFullPane() + const button = screen.getByRole('button', { name: /install runtime/i }) + fireEvent.click(button) + fireEvent.click(button) + expect(mocked.installLocalRuntime).toHaveBeenCalledTimes(1) + expect((button as HTMLButtonElement).disabled).toBe(true) + await act(async () => { + finish({ backend: 'cpu', job_id: 'install', tag: 'next' }) + }) + }) + it('offers the runtime install with a plain-language explanation', async () => { await renderFullPane() diff --git a/apps/desktop/src/app/settings/local-models-settings.tsx b/apps/desktop/src/app/settings/local-models-settings.tsx index 865e7c7561..e271c55f5d 100644 --- a/apps/desktop/src/app/settings/local-models-settings.tsx +++ b/apps/desktop/src/app/settings/local-models-settings.tsx @@ -16,7 +16,6 @@ import { getLocalModelsStatus, type HFFileGroup, type HFSearchHit, - installLocalRuntime, listHFRepoFiles, quickstartLocalModels, searchHFModels, @@ -41,9 +40,11 @@ import { } from '@/lib/icons' import { cn } from '@/lib/utils' import { + $localRuntimeInstallStarting, $localRuntimeJobs, runningDownloadFor, runningRuntimeInstall, + startLocalRuntimeInstall, watchLocalRuntimeJobs } from '@/store/local-runtime-jobs' import { notify, notifyError } from '@/store/notifications' @@ -88,6 +89,7 @@ function fitRank(model: LocalCatalogModel): number { export function LocalModelsSettings() { const { t } = useI18n() const copy = t.settings.localModels + const installStarting = useStore($localRuntimeInstallStarting) const [status, setStatus] = useState(null) const [hardware, setHardware] = useState(null) const [catalog, setCatalog] = useState(null) @@ -95,7 +97,7 @@ export function LocalModelsSettings() { const [serverBusy, setServerBusy] = useState(false) // Quickstart escape hatch: true once the user asks for the full pane // (model list, HF browser) instead of the one-button setup card. - const [configure, setConfigure] = useState(false) + const [configure, setConfigure] = useState(() => $localRuntimeInstallStarting.get()) // Jobs live in the app-level store (they must survive this pane // unmounting); the pane just renders the slice it cares about. const jobs = useStore($localRuntimeJobs) @@ -163,15 +165,6 @@ export function LocalModelsSettings() { refresh() }, [refresh, runningCount]) - async function handleInstallRuntime() { - try { - await installLocalRuntime() - watchLocalRuntimeJobs() - } catch (err) { - notifyError(err, copy.installFailed) - } - } - async function handleQuickstart() { try { await quickstartLocalModels() @@ -309,7 +302,9 @@ export function LocalModelsSettings() { const heroModel = catalog.find(c => c.recommended && c.fits) ?? null const hasRecommendation = catalog.some(c => c.recommended) - if (qJob || (needsSetup && !configure && heroModel)) { + const failedInstall = jobs.some(job => job.kind === 'runtime-install' && job.status === 'error') + + if (qJob || (needsSetup && !configure && heroModel && !installStarting && !rJob && !failedInstall)) { // Stage rail derived from the job phase: engine -> model -> finish. const phase = qJob?.phase ?? '' @@ -471,7 +466,7 @@ export function LocalModelsSettings() { ) : ( void handleInstallRuntime()} size="sm"> + @@ -484,7 +479,7 @@ export function LocalModelsSettings() { {status.update_available && !rJob && ( void handleInstallRuntime()} size="sm"> + diff --git a/apps/desktop/src/components/tips/local-runtime-update-offer.test.tsx b/apps/desktop/src/components/tips/local-runtime-update-offer.test.tsx new file mode 100644 index 0000000000..4f1e42f9a3 --- /dev/null +++ b/apps/desktop/src/components/tips/local-runtime-update-offer.test.tsx @@ -0,0 +1,284 @@ +import { useStore } from '@nanostores/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, useLocation, useNavigate } from 'react-router' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { SETTINGS_ROUTE } from '@/app/routes' +import { I18nProvider } from '@/i18n' +import { en } from '@/i18n/en' +import { $activeGatewayRoute } from '@/store/gateway' +import { $localModelsEnabled } from '@/store/local-models-flag' +import { $localRuntimeJobs } from '@/store/local-runtime-jobs' +import { $connection } from '@/store/session' +import { $activeTip, $nextTipAt, $retiredTips, $tipsEnabled, $tipShownAt, retireActiveTip } from '@/store/tips' + +import { offerLocalRuntimeUpdateTip } from './local-runtime-update-offer' +import { TipBubble } from './tip-bubble' + +const api = vi.hoisted(() => vi.fn()) + +const eligible = { enabled: true, runtime_installed: true, update_available: true, configured_tag: 'next-build' } +const anchor = document.createElement('button') +let offer: () => boolean + +function Harness() { + const tip = useStore($activeTip) + const navigate = useNavigate() + const location = useLocation() + offer = () => offerLocalRuntimeUpdateTip(en.tips, () => navigate(`${SETTINGS_ROUTE}?tab=providers&pview=local`)) + + return ( + <> + + {location.pathname} + {location.search} + + {tip && } + + ) +} + +async function due() { + await act(async () => { + offer() + }) + await act(async () => { + offer() + }) +} + +beforeEach(async () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.clearAllMocks() + $localModelsEnabled.set(true) + $connection.set({ mode: 'local' } as never) + $localRuntimeJobs.set([]) + $activeTip.set(null) + $retiredTips.set([]) + $tipShownAt.set({}) + $nextTipAt.set(null) + $tipsEnabled.set(true) + window.hermesDesktop = { api } as never + api.mockImplementation(async request => { + if (request.path.endsWith('/status')) { + return eligible + } + + if (request.path.endsWith('/jobs')) { + return { jobs: [] } + } + + return { job_id: 'install', backend: 'cpu', tag: 'next-build' } + }) + await act(async () => { + render( + + + + + + ) + }) +}) +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) +it('discards a completed read that waited too long for a quiet tick', async () => { + await act(async () => { + offer() + }) + const now = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 120_000) + api.mockImplementation(async request => + request.path.endsWith('/jobs') ? { jobs: [] } : { ...eligible, update_available: false } + ) + await act(async () => { + offer() + }) + expect($activeTip.get()).toBeNull() + await act(async () => { + offer() + }) + expect($activeTip.get()).toBeNull() + clock.mockRestore() +}) +it('lets rotation proceed after a failed eligibility read and retries at a later due moment', async () => { + api.mockRejectedValue(new Error('offline')) + await act(async () => { + offer() + }) + let held = true + await act(async () => { + held = offer() + }) + expect(held).toBe(false) + expect($activeTip.get()).toBeNull() + api.mockImplementation(async request => (request.path.endsWith('/jobs') ? { jobs: [] } : eligible)) + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 6_000) + await due() + expect($activeTip.get()?.action?.label).toBe('Update now') +}) +it('rechecks backend jobs at the due moment instead of trusting an idle store', async () => { + api.mockImplementation(async request => + request.path.endsWith('/jobs') ? { jobs: [{ kind: 'quickstart', status: 'running' }] } : eligible + ) + await due() + expect($activeTip.get()).toBeNull() +}) +it('does not repeat a timed-out bubble for a week, then reads completion live', async () => { + await due() + await act(async () => { + $activeTip.set(null) + }) + await due() + expect($activeTip.get()).toBeNull() + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 8 * 24 * 60 * 60_000) + api.mockImplementation(async request => + request.path.endsWith('/jobs') ? { jobs: [] } : { ...eligible, update_available: false } + ) + await due() + expect($activeTip.get()).toBeNull() + expect(api.mock.calls.filter(([request]) => request.path.endsWith('/status')).length).toBeGreaterThan(1) +}) +it.each(['connection', 'profile', 'tips-off', 'job-started'])( + 'invalidates a visible action when %s changes', + async context => { + await due() + const action = $activeTip.get()!.action! + await act(async () => { + if (context === 'connection') { + $connection.set({ mode: 'remote' } as never) + } + + if (context === 'profile') { + $activeGatewayRoute.set('other') + } + + if (context === 'tips-off') { + $tipsEnabled.set(false) + } + + if (context === 'job-started') { + $localRuntimeJobs.set([{ kind: 'quickstart', status: 'running' }] as never) + } + + action.onSelect() + }) + expect(api.mock.calls.some(([request]) => request.method === 'POST')).toBe(false) + expect(screen.getByRole('status').textContent).toBe('/') + $activeGatewayRoute.set('default') + } +) +it.each(['connection', 'profile'])('rejects a stale response after a %s round trip', async context => { + let resolve!: (value: unknown) => void + api.mockImplementation(request => + request.path.endsWith('/status') + ? new Promise(done => { + resolve = done + }) + : Promise.resolve({ jobs: [] }) + ) + await act(async () => { + offer() + }) + + if (context === 'connection') { + const original = $connection.get() + $connection.set({ mode: 'remote' } as never) + $connection.set(original) + } else { + $activeGatewayRoute.set('other') + $activeGatewayRoute.set('default') + } + + await act(async () => { + resolve(eligible) + }) + await act(async () => { + offer() + }) + expect($activeTip.get()).toBeNull() + await act(async () => { + resolve({ ...eligible, update_available: false }) + }) + await act(async () => { + offer() + }) +}) +it.each(['remote', 'flag-off', 'tips-off', 'runtime-install', 'quickstart'])( + 'declines before reading when %s', + async guard => { + if (guard === 'remote') { + $connection.set({ mode: 'remote' } as never) + } + + if (guard === 'flag-off') { + $localModelsEnabled.set(false) + } + + if (guard === 'tips-off') { + $tipsEnabled.set(false) + } + + if (guard === 'runtime-install' || guard === 'quickstart') { + $localRuntimeJobs.set([{ kind: guard, status: 'running' }] as never) + } + + api.mockClear() + await due() + expect($activeTip.get()).toBeNull() + expect(api).not.toHaveBeenCalled() + } +) +it('dismisses per target build without installing and persists the retirement', async () => { + await due() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: en.tips.close })) + }) + expect(JSON.parse(localStorage.getItem('hermes.desktop.tips.retired.v1')!)).toContain( + 'local-runtime-update:next-build' + ) + await due() + expect($activeTip.get()).toBeNull() + api.mockImplementation(async request => + request.path.endsWith('/jobs') ? { jobs: [] } : { ...eligible, configured_tag: 'later-build' } + ) + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000) + await due() + expect($activeTip.get()?.tipId).toBe('local-runtime-update:later-build') + expect(api.mock.calls.some(([request]) => request.method === 'POST')).toBe(false) +}) +it.each([{ enabled: false }, { runtime_installed: false }, { update_available: false }])( + 'does not offer when backend eligibility is missing: %j', + async overrides => { + api.mockImplementation(async request => + request.path.endsWith('/jobs') ? { jobs: [] } : { ...eligible, ...overrides } + ) + await due() + expect($activeTip.get()).toBeNull() + expect(api.mock.calls.some(([request]) => request.method === 'POST')).toBe(false) + } +) +it('the real Update now button navigates and sends exactly one install POST, never on show', async () => { + await due() + expect(api.mock.calls.filter(([request]) => request.method === 'POST')).toHaveLength(0) + expect(screen.getByText(/active local requests may be interrupted/i)).toBeTruthy() + const button = screen.getByRole('button', { name: 'Update now' }) + await act(async () => { + fireEvent.click(button) + fireEvent.click(button) + }) + expect(screen.getByRole('status').textContent).toBe(`${SETTINGS_ROUTE}?tab=providers&pview=local`) + expect(api.mock.calls.filter(([request]) => request.method === 'POST').map(([request]) => request)).toEqual([ + { body: { backend: null }, method: 'POST', path: '/api/local-models/runtime/install' } + ]) + expect($activeTip.get()).toBeNull() +}) diff --git a/apps/desktop/src/components/tips/local-runtime-update-offer.ts b/apps/desktop/src/components/tips/local-runtime-update-offer.ts new file mode 100644 index 0000000000..63cd19b109 --- /dev/null +++ b/apps/desktop/src/components/tips/local-runtime-update-offer.ts @@ -0,0 +1,141 @@ +import { getLocalModelsJobs, getLocalModelsStatus } from '@/hermes' +import type { Translations } from '@/i18n/types' +import { localSetupDue } from '@/lib/tips/local-cta' +import { $activeGatewayRoute } from '@/store/gateway' +import { $localModelsEnabled } from '@/store/local-models-flag' +import { + $localRuntimeInstallStarting, + $localRuntimeJobs, + localRuntimeInstallBusy, + startLocalRuntimeInstall +} from '@/store/local-runtime-jobs' +import { $connection } from '@/store/session' +import { $activeTip, $retiredTips, $tipsEnabled, $tipShownAt, dismissTip, showTip } from '@/store/tips' +import type { LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' + +let snapshot: { readAt: number; status: LocalModelsStatus | null; jobs: LocalRuntimeJob[] } | null = null +let pending = false +let generation = 0 + +const closeUpdateTip = () => { + if ($activeTip.get()?.tipId?.startsWith('local-runtime-update:')) { + dismissTip() + } +} + +const reset = () => { + generation++ + snapshot = null + pending = false + closeUpdateTip() +} + +$connection.listen(reset) +$activeGatewayRoute.listen(reset) +$tipsEnabled.listen(enabled => { + if (!enabled) { + reset() + } +}) +$localRuntimeJobs.listen(() => { + if (localRuntimeInstallBusy()) { + reset() + } +}) +$localRuntimeInstallStarting.listen(starting => { + if (starting) { + reset() + } +}) + +export function offerLocalRuntimeUpdateTip(copy: Translations['tips'], openLocalModels: () => void): boolean { + if ( + !$localModelsEnabled.get() || + !$tipsEnabled.get() || + $connection.get()?.mode !== 'local' || + localRuntimeInstallBusy() + ) { + return false + } + + const maxAge = snapshot?.status ? 60_000 : 5_000 + + if (snapshot && Date.now() - snapshot.readAt > maxAge) { + snapshot = null + } + + if (!snapshot) { + if (!pending) { + pending = true + const request = generation + void Promise.all([getLocalModelsStatus(), getLocalModelsJobs()]) + .then(([status, { jobs }]) => { + if (request === generation) { + snapshot = { status, jobs, readAt: Date.now() } + } + }) + .catch(() => { + if (request === generation) { + snapshot = { status: null, jobs: [], readAt: Date.now() } + } + }) + .finally(() => { + if (request === generation) { + pending = false + } + }) + } + + return true + } + + // Reuse a fresh answer while the UI checks for a quiet moment each second. + const { status, jobs } = snapshot + + if (jobs.some(job => job.status === 'running' && (job.kind === 'runtime-install' || job.kind === 'quickstart'))) { + return false + } + + if (!status || !status.enabled || !status.runtime_installed || !status.update_available) { + return false + } + + const tipId = `local-runtime-update:${status.configured_tag}` + + if ($retiredTips.get().includes(tipId) || !localSetupDue(Date.now(), $tipShownAt.get()[tipId])) { + return false + } + + const text = copy.items['local-runtime-update'] + const offeredIn = generation + showTip({ + tipId, + title: text.title, + text: text.text, + targets: ['[data-tour="model-pill"]'], + side: 'top', + action: { + label: text.action, + onSelect: () => { + if ( + offeredIn !== generation || + !$tipsEnabled.get() || + !$localModelsEnabled.get() || + $connection.get()?.mode !== 'local' || + localRuntimeInstallBusy() || + $activeTip.get()?.tipId !== tipId + ) { + closeUpdateTip() + + return + } + + dismissTip() + openLocalModels() + void startLocalRuntimeInstall() + } + } + }) + + return true +} diff --git a/apps/desktop/src/components/tips/tutorial-lifetime.test.tsx b/apps/desktop/src/components/tips/tutorial-lifetime.test.tsx index 3a2180a1ab..ec07e597b0 100644 --- a/apps/desktop/src/components/tips/tutorial-lifetime.test.tsx +++ b/apps/desktop/src/components/tips/tutorial-lifetime.test.tsx @@ -15,6 +15,7 @@ vi.mock('@/store/session', async () => { }) vi.mock('react-router', () => ({ useNavigate: () => vi.fn() })) vi.mock('./local-setup-offer', () => ({ offerLocalSetupTip: () => false })) +vi.mock('./local-runtime-update-offer', () => ({ offerLocalRuntimeUpdateTip: () => false })) import { en } from '@/i18n/en' diff --git a/apps/desktop/src/components/tips/use-tip-rotation.test.tsx b/apps/desktop/src/components/tips/use-tip-rotation.test.tsx new file mode 100644 index 0000000000..d267e93402 --- /dev/null +++ b/apps/desktop/src/components/tips/use-tip-rotation.test.tsx @@ -0,0 +1,231 @@ +import { act, cleanup, render } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { en } from '@/i18n/en' +import { $localModelsEnabled } from '@/store/local-models-flag' +import { $awaitingResponse, $busy, $connection } from '@/store/session' +import { $activeTip, $nextTipAt, $retiredTips, $tipsEnabled, $tipShownAt } from '@/store/tips' + +import { offerLocalSetupTip } from './local-setup-offer' +import { useTipRotation } from './use-tip-rotation' + +vi.mock('./local-setup-offer', () => ({ offerLocalSetupTip: vi.fn(() => false) })) +vi.mock('@/store/tutorial-lifetime', () => ({ checkTutorialLifetime: vi.fn() })) + +const reply = async (request: { path: string }) => + request.path.endsWith('/jobs') + ? { jobs: [] } + : { + enabled: true, + runtime_installed: true, + update_available: true, + configured_tag: 'target' + } + +const api = vi.fn(reply) + +function Harness() { + useTipRotation(en.tips) + + return +} + +function mount() { + return render( + + + + ) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(Math, 'random').mockReturnValue(0) + vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 160, 32)) + $connection.set({ mode: 'local' } as never) + $localModelsEnabled.set(true) + $tipsEnabled.set(true) + $activeTip.set(null) + $nextTipAt.set(null) + $retiredTips.set([]) + $tipShownAt.set({}) + $busy.set(false) + $awaitingResponse.set(false) + vi.clearAllMocks() + api.mockReset().mockImplementation(reply) + window.hermesDesktop = { api } as never +}) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) +it('offers the update at the first quiet moment despite tutorial settling and cooldown', async () => { + $nextTipAt.set(Date.now() + 6 * 60 * 60_000) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect($activeTip.get()?.action?.label).toBe('Update now') + expect(offerLocalSetupTip).not.toHaveBeenCalled() + expect(api.mock.calls.some(([request]) => 'method' in request && request.method === 'POST')).toBe(false) +}) + +it('does not turn the quiet-moment check into constant backend polling', async () => { + api.mockImplementation(async request => + request.path.endsWith('/jobs') + ? { jobs: [] } + : { + enabled: true, + runtime_installed: true, + update_available: false, + configured_tag: 'target' + } + ) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(50_000) + }) + expect(api.mock.calls.filter(([request]) => request.path.endsWith('/status'))).toHaveLength(1) + expect($activeTip.get()).toBeNull() +}) + +it('retries a transient startup failure within a few seconds', async () => { + api.mockRejectedValueOnce(new Error('backend starting')) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(8_000) + }) + expect($activeTip.get()?.action?.label).toBe('Update now') +}) + +it('waits for an active response and typing to stop', async () => { + $busy.set(true) + $awaitingResponse.set(true) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(api).not.toHaveBeenCalled() + $busy.set(false) + $awaitingResponse.set(false) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000) + }) + expect($activeTip.get()).toBeNull() + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect($activeTip.get()?.action?.label).toBe('Update now') +}) + +it('waits for a focused chat with no menu open', async () => { + const focus = vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(false) + const menu = globalThis.document.createElement('div') + menu.setAttribute('role', 'menu') + globalThis.document.body.append(menu) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + focus.mockReturnValue(true) + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(api).not.toHaveBeenCalled() + menu.remove() + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect($activeTip.get()?.action?.label).toBe('Update now') +}) + +it('does not consume the update before the model pill is visible', async () => { + const geometry = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect()) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(api).not.toHaveBeenCalled() + expect($tipShownAt.get()).toEqual({}) + geometry.mockReturnValue(new DOMRect(0, 0, 160, 32)) + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect($activeTip.get()?.action?.label).toBe('Update now') +}) + +it.each(['flag-off', 'tips-off', 'remote'])('does not read or offer when %s', async guard => { + if (guard === 'flag-off') { + $localModelsEnabled.set(false) + } + + if (guard === 'tips-off') { + $tipsEnabled.set(false) + } + + if (guard === 'remote') { + $connection.set({ mode: 'remote' } as never) + } + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(api).not.toHaveBeenCalled() + expect($activeTip.get()).toBeNull() +}) + +it('keeps tutorial settling and cooldown when no engine update is available', async () => { + api.mockImplementation(async request => + request.path.endsWith('/jobs') + ? { jobs: [] } + : { + enabled: true, + runtime_installed: true, + update_available: false, + configured_tag: 'target' + } + ) + mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(4 * 60_000) + }) + expect(offerLocalSetupTip).not.toHaveBeenCalled() + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(offerLocalSetupTip).toHaveBeenCalledTimes(1) + $nextTipAt.set(Date.now() + 6 * 60 * 60_000) + $activeTip.set(null) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(offerLocalSetupTip).toHaveBeenCalledTimes(1) +}) + +it('stops the fast check when the host unmounts', async () => { + api.mockImplementation(async request => + request.path.endsWith('/jobs') + ? { jobs: [] } + : { + enabled: true, + runtime_installed: true, + update_available: false, + configured_tag: 'target' + } + ) + const view = mount() + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + const calls = api.mock.calls.length + view.unmount() + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000) + }) + expect(api).toHaveBeenCalledTimes(calls) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/apps/desktop/src/components/tips/use-tip-rotation.ts b/apps/desktop/src/components/tips/use-tip-rotation.ts index ce2c69d6a7..2e9482aff9 100644 --- a/apps/desktop/src/components/tips/use-tip-rotation.ts +++ b/apps/desktop/src/components/tips/use-tip-rotation.ts @@ -28,13 +28,16 @@ import type { Translations } from '@/i18n/types' import { resolveTipAnchor } from '@/lib/tips/anchor' import { TIP_CATALOG } from '@/lib/tips/catalog' import { nextTip } from '@/lib/tips/rotation' +import { $localModelsEnabled } from '@/store/local-models-flag' import { $awaitingResponse, $busy } from '@/store/session' import { $activeTip, $lastTipId, $nextTipAt, $retiredTips, $tipsEnabled, $tipShownAt, showTip } from '@/store/tips' import { checkTutorialLifetime } from '@/store/tutorial-lifetime' +import { offerLocalRuntimeUpdateTip } from './local-runtime-update-offer' import { offerLocalSetupTip } from './local-setup-offer' const TICK_MS = 30_000 +const UPDATE_TICK_MS = 1_000 /** Nothing in the first stretch of a launch, however long the cooldown says * it's been: you opened the app to do a thing, and the tip can wait until * you've done it. Jittered so it isn't the same beat every time. */ @@ -84,6 +87,25 @@ export function useTipRotation(copy: Translations['tips']) { return Date.now() >= settledAt && (nextAt === null || Date.now() >= nextAt) } + const openLocalModels = () => { + navigate(`${SETTINGS_ROUTE}?tab=providers&pview=local`) + } + + // Engine updates use the first quiet chat moment, independently of the tutorial clock. + const offerUpdate = () => { + if ( + !$localModelsEnabled.get() || + !$tipsEnabled.get() || + $activeTip.get() || + !appIsQuiet(lastTypedAt) || + !resolveTipAnchor(document, ['[data-tour="model-pill"]']) + ) { + return false + } + + return offerLocalRuntimeUpdateTip(copy, openLocalModels) + } + const offer = () => { checkTutorialLifetime() @@ -99,11 +121,7 @@ export function useTipRotation(copy: Translations['tips']) { // live right now (the local-setup CTA) says something about THIS // machine, which beats the catalog's standing introduction. It shares // the cooldown, so taking the moment still costs it the usual hours. - if ( - offerLocalSetupTip(copy, () => { - navigate(`${SETTINGS_ROUTE}?tab=providers&pview=local`) - }) - ) { + if (offerUpdate() || offerLocalSetupTip(copy, openLocalModels)) { return } @@ -141,17 +159,25 @@ export function useTipRotation(copy: Translations['tips']) { const unbindSwitch = $tipsEnabled.listen(enabled => { if (enabled) { settledAt = Date.now() + offerUpdate() offer() } }) const timer = window.setInterval(offer, TICK_MS) + const updateTimer = $localModelsEnabled.get() ? window.setInterval(offerUpdate, UPDATE_TICK_MS) : undefined + offerUpdate() window.addEventListener('keydown', noteTyping, true) return () => { unbindSwitch() window.clearInterval(timer) + + if (updateTimer !== undefined) { + window.clearInterval(updateTimer) + } + window.removeEventListener('keydown', noteTyping, true) } }, [copy, navigate]) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 13bd6babe7..28b079806d 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -3230,6 +3230,11 @@ export const ar = defineLocale({ title: 'المرفقات والأوامر', text: 'اكتب @ لإحضار ملف إلى المحادثة، و / لتشغيل أمر.' }, + 'local-runtime-update': { + title: 'يتوفر تحديث للمحرك المحلي', + text: 'حدّث المحرك الذي يشغّل نماذجك المحلية. قد تنقطع الطلبات المحلية الجارية.', + action: 'التحديث الآن' + }, 'local-setup': { title: 'هذا الجهاز يمكنه تشغيل النماذج محليًا', text: 'عتادك قادر على تشغيل نموذج محلي. تبقى محادثاتك على جهازك ولا تكلف شيئًا.', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3014a98acb..00bc2042e7 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1390,8 +1390,7 @@ export const en: Translations = { updateAction: 'Update engine', updating: 'Updating engine…', upToDateTitle: 'Engine up to date', - upToDateDetail: (tag, backend) => `Running llama.cpp ${tag} (${backend}) — the latest build Hermes ships.`, - updateToast: next => `A newer local engine build (${next}) is available. Update from Settings → Local Models.`, + upToDateDetail: (tag, backend) => `Running llama.cpp ${tag} (${backend}) — the configured build.`, activeDetail: 'New chats use this model — it loads when you send your first message', activeNotLoaded: 'Loads on your first message', loadedPill: 'In memory', @@ -4211,6 +4210,11 @@ export const en: Translations = { title: 'Attach and command', text: 'Type @ to bring a file into the conversation, / to run a command.' }, + 'local-runtime-update': { + title: 'A local engine update is available', + text: 'Update the engine that runs your local models. Active local requests may be interrupted.', + action: 'Update now' + }, 'local-setup': { title: 'This machine can run models locally', text: 'Your hardware can serve a local model. Chats stay on your computer and cost nothing.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 61dddd8668..866cffe355 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1210,9 +1210,7 @@ export const ja = defineLocale({ updateAction: 'エンジンを更新', updating: 'エンジンを更新中…', upToDateTitle: 'エンジンは最新です', - upToDateDetail: (tag, backend) => `llama.cpp ${tag}(${backend})で動作中——Hermes が提供する最新ビルドです。`, - updateToast: next => - `ローカルエンジンの新しいビルド(${next})があります。設定 → ローカルモデル から更新できます。`, + upToDateDetail: (tag, backend) => `llama.cpp ${tag}(${backend})で動作中——設定されたビルドです。`, activeDetail: '新しいチャットはこのモデルを使用——最初のメッセージ送信時に読み込みます', activeNotLoaded: '最初のメッセージで読み込みます', loadedPill: '読み込み済み', @@ -3628,6 +3626,11 @@ export const ja = defineLocale({ title: 'ファイルとコマンド', text: '@ でファイルを会話に取り込み、/ でコマンドを実行できます。' }, + 'local-runtime-update': { + title: 'ローカルエンジンの更新があります', + text: 'ローカルモデルを実行するエンジンを更新します。実行中のローカルリクエストが中断される場合があります。', + action: '今すぐ更新' + }, 'local-setup': { title: 'このマシンはローカルでモデルを実行できます', text: 'お使いのハードウェアでローカルモデルを動かせます。会話はこのコンピュータから出ず、料金もかかりません。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index baf2a1b206..68e4dcab5f 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1208,7 +1208,6 @@ export interface Translations { updating: string upToDateTitle: string upToDateDetail: (tag: string, backend: string) => string - updateToast: (next: string) => string activeDetail: string activeNotLoaded: string loadedPill: string @@ -3665,6 +3664,7 @@ export interface Translations { * campaign tips, which live outside the rotation's catalog: they carry * a button, and `action` is its label. */ items: Record & { + 'local-runtime-update': { title: string; text: string; action: string } 'local-setup': { title: string; text: string; action: string } } } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 846b41d5ec..7039c62369 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1231,8 +1231,7 @@ export const zhHant = defineLocale({ updateAction: '更新引擎', updating: '正在更新引擎…', upToDateTitle: '引擎已是最新', - upToDateDetail: (tag, backend) => `正在執行 llama.cpp ${tag}(${backend})——Hermes 提供的最新組建。`, - updateToast: next => `本地引擎有新組建(${next})。可在 設定 → 本地模型 中更新。`, + upToDateDetail: (tag, backend) => `正在執行 llama.cpp ${tag}(${backend})——已設定的組建。`, activeDetail: '新對話使用此模型——傳送首條訊息時載入', activeNotLoaded: '首條訊息時載入', loadedPill: '已載入', @@ -3556,6 +3555,11 @@ export const zhHant = defineLocale({ title: '附件與指令', text: '輸入 @ 把檔案帶入對話,輸入 / 執行指令。' }, + 'local-runtime-update': { + title: '本機引擎有可用更新', + text: '更新執行本機模型的引擎。進行中的本機請求可能會中斷。', + action: '立即更新' + }, 'local-setup': { title: '這台電腦可以本地執行模型', text: '你的硬體可以執行本地模型。對話不離開你的電腦,而且完全免費。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 2d6f87f884..7ec9071a7d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1599,8 +1599,7 @@ export const zh = defineLocale({ updateAction: '更新引擎', updating: '正在更新引擎…', upToDateTitle: '引擎已是最新', - upToDateDetail: (tag, backend) => `正在运行 llama.cpp ${tag}(${backend})——Hermes 提供的最新构建。`, - updateToast: next => `本地引擎有新构建(${next})。可在 设置 → 本地模型 中更新。`, + upToDateDetail: (tag, backend) => `正在运行 llama.cpp ${tag}(${backend})——已配置的构建。`, activeDetail: '新对话使用此模型——发送首条消息时加载', activeNotLoaded: '首条消息时加载', loadedPill: '已加载', @@ -4350,6 +4349,11 @@ export const zh = defineLocale({ title: '附件与命令', text: '输入 @ 把文件带入对话,输入 / 运行命令。' }, + 'local-runtime-update': { + title: '本地引擎有可用更新', + text: '更新运行本地模型的引擎。正在进行的本地请求可能会中断。', + action: '立即更新' + }, 'local-setup': { title: '这台电脑可以本地运行模型', text: '你的硬件可以运行本地模型。对话不离开你的电脑,而且完全免费。', diff --git a/apps/desktop/src/store/local-runtime-jobs.test.ts b/apps/desktop/src/store/local-runtime-jobs.test.ts new file mode 100644 index 0000000000..4ba2a6c3f4 --- /dev/null +++ b/apps/desktop/src/store/local-runtime-jobs.test.ts @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { setApiRequestConnection, setApiRequestProfile } from '@/api/client' +import { $activeGatewayRoute } from '@/store/gateway' +import { $localModelsEnabled } from '@/store/local-models-flag' +import { $connection } from '@/store/session' +import type { LocalRuntimeJob } from '@/types/hermes' + +import { + $localRuntimeInstallStarting, + $localRuntimeJobs, + startLocalRuntimeInstall, + watchLocalRuntimeJobs +} from './local-runtime-jobs' + +const notices = vi.hoisted(() => ({ notify: vi.fn(), notifyError: vi.fn() })) +vi.mock('@/store/notifications', () => notices) +const api = vi.fn() + +const job: LocalRuntimeJob = { + job_id: 'install', + kind: 'runtime-install', + target: 'target', + model_id: null, + status: 'running', + phase: 'download', + detail: 'Real download', + total_bytes: 100, + done_bytes: 25, + percent: 25, + error: null +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + + return { promise, resolve, reject } +} + +async function flush() { + for (let i = 0; i < 12; i++) { + await Promise.resolve() + } +} + +let contextNumber = 0 +const posts = () => api.mock.calls.filter(([request]) => request.method === 'POST') +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + api.mockReset() + setApiRequestConnection(null) + $localModelsEnabled.set(true) + $connection.set({ mode: 'local', baseUrl: `http://test-${++contextNumber}` } as never) + setApiRequestProfile('default') + $activeGatewayRoute.set('default') + $localRuntimeJobs.set([]) + window.hermesDesktop = { api } as never +}) +afterEach(async () => { + api.mockResolvedValue({ jobs: [] }) + await vi.advanceTimersByTimeAsync(700) + $connection.set(null) + vi.clearAllTimers() + vi.useRealTimers() +}) +it.each(['connection', 'profile'])('retains late install acceptance after a %s round trip', async context => { + const post = deferred<{ job_id: string }>() + api.mockImplementation(request => (request.method === 'POST' ? post.promise : Promise.resolve({ jobs: [job] }))) + const first = startLocalRuntimeInstall() + const original = $connection.get() + + if (context === 'connection') { + $connection.set({ mode: 'remote' } as never) + $connection.set(original) + } else { + $activeGatewayRoute.set('other') + $activeGatewayRoute.set('default') + } + + void startLocalRuntimeInstall() + expect(posts()).toHaveLength(1) + post.resolve({ job_id: 'install' }) + await first + expect(posts()).toHaveLength(1) + expect($localRuntimeJobs.get()).toEqual([job]) + expect($localRuntimeInstallStarting.get()).toBe(false) +}) +it.each(['connection', 'profile'])('ignores late job completion and notifications after a %s switch', async context => { + api.mockResolvedValue({ jobs: [job] }) + watchLocalRuntimeJobs() + await flush() + const read = deferred<{ jobs: LocalRuntimeJob[] }>() + api.mockReturnValue(read.promise) + await vi.advanceTimersByTimeAsync(700) + + if (context === 'connection') { + $connection.set({ mode: 'remote' } as never) + } else { + $activeGatewayRoute.set('other') + } + + read.resolve({ jobs: [{ ...job, status: 'done' }] }) + await flush() + expect($localRuntimeJobs.get()).toEqual([]) + expect(notices.notify).not.toHaveBeenCalled() + expect(notices.notifyError).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) +}) +it('does not let an old POST failure clear a new context start or notify it', async () => { + const old = deferred<{ job_id: string }>() + const current = deferred<{ job_id: string }>() + api.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise).mockResolvedValue({ jobs: [] }) + const first = startLocalRuntimeInstall() + $activeGatewayRoute.set('other') + const second = startLocalRuntimeInstall() + old.reject(new Error('old connection failed')) + await first + const stillStarting = $localRuntimeInstallStarting.get() + current.resolve({ job_id: 'new' }) + await second + expect(stillStarting).toBe(true) + expect(notices.notifyError).not.toHaveBeenCalled() + expect(posts()).toHaveLength(2) +}) +it.each(['connection', 'profile'])( + 'resumes accepted work on a %s round trip before permitting another install', + async context => { + api.mockImplementation(request => + Promise.resolve(request.method === 'POST' ? { job_id: job.job_id } : { jobs: [job] }) + ) + await startLocalRuntimeInstall() + const original = $connection.get() + + if (context === 'profile') { + $activeGatewayRoute.set('other') + setApiRequestProfile('other') + } else { + $connection.set({ mode: 'remote', baseUrl: 'http://other' } as never) + } + + const readsBefore = api.mock.calls.length + await vi.advanceTimersByTimeAsync(1400) + expect(api.mock.calls).toHaveLength(readsBefore) + expect($localRuntimeJobs.get()).toEqual([]) + const discovery = deferred<{ jobs: LocalRuntimeJob[] }>() + api.mockReturnValue(discovery.promise) + + if (context === 'profile') { + $activeGatewayRoute.set('default') + } else { + $connection.set({ ...original } as never) + } + + setApiRequestProfile('default') + void startLocalRuntimeInstall() + await flush() + expect(posts()).toHaveLength(1) + expect(api.mock.calls).toHaveLength(readsBefore + 1) + expect(api.mock.calls.at(-1)?.[0].profile).toBe('default') + expect($localRuntimeInstallStarting.get()).toBe(true) + discovery.resolve({ jobs: [{ ...job, status: 'done' }] }) + await flush() + expect($localRuntimeJobs.get()[0].status).toBe('done') + expect(notices.notify).toHaveBeenCalledTimes(1) + await startLocalRuntimeInstall() + expect(posts()).toHaveLength(2) + } +) +it.each(['error', 'done'] as const)( + 'notifies an accepted early %s exactly once after a polling outage, not history', + async status => { + const terminal = { ...job, job_id: 'accepted-id', status, error: status === 'error' ? 'download failed' : null } + const jobs = [{ ...terminal, job_id: 'unrelated-history' }, terminal] + api + .mockResolvedValueOnce({ job_id: 'accepted-id' }) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ jobs }) + await startLocalRuntimeInstall() + expect($localRuntimeInstallStarting.get()).toBe(true) + $activeGatewayRoute.set('other') + setApiRequestProfile('other') + await vi.advanceTimersByTimeAsync(1400) + expect(api).toHaveBeenCalledTimes(2) + $activeGatewayRoute.set('default') + setApiRequestProfile('default') + await flush() + expect($localRuntimeInstallStarting.get()).toBe(false) + const expected = status === 'error' ? notices.notifyError : notices.notify + expect(expected).toHaveBeenCalledTimes(1) + expect(status === 'error' ? notices.notify : notices.notifyError).not.toHaveBeenCalled() + watchLocalRuntimeJobs() + await flush() + $activeGatewayRoute.set('other') + $activeGatewayRoute.set('default') + watchLocalRuntimeJobs() + await flush() + expect(expected).toHaveBeenCalledTimes(1) + } +) +it('notifies accepted failure even when a pane read saw it before the POST response', async () => { + const post = deferred<{ job_id: string }>() + const failed = { ...job, job_id: 'fast-failure', status: 'error' as const, error: 'download failed' } + api.mockImplementation(request => (request.method === 'POST' ? post.promise : Promise.resolve({ jobs: [failed] }))) + const starting = startLocalRuntimeInstall() + watchLocalRuntimeJobs() + await flush() + expect(notices.notifyError).not.toHaveBeenCalled() + post.resolve({ job_id: failed.job_id }) + await starting + expect(notices.notifyError).toHaveBeenCalledTimes(1) + watchLocalRuntimeJobs() + await flush() + expect(notices.notifyError).toHaveBeenCalledTimes(1) +}) +it('isolates registry sources with the same profile and defers reads until activation settles', async () => { + const original = $connection.get() + setApiRequestConnection('source-a') + $connection.set({ ...original, connectionId: 'source-a' } as never) + api.mockImplementation(request => + Promise.resolve(request.method === 'POST' ? { job_id: job.job_id } : { jobs: [job] }) + ) + await startLocalRuntimeInstall() + setApiRequestConnection('source-b') + $connection.set({ ...original, connectionId: 'source-b' } as never) + await flush() + expect($localRuntimeJobs.get()).toEqual([]) + expect(api).toHaveBeenCalledTimes(2) + setApiRequestProfile('other') + setApiRequestConnection('source-a') + $connection.set({ ...original, connectionId: 'source-a' } as never) + // The REST tag is assigned by the activation callback AFTER the atoms. + expect(api).toHaveBeenCalledTimes(2) + setApiRequestProfile('default') + await flush() + expect(api.mock.calls.at(-1)?.[0]).toMatchObject({ connectionId: 'source-a', profile: 'default' }) + expect($localRuntimeJobs.get()).toEqual([job]) + await startLocalRuntimeInstall() + expect(posts()).toHaveLength(1) +}) +it('does not discover unused contexts or automatically resume with the launch flag off', async () => { + $activeGatewayRoute.set('unused') + $activeGatewayRoute.set('default') + await flush() + expect(api).not.toHaveBeenCalled() + api.mockImplementation(request => + Promise.resolve(request.method === 'POST' ? { job_id: job.job_id } : { jobs: [job] }) + ) + await startLocalRuntimeInstall() + $localModelsEnabled.set(false) + $activeGatewayRoute.set('other') + $activeGatewayRoute.set('default') + await flush() + await vi.advanceTimersByTimeAsync(1400) + expect(api).toHaveBeenCalledTimes(2) +}) +it.each(['connection', 'profile'])('reports a POST failure after returning to its owning %s', async context => { + const post = deferred<{ job_id: string }>() + api.mockReturnValue(post.promise) + const starting = startLocalRuntimeInstall() + const original = $connection.get() + + if (context === 'connection') { + $connection.set({ mode: 'remote' } as never) + $connection.set(original) + } else { + $activeGatewayRoute.set('other') + $activeGatewayRoute.set('default') + } + + post.reject(new Error('installation request failed')) + await starting + expect(notices.notifyError).toHaveBeenCalledTimes(1) + expect($localRuntimeInstallStarting.get()).toBe(false) + expect(posts()).toHaveLength(1) +}) +it('releases a failed POST for retry', async () => { + api + .mockRejectedValueOnce(new Error('offline')) + .mockImplementation(request => Promise.resolve(request.method === 'POST' ? { job_id: 'install' } : { jobs: [job] })) + await startLocalRuntimeInstall() + expect($localRuntimeInstallStarting.get()).toBe(false) + expect(notices.notifyError).toHaveBeenCalledTimes(1) + await startLocalRuntimeInstall() + expect(posts()).toHaveLength(2) + expect($localRuntimeJobs.get()).toEqual([job]) +}) +it('retries a failed post-acceptance jobs read without inventing progress or allowing another POST', async () => { + api + .mockResolvedValueOnce({ job_id: 'install' }) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ jobs: [job] }) + await startLocalRuntimeInstall() + const lockedAfterFailure = $localRuntimeInstallStarting.get() + const snapshotAfterFailure = $localRuntimeJobs.get() + await startLocalRuntimeInstall() + await vi.advanceTimersByTimeAsync(700) + expect(lockedAfterFailure).toBe(true) + expect(snapshotAfterFailure).toEqual([]) + expect(posts()).toHaveLength(1) + expect($localRuntimeJobs.get()).toEqual([job]) + expect($localRuntimeInstallStarting.get()).toBe(false) +}) +it('keeps the shared start locked until the first authoritative jobs read finishes', async () => { + const read = deferred<{ jobs: LocalRuntimeJob[] }>() + api.mockImplementation(request => (request.method === 'POST' ? Promise.resolve({ job_id: 'install' }) : read.promise)) + const first = startLocalRuntimeInstall() + await flush() + expect($localRuntimeInstallStarting.get()).toBe(true) + await startLocalRuntimeInstall() + expect(posts()).toHaveLength(1) + read.resolve({ jobs: [job] }) + await first + expect($localRuntimeJobs.get()).toEqual([job]) + expect($localRuntimeInstallStarting.get()).toBe(false) +}) +it('serializes pane polling and reads again after POST acceptance before releasing the lock', async () => { + const post = deferred<{ job_id: string }>() + const stale = deferred<{ jobs: LocalRuntimeJob[] }>() + const fresh = deferred<{ jobs: LocalRuntimeJob[] }>() + let reads = 0 + api.mockImplementation(request => + request.method === 'POST' ? post.promise : ++reads === 1 ? stale.promise : fresh.promise + ) + const first = startLocalRuntimeInstall() + watchLocalRuntimeJobs() + post.resolve({ job_id: 'install' }) + await flush() + watchLocalRuntimeJobs() + expect(reads).toBe(1) + stale.resolve({ jobs: [] }) + await flush() + expect(reads).toBe(2) + expect($localRuntimeInstallStarting.get()).toBe(true) + await startLocalRuntimeInstall() + expect(posts()).toHaveLength(1) + fresh.resolve({ jobs: [job] }) + await first + expect($localRuntimeJobs.get()).toEqual([job]) + expect($localRuntimeInstallStarting.get()).toBe(false) + await vi.advanceTimersByTimeAsync(700) + expect(reads).toBe(3) + expect(vi.getTimerCount()).toBe(1) +}) diff --git a/apps/desktop/src/store/local-runtime-jobs.ts b/apps/desktop/src/store/local-runtime-jobs.ts index 64c343f483..06170cdebc 100644 --- a/apps/desktop/src/store/local-runtime-jobs.ts +++ b/apps/desktop/src/store/local-runtime-jobs.ts @@ -1,8 +1,12 @@ import { atom } from 'nanostores' -import { getLocalModelsJobs, getLocalModelsStatus } from '@/hermes' +import { getApiRequestConnection } from '@/api/client' +import { getLocalModelsJobs, installLocalRuntime } from '@/hermes' import { translateNow } from '@/i18n' +import { $activeGatewayRoute } from '@/store/gateway' +import { $localModelsEnabled } from '@/store/local-models-flag' import { notify, notifyError } from '@/store/notifications' +import { $connection } from '@/store/session' import type { LocalRuntimeJob } from '@/types/hermes' // App-level tracker for local-runtime jobs (runtime installs, model @@ -14,11 +18,144 @@ import type { LocalRuntimeJob } from '@/types/hermes' export const $localRuntimeJobs = atom([]) +export const $localRuntimeInstallStarting = atom(false) + +// Shared by the settings button and the campaign CTA. This is request state, +// not invented job progress; the backend registry still owns the actual work. +export function localRuntimeInstallBusy(): boolean { + return ( + $localRuntimeInstallStarting.get() || + $localRuntimeJobs + .get() + .some(job => job.status === 'running' && (job.kind === 'runtime-install' || job.kind === 'quickstart')) + ) +} + +export async function startLocalRuntimeInstall(): Promise { + if (localRuntimeInstallBusy()) { + return + } + + const owner = activeContext + owner.postPending = true + $localRuntimeInstallStarting.set(true) + + try { + const { job_id } = await installLocalRuntime() + owner.acceptedIds.add(job_id) + owner.postPending = false + owner.readPending = true + owner.acceptedInstall++ + + if (owner !== activeContext) { + return + } + + // The pane can mount while this POST resolves. Hold the shared lock + // through a fresh read, not merely until the request was accepted. + await polling + + if (owner !== activeContext) { + return + } + + await poll() + } catch (error) { + if (owner === activeContext) { + notifyError(error, translateNow('settings.localModels.installFailed')) + } + } finally { + owner.postPending = false + + if (owner === activeContext && !owner.readPending) { + $localRuntimeInstallStarting.set(false) + } + } +} + const POLL_ACTIVE_MS = 700 let timer: null | number = null -let polling = false -// Jobs we've already toasted for, so a poll race can't double-notify. -const settledNotified = new Set() +let polling: Promise | null = null +let generation = 0 +interface JobContext { + jobs: readonly LocalRuntimeJob[] + postPending: boolean + readPending: boolean + acceptedInstall: number + acceptedIds: Set + settledNotified: Set +} + +// Only foreground requests run; switching away retains ownership, not a poller. +const contexts = new Map() + +function contextKey() { + const connection = $connection.get() + + return JSON.stringify([ + getApiRequestConnection() ?? connection?.connectionId ?? [connection?.mode, connection?.baseUrl], + $activeGatewayRoute.get() + ]) +} + +function cachedContext(key: string): JobContext { + let state = contexts.get(key) + + if (!state) { + state = { + jobs: [], + postPending: false, + readPending: false, + acceptedInstall: 0, + acceptedIds: new Set(), + settledNotified: new Set() + } + contexts.set(key, state) + } + + return state +} + +let activeKey = contextKey() +let activeContext = cachedContext(activeKey) + +function resetContext() { + activeContext.jobs = $localRuntimeJobs.get() + const context = ++generation + + if (timer !== null) { + window.clearTimeout(timer) + } + + timer = null + polling = null + activeKey = contextKey() + activeContext = cachedContext(activeKey) + activeContext.readPending ||= activeContext.jobs.some(job => job.status === 'running') + $localRuntimeJobs.set(activeContext.jobs) + $localRuntimeInstallStarting.set(activeContext.postPending || activeContext.readPending) + + // Gateway activation publishes the route BEFORE the REST profile tag. + // Coalesce that synchronous re-home before any ambient API request. + void Promise.resolve().then(() => { + if (context !== generation) { + return + } + + if (activeKey !== contextKey()) { + resetContext() + + return + } + + if ($localModelsEnabled.get() && activeContext.readPending && !activeContext.postPending) { + void poll() + } + }) +} + +$connection.listen(resetContext) +$activeGatewayRoute.listen(resetContext) function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) { if (a.length !== b.length) { @@ -38,14 +175,20 @@ function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) } function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly LocalRuntimeJob[]) { + const { acceptedIds, settledNotified } = activeContext const wasRunning = new Set(previous.filter(j => j.status === 'running').map(j => j.job_id)) for (const job of next) { - if (job.status === 'running' || !wasRunning.has(job.job_id) || settledNotified.has(job.job_id)) { + if ( + job.status === 'running' || + (!wasRunning.has(job.job_id) && !acceptedIds.has(job.job_id)) || + settledNotified.has(job.job_id) + ) { continue } settledNotified.add(job.job_id) + acceptedIds.delete(job.job_id) if (job.status === 'done') { notify({ @@ -76,43 +219,67 @@ function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly Loca } } -async function poll() { - try { - const { jobs } = await getLocalModelsJobs() - const previous = $localRuntimeJobs.get() +function poll(): Promise { + if (polling) { + return polling + } - if (!jobsEqual(previous, jobs)) { + const context = generation + const owner = activeContext + const accepted = owner.acceptedInstall + + if (timer !== null) { + window.clearTimeout(timer) + } + + timer = null + polling = (async () => { + try { + const { jobs } = await getLocalModelsJobs() + + if (context !== generation || accepted !== owner.acceptedInstall) { + return + } + + const previous = $localRuntimeJobs.get() + + // Acceptance can arrive after a pane already read the terminal job. notifySettled(previous, jobs) - $localRuntimeJobs.set(jobs) + + if (!jobsEqual(previous, jobs)) { + $localRuntimeJobs.set(jobs) + } + + owner.jobs = jobs + + if (owner.readPending) { + owner.readPending = false + $localRuntimeInstallStarting.set(owner.postPending) + } + } catch { + // Backend unreachable — keep the last snapshot; the next poll retries. + } finally { + if (context === generation) { + polling = null + + if (owner.readPending || $localRuntimeJobs.get().some(j => j.status === 'running')) { + timer = window.setTimeout(() => void poll(), POLL_ACTIVE_MS) + } + } } - } catch { - // Backend unreachable — keep the last snapshot; the next poll retries. - } + })() - const anyRunning = $localRuntimeJobs.get().some(j => j.status === 'running') - - if (anyRunning) { - timer = window.setTimeout(() => void poll(), POLL_ACTIVE_MS) - } else { - polling = false - timer = null - } + return polling } // Idempotent kick: start (or keep) the poll loop while work is in flight. // Call after starting a job AND on app boot (to rediscover work started // before a reload). export function watchLocalRuntimeJobs() { - if (polling) { + if (polling || timer !== null) { return } - polling = true - - if (timer !== null) { - window.clearTimeout(timer) - } - void poll() } @@ -138,32 +305,3 @@ export function runningModelDownloads(jobs: readonly LocalRuntimeJob[]): LocalRu export function runningRuntimeInstall(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob | null { return jobs.find(j => j.kind === 'runtime-install' && j.status === 'running') ?? null } - -// One engine-update toast per app session: checked at boot (after the -// gateway is ready), only when the user runs the local engine. The -// download itself is always a button click in Local Models — this is a -// pointer, not an installer. -let updateNotified = false - -export async function checkLocalRuntimeUpdate() { - if (updateNotified) { - return - } - - try { - const status = await getLocalModelsStatus() - - if (status.enabled && status.update_available) { - updateNotified = true - notify({ - durationMs: 10_000, - kind: 'info', - title: translateNow('settings.localModels.title'), - message: translateNow('settings.localModels.updateToast', status.configured_tag) - }) - } - } catch { - // Backend without the endpoint (older runtime) or transient failure — - // silently skip; the pane still shows the update row when opened. - } -}