feat(desktop): add one-click local engine updates

This commit is contained in:
emozilla
2026-09-14 21:49:14 -04:00
parent e9e363c856
commit de5a1da632
16 changed files with 1369 additions and 91 deletions
@@ -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))
@@ -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()
@@ -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<LocalModelsStatus | null>(null)
const [hardware, setHardware] = useState<LocalHardware | null>(null)
const [catalog, setCatalog] = useState<LocalCatalogModel[] | null>(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() {
) : (
<ListRow
action={
<Button onClick={() => void handleInstallRuntime()} size="sm">
<Button disabled={installStarting} onClick={() => void startLocalRuntimeInstall()} size="sm">
<Download />
{copy.installAction}
</Button>
@@ -484,7 +479,7 @@ export function LocalModelsSettings() {
{status.update_available && !rJob && (
<ListRow
action={
<Button onClick={() => void handleInstallRuntime()} size="sm">
<Button disabled={installStarting} onClick={() => void startLocalRuntimeInstall()} size="sm">
<Download />
{copy.updateAction}
</Button>
@@ -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 (
<>
<output>
{location.pathname}
{location.search}
</output>
{tip && <TipBubble {...tip} anchor={anchor} onClose={retireActiveTip} />}
</>
)
}
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(
<MemoryRouter>
<I18nProvider>
<Harness />
</I18nProvider>
</MemoryRouter>
)
})
})
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()
})
@@ -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
}
@@ -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'
@@ -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 <button data-tour="model-pill">Model</button>
}
function mount() {
return render(
<MemoryRouter>
<Harness />
</MemoryRouter>
)
}
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)
})
@@ -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])
+5
View File
@@ -3230,6 +3230,11 @@ export const ar = defineLocale({
title: 'المرفقات والأوامر',
text: 'اكتب @ لإحضار ملف إلى المحادثة، و / لتشغيل أمر.'
},
'local-runtime-update': {
title: 'يتوفر تحديث للمحرك المحلي',
text: 'حدّث المحرك الذي يشغّل نماذجك المحلية. قد تنقطع الطلبات المحلية الجارية.',
action: 'التحديث الآن'
},
'local-setup': {
title: 'هذا الجهاز يمكنه تشغيل النماذج محليًا',
text: 'عتادك قادر على تشغيل نموذج محلي. تبقى محادثاتك على جهازك ولا تكلف شيئًا.',
+6 -2
View File
@@ -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.',
+6 -3
View File
@@ -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: 'お使いのハードウェアでローカルモデルを動かせます。会話はこのコンピュータから出ず、料金もかかりません。',
+1 -1
View File
@@ -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<TipId, { title: string; text: string }> & {
'local-runtime-update': { title: string; text: string; action: string }
'local-setup': { title: string; text: string; action: string }
}
}
+6 -2
View File
@@ -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: '你的硬體可以執行本地模型。對話不離開你的電腦,而且完全免費。',
+6 -2
View File
@@ -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: '你的硬件可以运行本地模型。对话不离开你的电脑,而且完全免费。',
@@ -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<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((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)
})
+196 -58
View File
@@ -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<readonly LocalRuntimeJob[]>([])
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<void> {
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<string>()
let polling: Promise<void> | null = null
let generation = 0
interface JobContext {
jobs: readonly LocalRuntimeJob[]
postPending: boolean
readPending: boolean
acceptedInstall: number
acceptedIds: Set<string>
settledNotified: Set<string>
}
// Only foreground requests run; switching away retains ownership, not a poller.
const contexts = new Map<string, JobContext>()
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<void> {
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.
}
}