feat(desktop): add one-click local engine updates
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -3230,6 +3230,11 @@ export const ar = defineLocale({
|
||||
title: 'المرفقات والأوامر',
|
||||
text: 'اكتب @ لإحضار ملف إلى المحادثة، و / لتشغيل أمر.'
|
||||
},
|
||||
'local-runtime-update': {
|
||||
title: 'يتوفر تحديث للمحرك المحلي',
|
||||
text: 'حدّث المحرك الذي يشغّل نماذجك المحلية. قد تنقطع الطلبات المحلية الجارية.',
|
||||
action: 'التحديث الآن'
|
||||
},
|
||||
'local-setup': {
|
||||
title: 'هذا الجهاز يمكنه تشغيل النماذج محليًا',
|
||||
text: 'عتادك قادر على تشغيل نموذج محلي. تبقى محادثاتك على جهازك ولا تكلف شيئًا.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'お使いのハードウェアでローカルモデルを動かせます。会話はこのコンピュータから出ず、料金もかかりません。',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '你的硬體可以執行本地模型。對話不離開你的電腦,而且完全免費。',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user