Files
aiturk-hermes-ide/apps/desktop/src/store/local-runtime-jobs.ts
T

170 lines
5.6 KiB
TypeScript

import { atom } from 'nanostores'
import { getLocalModelsJobs, getLocalModelsStatus } from '@/hermes'
import { translateNow } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import type { LocalRuntimeJob } from '@/types/hermes'
// App-level tracker for local-runtime jobs (runtime installs, model
// downloads). The AUTHORITY is the backend job registry — this store is a
// cache of it (desktop guide: server truth is cached, not owned). Living at
// the store layer, not in the settings pane, is what makes a download
// survive the pane unmounting: anything can start a job, the poller follows
// it to completion, and completion/failure notify app-wide exactly once.
export const $localRuntimeJobs = atom<readonly LocalRuntimeJob[]>([])
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>()
function jobsEqual(a: readonly LocalRuntimeJob[], b: readonly LocalRuntimeJob[]) {
if (a.length !== b.length) {
return false
}
return a.every((job, i) => {
const other = b[i]
return (
job.job_id === other.job_id &&
job.status === other.status &&
job.phase === other.phase &&
job.done_bytes === other.done_bytes
)
})
}
function notifySettled(previous: readonly LocalRuntimeJob[], next: readonly LocalRuntimeJob[]) {
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)) {
continue
}
settledNotified.add(job.job_id)
if (job.status === 'done') {
notify({
durationMs: 6_000,
kind: 'success',
title: translateNow('settings.localModels.title'),
message:
job.kind === 'model-download'
? translateNow('settings.localModels.downloadDoneToast', job.target)
: job.kind === 'model-activate'
? translateNow('settings.localModels.activateDoneToast', job.target)
: job.kind === 'quickstart'
? translateNow('settings.localModels.quickstartDoneToast', job.target)
: translateNow('settings.localModels.installDoneToast')
})
} else {
notifyError(
new Error(job.error ?? job.detail ?? 'failed'),
job.kind === 'model-download'
? translateNow('settings.localModels.downloadFailed', job.target)
: job.kind === 'model-activate'
? translateNow('settings.localModels.activateFailed', job.target)
: job.kind === 'quickstart'
? translateNow('settings.localModels.quickstartFailed')
: translateNow('settings.localModels.installFailed')
)
}
}
}
async function poll() {
try {
const { jobs } = await getLocalModelsJobs()
const previous = $localRuntimeJobs.get()
if (!jobsEqual(previous, jobs)) {
notifySettled(previous, jobs)
$localRuntimeJobs.set(jobs)
}
} 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
}
}
// 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) {
return
}
polling = true
if (timer !== null) {
window.clearTimeout(timer)
}
void poll()
}
// Selector: the running download job for a catalog model id, if any.
export function runningDownloadFor(jobs: readonly LocalRuntimeJob[], modelId: string): LocalRuntimeJob | null {
return jobs.find(j => j.kind === 'model-download' && j.status === 'running' && j.model_id === modelId) ?? null
}
// Selector: every model on its way to the library right now — plain
// downloads plus quickstart runs while they are still fetching bytes
// (later quickstart phases mean the model is staged and activating).
// The model picker renders these as disabled progress rows.
const DOWNLOAD_PHASES = new Set(['starting', 'installing-runtime', 'downloading'])
export function runningModelDownloads(jobs: readonly LocalRuntimeJob[]): LocalRuntimeJob[] {
return jobs.filter(
j =>
j.status === 'running' &&
(j.kind === 'model-download' || (j.kind === 'quickstart' && DOWNLOAD_PHASES.has(j.phase)))
)
}
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.
}
}