mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-30 08:06:51 +02:00
Adds a cancel button to in-progress Ollama model downloads and unifies the Active Model Downloads card layout with the Active Downloads card used for ZIMs, maps, and pmtiles (byte counts, progress bar, live speed, status indicator). Closes #676.
221 lines
11 KiB
TypeScript
221 lines
11 KiB
TypeScript
import { useCallback, useRef, useState } from 'react'
|
|
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
|
|
import StyledSectionHeader from './StyledSectionHeader'
|
|
import StyledModal from './StyledModal'
|
|
import { IconAlertTriangle, IconLoader2, IconX } from '@tabler/icons-react'
|
|
import api from '~/lib/api'
|
|
import { useModals } from '~/context/ModalContext'
|
|
import { formatBytes } from '~/lib/util'
|
|
|
|
interface ActiveModelDownloadsProps {
|
|
withHeader?: boolean
|
|
}
|
|
|
|
function formatSpeed(bytesPerSec: number): string {
|
|
if (bytesPerSec <= 0) return '0 B/s'
|
|
if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`
|
|
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`
|
|
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`
|
|
}
|
|
|
|
const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) => {
|
|
const { downloads, removeDownload } = useOllamaModelDownloads()
|
|
const { openModal, closeAllModals } = useModals()
|
|
const [cancellingModels, setCancellingModels] = useState<Set<string>>(new Set())
|
|
|
|
// Track previous downloadedBytes for speed calculation — mirrors the approach in
|
|
// ActiveDownloads.tsx so content + model downloads feel identical.
|
|
const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map())
|
|
const speedRef = useRef<Map<string, number[]>>(new Map())
|
|
|
|
const getSpeed = useCallback((model: string, currentBytes?: number): number => {
|
|
if (!currentBytes || currentBytes <= 0) return 0
|
|
|
|
const prev = prevBytesRef.current.get(model)
|
|
const now = Date.now()
|
|
|
|
if (prev && prev.bytes > 0 && currentBytes > prev.bytes) {
|
|
const deltaBytes = currentBytes - prev.bytes
|
|
const deltaSec = (now - prev.time) / 1000
|
|
if (deltaSec > 0) {
|
|
const instantSpeed = deltaBytes / deltaSec
|
|
|
|
// Simple moving average (last 5 samples)
|
|
const samples = speedRef.current.get(model) || []
|
|
samples.push(instantSpeed)
|
|
if (samples.length > 5) samples.shift()
|
|
speedRef.current.set(model, samples)
|
|
|
|
const avg = samples.reduce((a, b) => a + b, 0) / samples.length
|
|
prevBytesRef.current.set(model, { bytes: currentBytes, time: now })
|
|
return avg
|
|
}
|
|
}
|
|
|
|
// Only set initial observation; never advance timestamp when bytes unchanged
|
|
if (!prev) {
|
|
prevBytesRef.current.set(model, { bytes: currentBytes, time: now })
|
|
}
|
|
return speedRef.current.get(model)?.at(-1) || 0
|
|
}, [])
|
|
|
|
const runCancel = async (download: { model: string; jobId?: string }) => {
|
|
// Defensive guard: stale broadcasts during a hot upgrade may not include jobId.
|
|
// Without it we have nothing to call the cancel API with.
|
|
if (!download.jobId) return
|
|
|
|
setCancellingModels((prev) => new Set(prev).add(download.model))
|
|
try {
|
|
await api.cancelDownloadJob(download.jobId)
|
|
// Optimistically clear the entry — the Transmit cancelled broadcast usually
|
|
// arrives within a second but we don't want to leave the row hanging if it doesn't.
|
|
removeDownload(download.model)
|
|
// Clean up speed tracking refs for this model
|
|
prevBytesRef.current.delete(download.model)
|
|
speedRef.current.delete(download.model)
|
|
} finally {
|
|
setCancellingModels((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(download.model)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const confirmCancel = (download: { model: string; jobId?: string }) => {
|
|
if (!download.jobId) return
|
|
|
|
openModal(
|
|
<StyledModal
|
|
title="Cancel Download?"
|
|
onConfirm={() => {
|
|
closeAllModals()
|
|
runCancel(download)
|
|
}}
|
|
onCancel={closeAllModals}
|
|
open={true}
|
|
confirmText="Cancel Download"
|
|
cancelText="Keep Downloading"
|
|
>
|
|
<div className="space-y-3 text-text-primary">
|
|
<p>
|
|
Stop downloading <span className="font-mono font-semibold">{download.model}</span>?
|
|
</p>
|
|
<p className="text-sm text-text-muted">
|
|
Any data already downloaded will remain on disk. If you re-download
|
|
this model later, it will resume from where it left off rather than
|
|
starting over.
|
|
</p>
|
|
</div>
|
|
</StyledModal>,
|
|
'confirm-cancel-model-download-modal'
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{withHeader && <StyledSectionHeader title="Active Model Downloads" className="mt-12 mb-4" />}
|
|
<div className="space-y-4">
|
|
{downloads && downloads.length > 0 ? (
|
|
downloads.map((download) => {
|
|
const isCancelling = cancellingModels.has(download.model)
|
|
const canCancel = !!download.jobId && !download.error
|
|
const speed = getSpeed(download.model, download.downloadedBytes)
|
|
const hasBytes = !!(download.downloadedBytes && download.totalBytes)
|
|
|
|
return (
|
|
<div
|
|
key={download.model}
|
|
className={`rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
|
download.error
|
|
? 'bg-surface-primary border-red-300'
|
|
: 'bg-surface-primary border-default'
|
|
}`}
|
|
>
|
|
{download.error ? (
|
|
<div className="flex items-center gap-2">
|
|
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-text-primary truncate">
|
|
{download.model}
|
|
</p>
|
|
<p className="text-xs text-red-600 mt-0.5">{download.error}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{/* Title + Cancel button row */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-semibold text-desert-green truncate">
|
|
{download.model}
|
|
</p>
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono">
|
|
ollama
|
|
</span>
|
|
</div>
|
|
{canCancel && (
|
|
isCancelling ? (
|
|
<IconLoader2 className="w-4 h-4 text-text-muted animate-spin flex-shrink-0" />
|
|
) : (
|
|
<button
|
|
onClick={() => confirmCancel(download)}
|
|
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
|
|
title="Cancel download"
|
|
>
|
|
<IconX className="w-4 h-4 text-text-muted hover:text-red-500" />
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{/* Size info */}
|
|
<div className="flex justify-between items-baseline text-sm text-text-muted font-mono">
|
|
<span>
|
|
{hasBytes
|
|
? `${formatBytes(download.downloadedBytes!, 1)} / ${formatBytes(download.totalBytes!, 1)}`
|
|
: `${download.percent.toFixed(1)}% / 100%`}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="relative">
|
|
<div className="h-6 bg-desert-green-lighter bg-opacity-20 rounded-lg border border-default overflow-hidden">
|
|
<div
|
|
className="h-full rounded-lg transition-all duration-1000 ease-out bg-desert-green"
|
|
style={{ width: `${download.percent}%` }}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={`absolute top-1/2 -translate-y-1/2 font-bold text-xs ${
|
|
download.percent > 15
|
|
? 'left-2 text-white drop-shadow-md'
|
|
: 'right-2 text-desert-green'
|
|
}`}
|
|
>
|
|
{Math.round(download.percent)}%
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
<span className="text-xs text-text-muted">
|
|
Downloading...{speed > 0 ? ` ${formatSpeed(speed)}` : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
) : (
|
|
<p className="text-text-muted">No active model downloads</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default ActiveModelDownloads
|