Skip to content

Commit 8b0e098

Browse files
authored
Merge pull request #517 from atomantic/feat/inline-hf-model-download-badge
inline HF model download badge on image + video gen forms
2 parents fd60d7a + c8a1c97 commit 8b0e098

21 files changed

Lines changed: 1100 additions & 2 deletions

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ TBD
1515
- **Brain → Links: edit a link's URL**: the per-link edit form now exposes the URL alongside the title (each with a visible label so they're unambiguous even when the title defaults to the URL). Changing the URL re-derives the GitHub repo metadata, guards against duplicate URLs (409), and resets stale clone state so a cloned repo's local path can't point at the wrong target.
1616
- **Base style image (style probe)** on a universe: generate a canonical image from the universe's embrace/avoid influences as the positive/negative prompt, with no character or subject — to preview the world's base visual emphasis. `styleNotes` is intentionally NOT mixed into the probe prompt because it never reaches the image model on any downstream prompt (canon refs, variations, sheets, comic pages) — including it would misrepresent what those renders will look like. Triggerable from both the Universe Builder (under the style/influences editor) and the Story Builder's Universe Aesthetic step; the result persists on the universe (`styleImageRefs`) so both surfaces share it. Includes a "Regenerate" affordance to re-roll the probe once an image already exists (new filenames append to `styleImageRefs[]` so prior renders survive as walk-back fallbacks).
1717
- **Reader Map** on a series arc (`series.arc.readerMap`): a distinct audience-experience roadmap — hooks, payoffs, emotional beats, and cliffhangers across the arc — built on top of the Vonnegut story shape, separate from the protagonist arc. Generated and refined via the new Story Builder reader-map step (also preserved by arc regeneration).
18+
- **Image + Video Gen: inline model download status**. The model picker on both pages now shows whether the selected model's weights are already in the local HuggingFace cache. Available models get a green "Available · 7.8 GB" badge; missing ones get a "Download (~est)" button that pre-fetches the repo over SSE with a live progress bar (so users learn about a multi-GB pull before hitting Render instead of being surprised by a silent lazy download mid-generation). Hitting Render without pre-downloading still works — the existing lazy-download path is the fallback. The video form also surfaces the active text encoder (a separate ~7–25 GB Gemma pull) with its own button. New endpoints: `GET /api/image-gen/models/status`, `GET /api/image-gen/models/:id/download` (SSE), `GET /api/video-gen/models/status`, `GET /api/video-gen/models/:id/download` (SSE), `GET /api/video-gen/text-encoder/download` (SSE). Cache detection lives in `server/lib/hfCache.js` (`inspectModelCache`); download orchestration in `server/lib/hfDownload.js` and the new `scripts/hf_download_repo.py` helper (runs in the FLUX.2 venv, falls back to the mflux pythonPath). Mflux legacy `dev` / `schnell` map to their canonical `black-forest-labs/FLUX.1-*` repos so they get the same badge as every other entry.
1819
- Local LLMs: when an Ollama model pull fails with `412: requires a newer version of Ollama`, PortOS now auto-upgrades Ollama in place and retries the install — no confirm click. On macOS with `/Applications/Ollama.app` present, this downloads the latest `Ollama-darwin.zip` directly from GitHub releases, force-kills the running Ollama, replaces the `.app` bundle, strips quarantine, relaunches, and polls `/api/version` until the new binary is serving. (The old brew-only path silently left the old binary running, because `/usr/local/bin/ollama` symlinks into the `.app` bundle — even a successful `brew upgrade` doesn't change which binary is on disk inside the `.app`.) On Linux it still re-runs the official Ollama install script; for headless macOS installs (brew formula only, no `.app`), it still runs `brew upgrade ollama`. The UI now shows a prominent yellow warning banner with live step-by-step progress for the whole upgrade flow, instead of a quiet inline confirm row. Failed `brew` runs now also surface the actual stderr (e.g. `Error: ollama not installed`) instead of just an exit code.
1920

2021
## Changed

client/src/components/imageGen/ImageGenControls.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { filterResolutions, resolveResolutionLabel } from '../../lib/imageGenRes
1313
import { randomSeed } from '../../lib/genUtils';
1414
import { RUNNER_FAMILIES } from '../../lib/runnerFamilies';
1515
import { IMAGE_GEN_MODE } from '../../lib/imageGenBackends';
16+
import ModelDownloadBadge, { deriveSizeEstimate } from '../media/ModelDownloadBadge';
1617

1718
const QUANTIZE_OPTIONS = [
1819
{ value: '3', label: '3-bit' },
@@ -39,6 +40,14 @@ export default function ImageGenControls({
3940
// Optional column override — defaults to 2/3 like the Image Gen page.
4041
// Pass e.g. "grid-cols-2 sm:grid-cols-4" to fit a denser layout.
4142
className = 'grid grid-cols-2 sm:grid-cols-3 gap-3',
43+
// Pre-download badge integration. `modelStatus` is the per-model entry from
44+
// useModelDownloadStatus().getStatus(modelId); `onModelDownload` /
45+
// `onModelDownloadCancel` are optional triggers. Omitting the props hides
46+
// the badge — callers that don't care (Universe Builder batch render) opt
47+
// out by simply not passing them.
48+
modelStatus = null,
49+
onModelDownload,
50+
onModelDownloadCancel,
4251
}) {
4352
const isLocal = mode === IMAGE_GEN_MODE.LOCAL;
4453
const isCodex = mode === IMAGE_GEN_MODE.CODEX;
@@ -71,6 +80,14 @@ export default function ImageGenControls({
7180
>
7281
{models.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
7382
</select>
83+
{onModelDownload && modelStatus && (
84+
<ModelDownloadBadge
85+
status={modelStatus}
86+
onDownload={() => onModelDownload(modelId)}
87+
onCancel={onModelDownloadCancel}
88+
estimateLabel={deriveSizeEstimate(currentModel?.name)}
89+
/>
90+
)}
7491
</div>
7592
)}
7693

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Inline "Available · 7.8 GB" / "Download (~8 GB)" badge for the image and
2+
// video gen model pickers. Drops below the model <select> so the user can
3+
// see — before hitting Render — whether their pick still needs a multi-GB
4+
// HF pull. Hitting Render is NOT blocked: lazy download remains the
5+
// fallback, so a user who just wants to fire and wait can keep doing that.
6+
//
7+
// Three render states:
8+
// 1. cached → green CheckCircle, "Available · <size>"
9+
// 2. unknown → grey, no CTA (model has no `repo` in the registry)
10+
// 3. needsDl → "↓ Download (~est)" button; while downloading, a stage
11+
// label + percentage replace the button.
12+
13+
import { CheckCircle, Download, Loader2 } from 'lucide-react';
14+
import { formatBytes } from '../../utils/formatters.js';
15+
16+
const STAGE_LABELS = {
17+
starting: 'Starting…',
18+
list: 'Fetching file list…',
19+
download: 'Downloading…',
20+
};
21+
22+
export default function ModelDownloadBadge({
23+
status, // { id, repo, cached, sizeBytes, downloading?, progress? }
24+
onDownload, // () => void
25+
onCancel, // () => void
26+
estimateLabel, // e.g. "~8 GB" — caller derives from model entry name
27+
}) {
28+
if (!status) {
29+
return <p className="text-[10px] text-gray-500 mt-1">Checking model cache…</p>;
30+
}
31+
32+
// Unknown repo (custom mflux entry without `repo`) — just skip the badge
33+
// rather than mislead the user with "not downloaded".
34+
if (status.cached === null) {
35+
return null;
36+
}
37+
38+
if (status.downloading) {
39+
const frame = status.progress || {};
40+
const pct = typeof frame.progress === 'number' ? Math.round(frame.progress * 100) : null;
41+
const stage = STAGE_LABELS[frame.stage] || (frame.type === 'log' ? 'Downloading…' : (STAGE_LABELS[frame.type] || 'Downloading…'));
42+
const fileLine = frame.file ? `${frame.step}/${frame.total} · ${frame.file}` : '';
43+
return (
44+
<div className="mt-1 flex items-center gap-2 text-[11px]">
45+
<Loader2 className="w-3.5 h-3.5 animate-spin text-port-accent" />
46+
<div className="flex-1 min-w-0">
47+
<div className="flex items-center justify-between gap-2 text-port-accent">
48+
<span className="truncate">
49+
{stage}
50+
{pct != null ? ` ${pct}%` : ''}
51+
</span>
52+
{onCancel && (
53+
<button
54+
type="button"
55+
onClick={onCancel}
56+
className="text-gray-400 hover:text-white shrink-0"
57+
>
58+
Cancel
59+
</button>
60+
)}
61+
</div>
62+
{fileLine && (
63+
<div className="text-[10px] text-gray-500 truncate" title={fileLine}>{fileLine}</div>
64+
)}
65+
{pct != null && (
66+
<div className="mt-0.5 h-1 bg-port-border rounded overflow-hidden">
67+
<div className="h-full bg-port-accent" style={{ width: `${pct}%` }} />
68+
</div>
69+
)}
70+
</div>
71+
</div>
72+
);
73+
}
74+
75+
if (status.cached) {
76+
const sizeLabel = status.sizeBytes ? ` · ${formatBytes(status.sizeBytes)}` : '';
77+
return (
78+
<p className="mt-1 flex items-center gap-1.5 text-[11px] text-port-success">
79+
<CheckCircle className="w-3.5 h-3.5" />
80+
<span>Available{sizeLabel}</span>
81+
</p>
82+
);
83+
}
84+
85+
// Not cached, not in flight — offer the inline trigger.
86+
return (
87+
<button
88+
type="button"
89+
onClick={onDownload}
90+
className="mt-1 inline-flex items-center gap-1.5 text-[11px] text-port-accent hover:text-white border border-port-border hover:border-port-accent rounded px-2 py-1"
91+
title={status.repo ? `Pre-download ${status.repo} into ~/.cache/huggingface/hub/` : 'Pre-download model weights'}
92+
>
93+
<Download className="w-3.5 h-3.5" />
94+
<span>Download{estimateLabel ? ` (${estimateLabel})` : ''}</span>
95+
</button>
96+
);
97+
}
98+
99+
// Pull a size estimate out of the model's display name when the registry
100+
// embedded one (e.g. "Flux 2 Klein 4B (SDNQ 4-bit, ~8 GB @ 512px)"). The
101+
// registry isn't required to carry a structured size field, so we just
102+
// pluck whatever "~N GB" parenthetical the human-readable label included.
103+
export function deriveSizeEstimate(modelName) {
104+
if (!modelName) return null;
105+
const m = String(modelName).match(/~\s*(\d+(?:\.\d+)?)\s*GB/i);
106+
return m ? `~${m[1]} GB` : null;
107+
}

client/src/hooks/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ grep -i "what you want to do" client/src/hooks/README.md
3030
| Hook | Purpose | Use when |
3131
|---|---|---|
3232
| `useSseProgress` | Generic JSON-frame EventSource subscriber. | New SSE progress stream — start here, build on top. |
33+
| `useModelDownloadStatus` | Image/video model cache-status + SSE pre-download. | Surfacing "Available" vs "Download" badge inline in the gen form. |
3334
| `useImageGenProgress` | Live diffusion progress for an image-gen call. | Showing per-call image-gen progress. |
3435
| `useMediaJobProgress` | Live progress for a single `mediaJobQueue` job. | Subscribing to a known media-job id. |
3536
| `useOpenClawStream` | OpenClaw SSE chat stream. | OpenClaw file-browser chat surface only. |

client/src/hooks/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export * from './usePipelineEditorialProgress.js';
4747
export * from './usePipelineVolumeBeatsProgress.js';
4848
export * from './useSeriesEditorial.js';
4949
export * from './useSseProgress.js';
50+
export * from './useModelDownloadStatus.js';
5051

5152
// === Media (annotations, completion, attachments) ===
5253
export * from './useMediaAnnotations.js';
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { useEffect, useState, useCallback, useMemo } from 'react';
2+
import { useSseProgress } from './useSseProgress.js';
3+
import toast from '../components/ui/Toast';
4+
import { getImageModelStatuses, getVideoModelStatuses } from '../services/apiImageVideo.js';
5+
6+
// Sentinel `modelId` used to drive a text-encoder download instead of a model
7+
// download. The video form passes this to `start()`; the URL builder below
8+
// rewrites to the dedicated /text-encoder/download endpoint. Exported so
9+
// callers and the hook agree on the magic string.
10+
export const TEXT_ENCODER_DOWNLOAD_ID = '__text_encoder__';
11+
12+
const buildDownloadUrl = (kind, modelId) => {
13+
if (!modelId) return null;
14+
if (kind === 'video' && modelId === TEXT_ENCODER_DOWNLOAD_ID) {
15+
return '/api/video-gen/text-encoder/download';
16+
}
17+
return `/api/${kind}-gen/models/${encodeURIComponent(modelId)}/download`;
18+
};
19+
20+
// Model download-status hook. Drives the inline "Available · 7.8 GB" /
21+
// "Download (~8 GB)" badge next to the image/video gen model picker.
22+
//
23+
// `kind` selects the endpoint family ('image' | 'video'). `start(modelId)`
24+
// opens an EventSource against the download endpoint. When the stream
25+
// emits a terminal frame we automatically refetch /models/status so the
26+
// badge flips to "Available" without the caller wiring that up.
27+
export function useModelDownloadStatus({ kind = 'image' } = {}) {
28+
const [statuses, setStatuses] = useState(null);
29+
const [extra, setExtra] = useState({}); // video: { textEncoder: {...} }
30+
const [loading, setLoading] = useState(false);
31+
const [activeModelId, setActiveModelId] = useState(null);
32+
33+
const fetchStatuses = useCallback(async () => {
34+
setLoading(true);
35+
// Best-effort: a failure leaves the badge in its loading state. The form
36+
// still works because lazy download is the existing fallback.
37+
const body = await (kind === 'video' ? getVideoModelStatuses() : getImageModelStatuses())
38+
.catch(() => null);
39+
if (body == null) {
40+
setStatuses([]);
41+
setExtra({});
42+
} else if (kind === 'video') {
43+
setStatuses(Array.isArray(body?.models) ? body.models : []);
44+
setExtra({ textEncoder: body?.textEncoder || null });
45+
} else {
46+
setStatuses(Array.isArray(body) ? body : []);
47+
setExtra({});
48+
}
49+
setLoading(false);
50+
}, [kind]);
51+
52+
useEffect(() => { fetchStatuses(); }, [fetchStatuses]);
53+
54+
// EventSource for the active download. `null` URL = idle (useSseProgress's
55+
// `enabled: false` cleanup tears the connection down on cancel).
56+
const downloadUrl = buildDownloadUrl(kind, activeModelId);
57+
const sse = useSseProgress(downloadUrl, { enabled: !!downloadUrl });
58+
59+
// Refetch on natural stream close. useSseProgress flips `closed:true` once
60+
// per subscription and resets to false when the URL changes; that single
61+
// transition is the safe signal — no extra `wasClosed` ref needed.
62+
// A terminal error frame (gated repo, missing HF token, broken venv) is
63+
// routed to a toast here because the active-badge state vanishes the moment
64+
// we clear `activeModelId`; without this, the UI silently snaps back to the
65+
// Download button and the actionable server message is lost.
66+
useEffect(() => {
67+
if (sse.closed) {
68+
if (sse.latest?.type === 'error' && sse.latest?.message) {
69+
toast.error(sse.latest.message);
70+
}
71+
fetchStatuses();
72+
setActiveModelId(null);
73+
}
74+
}, [sse.closed, sse.latest, fetchStatuses]);
75+
76+
const start = useCallback((modelId) => {
77+
setActiveModelId(modelId);
78+
}, []);
79+
80+
// Manual cancel: refetch directly because `sse.close()` followed by
81+
// setActiveModelId(null) clears the URL, which causes useSseProgress to
82+
// reset `closed → false` before the close-effect can observe `true`.
83+
// Without this direct refetch the badge would stay stuck on its pre-cancel
84+
// state until the next page mount.
85+
const cancel = useCallback(() => {
86+
sse.close();
87+
setActiveModelId(null);
88+
fetchStatuses();
89+
}, [sse, fetchStatuses]);
90+
91+
// Memoize the active model's enriched status so a new SSE frame doesn't
92+
// hand a fresh object to every non-active model's badge (only the active
93+
// one re-renders). For inactive models we return the raw entry, which is
94+
// referentially stable across frames.
95+
const activeStatus = useMemo(() => {
96+
if (!activeModelId) return null;
97+
const list = Array.isArray(statuses) ? statuses : [];
98+
const entry = list.find((s) => s.id === activeModelId);
99+
if (!entry) return null;
100+
return { ...entry, downloading: true, progress: sse.latest };
101+
}, [activeModelId, statuses, sse.latest]);
102+
103+
const getStatus = useCallback((modelId) => {
104+
if (modelId === activeModelId) return activeStatus;
105+
const list = Array.isArray(statuses) ? statuses : [];
106+
return list.find((s) => s.id === modelId) || null;
107+
}, [statuses, activeModelId, activeStatus]);
108+
109+
return {
110+
statuses,
111+
extra,
112+
loading,
113+
refresh: fetchStatuses,
114+
start,
115+
cancel,
116+
getStatus,
117+
activeModelId,
118+
progress: sse.latest,
119+
downloading: !!activeModelId,
120+
};
121+
}

client/src/pages/ImageGen.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { resolveCleanersFromConfig } from '../lib/imageCleaners';
3939
import toast from '../components/ui/Toast';
4040
import BrailleSpinner from '../components/BrailleSpinner';
4141
import { useImageGenProgress } from '../hooks/useImageGenProgress';
42+
import { useModelDownloadStatus } from '../hooks/useModelDownloadStatus';
4243
import {
4344
getImageGenStatus, generateImage, listImageModels, listLorasFull, listImageGallery,
4445
cancelImageGen, deleteImage, setImageHidden, cleanGalleryImage, getActiveImageJob, getSettings,
@@ -193,6 +194,12 @@ export default function ImageGen() {
193194
// via imageGenEvents so the same UI bits light up).
194195
const { progress: externalProgress, begin: beginGenerate, end: endGenerate, resume: resumeGenerate } = useImageGenProgress();
195196

197+
// Per-model cache status drives the inline "Available / Download" badge
198+
// under the model picker. Only meaningful for the local backend (external
199+
// SD-API and Codex don't use HF cache), so we conditionally pass the
200+
// status through to ImageGenControls below.
201+
const modelDownload = useModelDownloadStatus({ kind: 'image' });
202+
196203
// selectedMode is null until settings load — fall back to status.mode
197204
// so the form doesn't flicker between defaults.
198205
const effectiveMode = selectedMode || status?.mode || IMAGE_GEN_MODE.EXTERNAL;
@@ -993,6 +1000,9 @@ export default function ImageGen() {
9931000
seed={seed} onSeedChange={setSeed}
9941001
showSeed
9951002
disabled={statusLoading}
1003+
modelStatus={isLocalMode ? modelDownload.getStatus(modelId) : null}
1004+
onModelDownload={isLocalMode ? modelDownload.start : undefined}
1005+
onModelDownloadCancel={modelDownload.cancel}
9961006
/>
9971007

9981008
{isLocalMode && (

0 commit comments

Comments
 (0)