model download status

This commit is contained in:
James Pine
2026-04-23 03:26:30 -07:00
parent ef00570145
commit be73a33ee1
2 changed files with 124 additions and 92 deletions
+37 -29
View File
@@ -513,37 +513,45 @@ export function CapturesTab() {
Configure
</Link>
</Button>
<Button
variant="outline"
onClick={handleUploadClick}
disabled={session.isUploading}
>
{session.isUploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? 'Uploading...' : 'Import'}
</Button>
{readiness.allReady && (
<Button
variant="outline"
onClick={handleUploadClick}
disabled={session.isUploading}
>
{session.isUploading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{session.isUploading ? 'Uploading...' : 'Import'}
</Button>
)}
</>
)}
<Button
onClick={session.toggleRecording}
disabled={session.isUploading && !session.isRecording}
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
>
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
Stop
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
Dictate
</>
)}
</Button>
{/* Hide Dictate when readiness fails so the user can't kick off
a capture that has nowhere to land. Stop stays visible if a
recording is somehow already in flight (e.g. a model was
uninstalled mid-record) so the user can always cancel. */}
{(readiness.allReady || session.isRecording) && (
<Button
onClick={session.toggleRecording}
disabled={session.isUploading && !session.isRecording}
className="relative overflow-hidden transition-all bg-accent text-accent-foreground hover:bg-accent/90"
>
{session.isRecording ? (
<>
<Square className="h-4 w-4 mr-2 fill-current" />
Stop
</>
) : (
<>
<Mic className="h-4 w-4 mr-2" />
Dictate
</>
)}
</Button>
)}
</div>
</div>
@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Accessibility,
CheckCircle2,
@@ -9,10 +9,11 @@ import {
Keyboard,
Loader2,
} from 'lucide-react';
import { useState } from 'react';
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { ActiveDownloadTask } from '@/lib/api/types';
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
import { cn } from '@/lib/utils/cn';
@@ -51,41 +52,81 @@ function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
);
}
function progressPercent(task: ActiveDownloadTask | undefined): number | null {
if (!task) return null;
if (typeof task.progress === 'number') return Math.max(0, Math.min(100, task.progress));
if (task.current && task.total) return Math.round((task.current / task.total) * 100);
return null;
}
/**
* Renders one row per dictation-readiness gate. Each unmet gate gets an
* inline action — Download for missing models, Open Settings for missing
* TCC permissions — so the user can resolve everything without leaving
* Captures.
*
* Download-in-progress state is sourced from ``/tasks/active`` (same query
* the Models page uses) so it survives unmount: navigating away and back
* still shows "Downloading…" instead of resetting to "Download".
*
* The chord stays disarmed until every row is green; this is what stops the
* "stuck pill" failure mode of pressing the chord with a missing model.
*/
export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [downloading, setDownloading] = useState<Set<ReadinessGate>>(new Set());
const { data: activeTasks } = useQuery({
queryKey: ['activeTasks'],
queryFn: () => apiClient.getActiveTasks(),
// Mirror ModelManagement's cadence: 1s while a download is in flight,
// 5s otherwise. Keeps progress feeling live without hammering when idle.
refetchInterval: (query) => {
const data = query.state.data;
const hasActive = data?.downloads.some((d) => d.status === 'downloading');
return hasActive ? 1000 : 5000;
},
});
const downloadByModel = new Map<string, ActiveDownloadTask>();
for (const dl of activeTasks?.downloads ?? []) {
if (dl.status === 'downloading') downloadByModel.set(dl.model_name, dl);
}
// When a download disappears from activeTasks, it just finished — refetch
// readiness immediately so the row flips to ✓ instead of waiting up to 5s
// for the next readiness poll.
const prevActive = useRef<Set<string>>(new Set());
useEffect(() => {
const current = new Set(downloadByModel.keys());
for (const name of prevActive.current) {
if (!current.has(name)) {
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
break;
}
}
prevActive.current = current;
}, [activeTasks, queryClient, downloadByModel]);
const downloadMutation = useMutation({
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
apiClient.triggerModelDownload(modelName),
onSuccess: (_data, vars) => {
// Bump model status + readiness so the checklist row flips green as
// soon as the cache is populated. Keep the gate in `downloading` until
// readiness reports `ready: true` to avoid a flash of "Download" on
// post-completion polls.
// Bump activeTasks so the row immediately shows "Downloading…" without
// waiting for the next 5s poll. modelStatus + readiness invalidations
// keep adjacent UI in sync.
queryClient.invalidateQueries({ queryKey: ['activeTasks'] });
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
const displayName =
vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name;
toast({
title: 'Download started',
description: `${vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name} is downloading. The shortcut will arm itself when it finishes.`,
description: `${displayName} is downloading. The shortcut will arm itself when it finishes.`,
});
},
onError: (err: Error, vars) => {
setDownloading((prev) => {
const next = new Set(prev);
next.delete(vars.gate);
return next;
});
onError: (err: Error) => {
toast({
title: 'Download failed',
description: err.message,
@@ -94,20 +135,41 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
},
});
const startDownload = (gate: ReadinessGate, modelName: string) => {
setDownloading((prev) => new Set(prev).add(gate));
downloadMutation.mutate({ gate, modelName });
};
// Once readiness flips ready=true for a gate, drop it from `downloading`
// so subsequent reopens show the green check, not "Downloading…".
const isDownloading = (gate: ReadinessGate, ready: boolean) => !ready && downloading.has(gate);
const sttSize =
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
const llmSize =
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
function modelDownloadButton(
gate: 'stt' | 'llm',
modelName: string,
ready: boolean,
): React.ReactNode {
const task = downloadByModel.get(modelName);
const downloading = !ready && !!task;
const pct = progressPercent(task);
return (
<Button
size="sm"
onClick={() => downloadMutation.mutate({ gate, modelName })}
disabled={downloading || downloadMutation.isPending}
className="gap-1.5"
>
{downloading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{pct != null ? `Downloading… ${pct}%` : 'Downloading…'}
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
</>
)}
</Button>
);
}
return (
<div className="w-full max-w-md mx-auto space-y-2.5">
<div className="text-center mb-5 space-y-1">
@@ -129,26 +191,7 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
: `Needed to transcribe your audio${sttSize ? ` · ${sttSize}` : ''}.`
}
ready={readiness.stt.ready}
action={
<Button
size="sm"
onClick={() => startDownload('stt', readiness.stt!.model_name)}
disabled={isDownloading('stt', readiness.stt.ready)}
className="gap-1.5"
>
{isDownloading('stt', readiness.stt.ready) ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Downloading
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
</>
)}
</Button>
}
action={modelDownloadButton('stt', readiness.stt.model_name, readiness.stt.ready)}
/>
)}
@@ -162,26 +205,7 @@ export function DictationReadinessChecklist({ readiness }: { readiness: Dictatio
: `Cleans up the raw transcript before paste${llmSize ? ` · ${llmSize}` : ''}.`
}
ready={readiness.llm.ready}
action={
<Button
size="sm"
onClick={() => startDownload('llm', readiness.llm!.model_name)}
disabled={isDownloading('llm', readiness.llm.ready)}
className="gap-1.5"
>
{isDownloading('llm', readiness.llm.ready) ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Downloading
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Download
</>
)}
</Button>
}
action={modelDownloadButton('llm', readiness.llm.model_name, readiness.llm.ready)}
/>
)}