/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config */
// The quantization page, ported from core/http/react-ui/src/pages/Quantize.jsx
// with the port table: React, the translator, the shared components, the
// session-bound fetch and the viewer come from ctx; the job views are the
// shared ones of addons/internal/tools-ui. Class names, test ids and keys are
// the core page's.
import { percentOf, ACTIVE } from './lib/progress.js'
const QUANT_PRESETS = [
'q2_k', 'q3_k_s', 'q3_k_m', 'q3_k_l',
'q4_0', 'q4_k_s', 'q4_k_m',
'q5_0', 'q5_k_s', 'q5_k_m',
'q6_k', 'q8_0', 'f16',
]
const DEFAULT_QUANT = 'q4_k_m'
const FALLBACK_BACKEND = 'llama-cpp-quantization'
const BADGE = {
queued: '', downloading: 'dk-badge--warn', converting: 'dk-badge--warn', quantizing: 'dk-badge--accent',
completed: 'dk-badge--ok', failed: 'dk-badge--error', stopped: '',
}
const startedAt = (job) => {
const d = new Date(job.created_at)
return Number.isNaN(d.getTime()) ? (job.created_at || '') : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// `shared` is the shared job views: { tools, readSse, ToolSteps, Checks, StageLine,
// JobLog, ToolOff }. Returns the page and, for tests, the parts it is made of.
export function createQuantize(ctx, api, shared) {
const { h, Fragment, t, Icon, ui, viewer } = ctx
const { useState, useEffect, useRef, useCallback, useMemo } = ctx.React
const { QZ_STAGES, TERMINAL, appendLog, blocked, logLines, machineFacts, quantizeChecks, warned } = shared.tools
const { ToolSteps, Checks, StageLine, JobLog, ToolOff, readSse } = shared
function StatusBadge({ status }) {
return {ctx.tOr(`quantize.status.${status}`, String(status || ''))}
}
function Field({ id, label, hint, className = '', children }) {
return (
{label}
{children}
{hint &&
{hint}
}
)
}
function Section({ done, title, children }) {
return (
{done ? : null}
{title}
{children}
)
}
// The foot of the delete layer. The host draws the dialog around it.
function DeleteDialogBody({ onConfirm, close }) {
return (
{t('stop.cancelDelete')}
{ close(); onConfirm() }} data-testid="delete-confirm">{t('jobs.delete')}
)
}
// What to do with a finished quantization: import it as a model, then use it.
function ResultPanel({ job, onRefresh, modelExports = true }) {
const [modelName, setModelName] = useState('')
const [importing, setImporting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (job?.import_status !== 'importing') return undefined
const timer = setInterval(() => { onRefresh().catch(() => {}) }, 3000)
return () => clearInterval(timer)
}, [job?.import_status, onRefresh])
if (!job || job.status !== 'completed') return null
const handleImport = async () => {
setImporting(true)
setError('')
try {
await api.quantize.importModel(job.id, { name: modelName || undefined })
await onRefresh()
} catch (e) {
setError(e.message || t('quantize.importFailed'))
} finally {
setImporting(false)
}
}
const imported = job.import_status === 'completed' && job.import_model_name
const download = api.downloadHref(job.id)
return (
<>
{imported && (
{t('quantize.nextTitle', { name: job.import_model_name })}
{t('quantize.nextText')}
)}
{t('quantize.outputTitle')}
{t('quantize.outputText', { type: job.quantization_type })}
{job.output_file && {t('quantize.file')} {job.output_file}
}
{error && {error}
}
{job.import_status === 'failed' && {t('quantize.importFailedWith', { message: job.import_message })}
}
{!imported && (
{job.import_status === 'importing' ? (
{t('quantize.importing')} {job.import_message}
) : (
<>
setModelName(e.target.value)} />
{!modelExports &&
{t('quantize.importBlocked')}
}
>
)}
)}
>
)
}
// A working, failed or finished job: progress, the log and what to do with
// the result. The server streams progress events while the job is active; a
// finished job is read from the job record. `seed` starts the view with an
// event already read; tests use it to draw a state the stream would reach.
function JobView({ job, onStop, onReuse, onTerminal, onRefresh, seed, modelExports = true }) {
const [latest, setLatest] = useState(seed?.latest ?? null)
const [log, setLog] = useState([])
const previousRef = useRef(null)
const terminalRef = useRef(onTerminal)
terminalRef.current = onTerminal
useEffect(() => {
previousRef.current = null
setLatest(null)
setLog([])
if (!job || TERMINAL.includes(job.status)) return undefined
// The progress stream is a fetch read with readSse: it carries the
// session and the base path of sdk.core.fetch. Unmounting aborts it.
const controller = new AbortController()
const onEvent = (data) => {
setLatest(data)
setLog(prev => appendLog(prev, logLines(data, previousRef.current)))
previousRef.current = data
if (TERMINAL.includes(data.status)) { controller.abort(); terminalRef.current?.() }
}
;(async () => {
try {
const response = await api.quantize.progress(job.id, controller.signal)
if (response.ok) await readSse(response, onEvent, controller.signal)
} catch (_) { /* the stream ended; the job list poll shows the state */ }
})()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [job?.id])
const status = latest?.status || job.status
const active = ACTIVE.includes(status)
const failed = status === 'failed'
// A working job can read 0% before it has reported anything. That 0 is
// unknown, not a measurement, so the page draws no figure for it.
const progress = percentOf(latest ? { ...latest, status } : status === 'completed' ? { status } : null)
const known = progress != null
const message = latest?.message || job.message || ''
const stageLabels = Object.fromEntries(QZ_STAGES.map(s => [s, t(`quantize.status.${s}`)]))
return (
{failed && (
{t('quantize.failed')}
{message || t('fineTune.noMessage')}
)}
{job.model}
{t('quantize.jobMeta', { type: job.quantization_type, backend: job.backend, started: startedAt(job) })}
ยท {job.id?.slice(0, 8)}
{active && (
onStop(job.id)} data-testid="job-stop">
{t('run.stop')}
)}
{(latest || status === 'completed') && (
{known
? {progress.toFixed(0)}%
: {t('run.unknownPercent')} }
)}
{(active || latest) && (
)}
{active && }
{!failed && message && {message}
}
{status === 'completed' && {t('quantize.finished', { type: job.quantization_type })}
}
{status === 'stopped' && {t('quantize.stopped')}
}
{failed && (
{t('fineTune.tryTitle')}
{t('quantize.tryText')}
onReuse(job)} data-testid="job-retry"> {t('fineTune.reuse')}
)}
{(active || log.length > 0) &&
}
)
}
// Whether this instance may write an imported model. Only a clear false turns
// the controls off: a failed or slow answer leaves them on, as the server does.
function useModelExports() {
const [allowed, setAllowed] = useState(true)
useEffect(() => {
let off = false
api.quantize.status().then(r => { if (!off && r && r.model_exports === false) setAllowed(false) }).catch(() => {})
return () => { off = true }
}, [])
return allowed
}
// The machine facts for the checks: the one request the page makes, as a
// poll that waits while the tab is hidden and catches up on return.
function useResources(interval) {
const [resources, setResources] = useState(null)
useEffect(() => {
let off = false
const read = async () => {
if (typeof document !== 'undefined' && document.hidden) return
try {
const data = await api.resources.get()
if (!off) setResources(data)
} catch (_) { /* the next tick tries again */ }
}
read()
const timer = setInterval(read, interval)
document.addEventListener('visibilitychange', read)
return () => { off = true; clearInterval(timer); document.removeEventListener('visibilitychange', read) }
}, [interval])
return resources
}
function QuantizeBody({ path }) {
const isAdmin = viewer.isAdmin()
const resources = useResources(10000)
const modelExports = useModelExports()
const facts = useMemo(() => machineFacts(resources), [resources])
// A deep link names the model: ?model= or the first path segment.
const [model, setModel] = useState(() => ctx.modelFrom(path) || '')
const [quantType, setQuantType] = useState(DEFAULT_QUANT)
const [customQuantType, setCustomQuantType] = useState('')
const [useCustomQuant, setUseCustomQuant] = useState(false)
const [backend, setBackend] = useState(FALLBACK_BACKEND)
const [hfToken, setHfToken] = useState('')
const [backends, setBackends] = useState(null)
const [showMore, setShowMore] = useState(false)
const [jobs, setJobs] = useState([])
const [jobsLoaded, setJobsLoaded] = useState(false)
const [selectedJob, setSelectedJob] = useState(null)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const loadJobs = useCallback(async () => {
try {
const data = await api.quantize.listJobs()
const list = Array.isArray(data) ? data : []
setJobs(list)
setSelectedJob(prev => {
if (!prev) return prev
const fresh = list.find(j => j.id === prev.id)
return fresh || prev
})
} catch (_) { /* the next poll tries again */ } finally {
setJobsLoaded(true)
}
}, [])
useEffect(() => {
api.quantize.listBackends().then(b => {
const list = Array.isArray(b) ? b : []
setBackends(list)
if (list.length) setBackend(list[0].name)
}).catch(() => setBackends(null))
loadJobs()
const interval = setInterval(loadJobs, 10000)
return () => clearInterval(interval)
}, [loadJobs])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSubmitting(true)
try {
const req = {
model,
backend: backend || FALLBACK_BACKEND,
quantization_type: useCustomQuant ? customQuantType : quantType,
}
if (hfToken) req.extra_options = { hf_token: hfToken }
const resp = await api.quantize.startJob(req)
setModel('')
await loadJobs()
setSelectedJob(await api.quantize.getJob(resp.id))
} catch (err) {
setError(err.message || t('quantize.startFailed'))
} finally {
setSubmitting(false)
}
}
const handleStop = async (jobId) => {
try {
await api.quantize.stopJob(jobId)
await loadJobs()
} catch (err) {
setError(err.message || t('quantize.stopFailed'))
}
}
const confirmDelete = async (jobId) => {
try {
await api.quantize.deleteJob(jobId)
if (selectedJob?.id === jobId) setSelectedJob(null)
await loadJobs()
} catch (err) {
setError(err.message || t('quantize.deleteFailed'))
}
}
const askDelete = (jobId) => ctx.openDialog({
title: t('jobs.deleteDialogTitle'),
description: t('quantize.deleteDialogText'),
render: ({ close }) => confirmDelete(jobId)} />,
})
// Put a job's setup back in the form.
const reuse = (job) => {
setModel(job.model || '')
if (job.backend) setBackend(job.backend)
const type = job.quantization_type || DEFAULT_QUANT
if (QUANT_PRESETS.includes(type)) { setUseCustomQuant(false); setQuantType(type) } else { setUseCustomQuant(true); setCustomQuantType(type) }
setSelectedJob(null)
}
const effectiveQuantType = useCustomQuant ? customQuantType : quantType
const backendNames = backends && backends.length > 0 ? backends.map(b => b.name) : [FALLBACK_BACKEND]
const checks = useMemo(() => quantizeChecks({
form: { model, backend, useCustom: useCustomQuant, customType: customQuantType, hfToken: hfToken.trim() },
facts,
backends: { quantize: backends },
}), [model, backend, useCustomQuant, customQuantType, hfToken, facts, backends])
const stop = blocked(checks)
const warnings = warned(checks)
const firstMissing = checks.find(c => c.tone === 'fail')
const steps = [
{ key: 'setup', label: t('steps.setup') },
{ key: 'check', label: t('steps.check') },
{ key: 'run', label: t('steps.run') },
{ key: 'result', label: t('steps.result') },
]
let current = stop ? 0 : 1
let failed = false
if (selectedJob) {
if (selectedJob.status === 'failed') { current = 2; failed = true } else if (TERMINAL.includes(selectedJob.status)) current = 3
else current = 2
}
const refreshSelected = useCallback(async () => {
if (!selectedJob) return
const updated = await api.quantize.getJob(selectedJob.id)
setSelectedJob(updated)
await loadJobs()
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [selectedJob?.id, loadJobs])
return (
{t('landing.experimental')} {t('quantize.lede')}>}
actions={selectedJob && (
setSelectedJob(null)} data-testid="job-back">
{t('quantize.newJob')}
)}
/>
{error && (
)}
{selectedJob ? (
) : (
)}
{jobs.length === 0 ? (
{jobsLoaded ? t('quantize.emptyTitle') : t('jobs.loading')}
{jobsLoaded &&
{t('quantize.emptyText')}
}
) : (
{t('jobs.title')}
{t('jobs.model')}
{t('quantize.type')}
{t('jobs.status')}
{t('jobs.started')}
{t('jobs.actions')}
{jobs.map(job => {
const active = QZ_STAGES.includes(job.status)
return (
setSelectedJob(job)} title={t('jobs.open')}>
{job.model}
{job.import_status === 'completed' && {t('quantize.imported', { name: job.import_model_name })} }
{job.status === 'failed' && job.message && {job.message} }
{job.quantization_type}
{startedAt(job)}
{active ? (
handleStop(job.id)}> {t('run.stop')}
) : (
<>
reuse(job)} title={t('jobs.reuseTitle')}>{t('jobs.reuse')}
askDelete(job.id)} aria-label={t('jobs.delete')} title={t('jobs.deleteTitle')}>
>
)}
)
})}
)}
)
}
// The page. The off notice replaces it for an account without the feature,
// so none of the page's requests run for that account.
function Quantize({ path }) {
return (
)
}
return { Quantize, JobView, ResultPanel, DeleteDialogBody, StatusBadge }
}