Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ TBD
- **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.
- **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).
- **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).
- **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.
- 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.

## Changed
Expand Down
17 changes: 17 additions & 0 deletions client/src/components/imageGen/ImageGenControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { filterResolutions, resolveResolutionLabel } from '../../lib/imageGenRes
import { randomSeed } from '../../lib/genUtils';
import { RUNNER_FAMILIES } from '../../lib/runnerFamilies';
import { IMAGE_GEN_MODE } from '../../lib/imageGenBackends';
import ModelDownloadBadge, { deriveSizeEstimate } from '../media/ModelDownloadBadge';

const QUANTIZE_OPTIONS = [
{ value: '3', label: '3-bit' },
Expand All @@ -39,6 +40,14 @@ export default function ImageGenControls({
// Optional column override — defaults to 2/3 like the Image Gen page.
// Pass e.g. "grid-cols-2 sm:grid-cols-4" to fit a denser layout.
className = 'grid grid-cols-2 sm:grid-cols-3 gap-3',
// Pre-download badge integration. `modelStatus` is the per-model entry from
// useModelDownloadStatus().getStatus(modelId); `onModelDownload` /
// `onModelDownloadCancel` are optional triggers. Omitting the props hides
// the badge — callers that don't care (Universe Builder batch render) opt
// out by simply not passing them.
modelStatus = null,
onModelDownload,
onModelDownloadCancel,
}) {
const isLocal = mode === IMAGE_GEN_MODE.LOCAL;
const isCodex = mode === IMAGE_GEN_MODE.CODEX;
Expand Down Expand Up @@ -71,6 +80,14 @@ export default function ImageGenControls({
>
{models.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
{onModelDownload && modelStatus && (
<ModelDownloadBadge
status={modelStatus}
onDownload={() => onModelDownload(modelId)}
onCancel={onModelDownloadCancel}
estimateLabel={deriveSizeEstimate(currentModel?.name)}
/>
)}
</div>
)}

Expand Down
107 changes: 107 additions & 0 deletions client/src/components/media/ModelDownloadBadge.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Inline "Available · 7.8 GB" / "Download (~8 GB)" badge for the image and
// video gen model pickers. Drops below the model <select> so the user can
// see — before hitting Render — whether their pick still needs a multi-GB
// HF pull. Hitting Render is NOT blocked: lazy download remains the
// fallback, so a user who just wants to fire and wait can keep doing that.
//
// Three render states:
// 1. cached → green CheckCircle, "Available · <size>"
// 2. unknown → grey, no CTA (model has no `repo` in the registry)
// 3. needsDl → "↓ Download (~est)" button; while downloading, a stage
// label + percentage replace the button.

import { CheckCircle, Download, Loader2 } from 'lucide-react';
import { formatBytes } from '../../utils/formatters.js';

const STAGE_LABELS = {
starting: 'Starting…',
list: 'Fetching file list…',
download: 'Downloading…',
};

export default function ModelDownloadBadge({
status, // { id, repo, cached, sizeBytes, downloading?, progress? }
onDownload, // () => void
onCancel, // () => void
estimateLabel, // e.g. "~8 GB" — caller derives from model entry name
}) {
if (!status) {
return <p className="text-[10px] text-gray-500 mt-1">Checking model cache…</p>;
}

// Unknown repo (custom mflux entry without `repo`) — just skip the badge
// rather than mislead the user with "not downloaded".
if (status.cached === null) {
return null;
}

if (status.downloading) {
const frame = status.progress || {};
const pct = typeof frame.progress === 'number' ? Math.round(frame.progress * 100) : null;
const stage = STAGE_LABELS[frame.stage] || (frame.type === 'log' ? 'Downloading…' : (STAGE_LABELS[frame.type] || 'Downloading…'));
const fileLine = frame.file ? `${frame.step}/${frame.total} · ${frame.file}` : '';
return (
<div className="mt-1 flex items-center gap-2 text-[11px]">
<Loader2 className="w-3.5 h-3.5 animate-spin text-port-accent" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 text-port-accent">
<span className="truncate">
{stage}
{pct != null ? ` ${pct}%` : ''}
</span>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="text-gray-400 hover:text-white shrink-0"
>
Cancel
</button>
)}
</div>
{fileLine && (
<div className="text-[10px] text-gray-500 truncate" title={fileLine}>{fileLine}</div>
)}
{pct != null && (
<div className="mt-0.5 h-1 bg-port-border rounded overflow-hidden">
<div className="h-full bg-port-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
</div>
);
}

if (status.cached) {
const sizeLabel = status.sizeBytes ? ` · ${formatBytes(status.sizeBytes)}` : '';
return (
<p className="mt-1 flex items-center gap-1.5 text-[11px] text-port-success">
<CheckCircle className="w-3.5 h-3.5" />
<span>Available{sizeLabel}</span>
</p>
);
}

// Not cached, not in flight — offer the inline trigger.
return (
<button
type="button"
onClick={onDownload}
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"
title={status.repo ? `Pre-download ${status.repo} into ~/.cache/huggingface/hub/` : 'Pre-download model weights'}
>
<Download className="w-3.5 h-3.5" />
<span>Download{estimateLabel ? ` (${estimateLabel})` : ''}</span>
</button>
);
}

// Pull a size estimate out of the model's display name when the registry
// embedded one (e.g. "Flux 2 Klein 4B (SDNQ 4-bit, ~8 GB @ 512px)"). The
// registry isn't required to carry a structured size field, so we just
// pluck whatever "~N GB" parenthetical the human-readable label included.
export function deriveSizeEstimate(modelName) {
if (!modelName) return null;
const m = String(modelName).match(/~\s*(\d+(?:\.\d+)?)\s*GB/i);
return m ? `~${m[1]} GB` : null;
}
1 change: 1 addition & 0 deletions client/src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| Hook | Purpose | Use when |
|---|---|---|
| `useSseProgress` | Generic JSON-frame EventSource subscriber. | New SSE progress stream — start here, build on top. |
| `useModelDownloadStatus` | Image/video model cache-status + SSE pre-download. | Surfacing "Available" vs "Download" badge inline in the gen form. |
| `useImageGenProgress` | Live diffusion progress for an image-gen call. | Showing per-call image-gen progress. |
| `useMediaJobProgress` | Live progress for a single `mediaJobQueue` job. | Subscribing to a known media-job id. |
| `useOpenClawStream` | OpenClaw SSE chat stream. | OpenClaw file-browser chat surface only. |
Expand Down
1 change: 1 addition & 0 deletions client/src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export * from './usePipelineEditorialProgress.js';
export * from './usePipelineVolumeBeatsProgress.js';
export * from './useSeriesEditorial.js';
export * from './useSseProgress.js';
export * from './useModelDownloadStatus.js';

// === Media (annotations, completion, attachments) ===
export * from './useMediaAnnotations.js';
Expand Down
121 changes: 121 additions & 0 deletions client/src/hooks/useModelDownloadStatus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useSseProgress } from './useSseProgress.js';
import toast from '../components/ui/Toast';
import { getImageModelStatuses, getVideoModelStatuses } from '../services/apiImageVideo.js';

// Sentinel `modelId` used to drive a text-encoder download instead of a model
// download. The video form passes this to `start()`; the URL builder below
// rewrites to the dedicated /text-encoder/download endpoint. Exported so
// callers and the hook agree on the magic string.
export const TEXT_ENCODER_DOWNLOAD_ID = '__text_encoder__';

const buildDownloadUrl = (kind, modelId) => {
if (!modelId) return null;
if (kind === 'video' && modelId === TEXT_ENCODER_DOWNLOAD_ID) {
return '/api/video-gen/text-encoder/download';
}
return `/api/${kind}-gen/models/${encodeURIComponent(modelId)}/download`;
};

// Model download-status hook. Drives the inline "Available · 7.8 GB" /
// "Download (~8 GB)" badge next to the image/video gen model picker.
//
// `kind` selects the endpoint family ('image' | 'video'). `start(modelId)`
// opens an EventSource against the download endpoint. When the stream
// emits a terminal frame we automatically refetch /models/status so the
// badge flips to "Available" without the caller wiring that up.
export function useModelDownloadStatus({ kind = 'image' } = {}) {
const [statuses, setStatuses] = useState(null);
const [extra, setExtra] = useState({}); // video: { textEncoder: {...} }
const [loading, setLoading] = useState(false);
const [activeModelId, setActiveModelId] = useState(null);

const fetchStatuses = useCallback(async () => {
setLoading(true);
// Best-effort: a failure leaves the badge in its loading state. The form
// still works because lazy download is the existing fallback.
const body = await (kind === 'video' ? getVideoModelStatuses() : getImageModelStatuses())
.catch(() => null);
if (body == null) {
setStatuses([]);
setExtra({});
} else if (kind === 'video') {
setStatuses(Array.isArray(body?.models) ? body.models : []);
setExtra({ textEncoder: body?.textEncoder || null });
} else {
setStatuses(Array.isArray(body) ? body : []);
setExtra({});
}
setLoading(false);
}, [kind]);

useEffect(() => { fetchStatuses(); }, [fetchStatuses]);

// EventSource for the active download. `null` URL = idle (useSseProgress's
// `enabled: false` cleanup tears the connection down on cancel).
const downloadUrl = buildDownloadUrl(kind, activeModelId);
const sse = useSseProgress(downloadUrl, { enabled: !!downloadUrl });

// Refetch on natural stream close. useSseProgress flips `closed:true` once
// per subscription and resets to false when the URL changes; that single
// transition is the safe signal — no extra `wasClosed` ref needed.
// A terminal error frame (gated repo, missing HF token, broken venv) is
// routed to a toast here because the active-badge state vanishes the moment
// we clear `activeModelId`; without this, the UI silently snaps back to the
// Download button and the actionable server message is lost.
useEffect(() => {
if (sse.closed) {
if (sse.latest?.type === 'error' && sse.latest?.message) {
toast.error(sse.latest.message);
}
fetchStatuses();
setActiveModelId(null);
}
}, [sse.closed, sse.latest, fetchStatuses]);

const start = useCallback((modelId) => {
setActiveModelId(modelId);
}, []);

// Manual cancel: refetch directly because `sse.close()` followed by
// setActiveModelId(null) clears the URL, which causes useSseProgress to
// reset `closed → false` before the close-effect can observe `true`.
// Without this direct refetch the badge would stay stuck on its pre-cancel
// state until the next page mount.
const cancel = useCallback(() => {
sse.close();
setActiveModelId(null);
fetchStatuses();
}, [sse, fetchStatuses]);

// Memoize the active model's enriched status so a new SSE frame doesn't
// hand a fresh object to every non-active model's badge (only the active
// one re-renders). For inactive models we return the raw entry, which is
// referentially stable across frames.
const activeStatus = useMemo(() => {
if (!activeModelId) return null;
const list = Array.isArray(statuses) ? statuses : [];
const entry = list.find((s) => s.id === activeModelId);
if (!entry) return null;
return { ...entry, downloading: true, progress: sse.latest };
}, [activeModelId, statuses, sse.latest]);

const getStatus = useCallback((modelId) => {
if (modelId === activeModelId) return activeStatus;
const list = Array.isArray(statuses) ? statuses : [];
return list.find((s) => s.id === modelId) || null;
}, [statuses, activeModelId, activeStatus]);

return {
statuses,
extra,
loading,
refresh: fetchStatuses,
start,
cancel,
getStatus,
activeModelId,
progress: sse.latest,
downloading: !!activeModelId,
};
}
10 changes: 10 additions & 0 deletions client/src/pages/ImageGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { resolveCleanersFromConfig } from '../lib/imageCleaners';
import toast from '../components/ui/Toast';
import BrailleSpinner from '../components/BrailleSpinner';
import { useImageGenProgress } from '../hooks/useImageGenProgress';
import { useModelDownloadStatus } from '../hooks/useModelDownloadStatus';
import {
getImageGenStatus, generateImage, listImageModels, listLorasFull, listImageGallery,
cancelImageGen, deleteImage, setImageHidden, cleanGalleryImage, getActiveImageJob, getSettings,
Expand Down Expand Up @@ -193,6 +194,12 @@ export default function ImageGen() {
// via imageGenEvents so the same UI bits light up).
const { progress: externalProgress, begin: beginGenerate, end: endGenerate, resume: resumeGenerate } = useImageGenProgress();

// Per-model cache status drives the inline "Available / Download" badge
// under the model picker. Only meaningful for the local backend (external
// SD-API and Codex don't use HF cache), so we conditionally pass the
// status through to ImageGenControls below.
const modelDownload = useModelDownloadStatus({ kind: 'image' });

// selectedMode is null until settings load — fall back to status.mode
// so the form doesn't flicker between defaults.
const effectiveMode = selectedMode || status?.mode || IMAGE_GEN_MODE.EXTERNAL;
Expand Down Expand Up @@ -993,6 +1000,9 @@ export default function ImageGen() {
seed={seed} onSeedChange={setSeed}
showSeed
disabled={statusLoading}
modelStatus={isLocalMode ? modelDownload.getStatus(modelId) : null}
onModelDownload={isLocalMode ? modelDownload.start : undefined}
onModelDownloadCancel={modelDownload.cancel}
/>

{isLocalMode && (
Expand Down
Loading