Fix image load progress, hide diffusion models from chat picker, and gallery polish
This commit is contained in:
parent
453efa7ae7
commit
adebdfc9f4
7 changed files with 237 additions and 64 deletions
|
|
@ -177,10 +177,12 @@ class DiffusionBackend:
|
|||
|
||||
downloaded = self._cache_bytes(loading.repo_id) + self._cache_bytes(loading.base_repo)
|
||||
expected = loading.expected_bytes
|
||||
# Downloads done but pipeline still dequantising / moving to GPU.
|
||||
# Downloads done but pipeline still dequantising / moving to GPU. The cache
|
||||
# scan can slightly exceed the estimate (extra cached quants, blob padding),
|
||||
# so clamp the reported bytes/fraction so the bar never overshoots 100%.
|
||||
if expected > 0 and downloaded >= expected * 0.999:
|
||||
return _progress("finalizing", downloaded, expected, 1.0)
|
||||
fraction = downloaded / expected if expected > 0 else 0.0
|
||||
return _progress("finalizing", min(downloaded, expected), expected, 1.0)
|
||||
fraction = min(downloaded / expected, 1.0) if expected > 0 else 0.0
|
||||
return _progress("downloading", downloaded, expected, fraction)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -196,12 +198,8 @@ class DiffusionBackend:
|
|||
info = api.model_info(repo_id, files_metadata = True, token = hf_token)
|
||||
total += sum(s.size or 0 for s in info.siblings if s.rfilename == gguf_filename)
|
||||
base_info = api.model_info(base_repo, files_metadata = True, token = hf_token)
|
||||
# The transformer subfolder is supplied by the GGUF, so from_pretrained
|
||||
# never downloads it — exclude it from the expected total.
|
||||
total += sum(
|
||||
s.size or 0
|
||||
for s in base_info.siblings
|
||||
if not s.rfilename.startswith("transformer/")
|
||||
s.size or 0 for s in base_info.siblings if _base_file_downloaded(s.rfilename)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — estimate is best-effort
|
||||
logger.warning("diffusion.size_estimate_failed: %s", exc)
|
||||
|
|
@ -373,6 +371,21 @@ class DiffusionBackend:
|
|||
}
|
||||
|
||||
|
||||
def _base_file_downloaded(rfilename: str) -> bool:
|
||||
"""True for base-repo files ``from_pretrained`` actually fetches.
|
||||
|
||||
The transformer is supplied by the GGUF, and repo docs (``assets/``, the
|
||||
top-level README/PDF/images) are never downloaded — counting them would peg
|
||||
the progress estimate above what lands on disk, so the bar would sit short of
|
||||
100% for the whole pipeline-load phase instead of advancing to "finalizing".
|
||||
"""
|
||||
if rfilename.startswith("transformer/"):
|
||||
return False
|
||||
if "/" not in rfilename: # top-level: only the pipeline manifest is fetched
|
||||
return rfilename == "model_index.json"
|
||||
return not rfilename.startswith("assets/")
|
||||
|
||||
|
||||
def _progress(
|
||||
phase: Optional[str],
|
||||
bytes_downloaded: int = 0,
|
||||
|
|
|
|||
|
|
@ -3215,6 +3215,20 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)):
|
|||
return {"cached": []}
|
||||
|
||||
|
||||
def _repo_is_diffusers(repo_info) -> bool:
|
||||
"""A diffusers pipeline repo (e.g. a Z-Image / FLUX base) carries a top-level
|
||||
model_index.json. These render images rather than chat, so the chat picker
|
||||
hides them — mirroring how cached diffusion GGUFs are classified by arch."""
|
||||
try:
|
||||
for rev in repo_info.revisions:
|
||||
for f in rev.files:
|
||||
if f.file_name == "model_index.json" or f.file_name.endswith("/model_index.json"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/cached-models")
|
||||
async def list_cached_models(current_subject: str = Depends(get_current_subject)):
|
||||
"""List non-GGUF model repos downloaded to HF cache, legacy Unsloth cache, and HF default cache."""
|
||||
|
|
@ -3261,6 +3275,7 @@ async def list_cached_models(current_subject: str = Depends(get_current_subject)
|
|||
row = {
|
||||
"repo_id": repo_id,
|
||||
"size_bytes": total_size,
|
||||
"task": "text-to-image" if _repo_is_diffusers(repo_info) else None,
|
||||
}
|
||||
# Keep the newest timestamp across duplicate caches;
|
||||
# attach only when known so absent rows sort as oldest.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monk
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(repo.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -105,6 +106,7 @@ def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_
|
|||
"size_bytes": 7_000,
|
||||
"cache_path": str(repo.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -200,6 +202,7 @@ def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(monkeypatch,
|
|||
"size_bytes": 6_000,
|
||||
"cache_path": str(larger.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -230,6 +233,7 @@ def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(repo.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -280,6 +284,7 @@ def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypa
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(mixed.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -307,6 +312,7 @@ def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(partial.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -343,6 +349,7 @@ def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypat
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(healthy.repo_path),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -390,7 +397,35 @@ def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp
|
|||
|
||||
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [{"repo_id": "Org/MmprojAux", "size_bytes": 15_000}]
|
||||
assert result["cached"] == [{"repo_id": "Org/MmprojAux", "size_bytes": 15_000, "task": None}]
|
||||
|
||||
|
||||
def test_list_cached_models_tags_diffusers_pipeline_as_text_to_image(monkeypatch, tmp_path):
|
||||
"""A cached diffusers pipeline repo (model_index.json present) is tagged
|
||||
text-to-image so the chat picker hides it, while a plain checkpoint isn't."""
|
||||
diffusion = _repo(
|
||||
"Tongyi-MAI/Z-Image-Turbo",
|
||||
[_file("model_index.json", 1_000), _file("text_encoder/model.safetensors", 9_000)],
|
||||
tmp_path / "models--Tongyi-MAI--Z-Image-Turbo",
|
||||
)
|
||||
checkpoint = _repo(
|
||||
"unsloth/Llama-3.2-1B-Instruct",
|
||||
[_file("config.json", 1_000), _file("model.safetensors", 9_000)],
|
||||
tmp_path / "models--unsloth--Llama-3.2-1B-Instruct",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [diffusion, checkpoint])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
|
||||
by_repo = {c["repo_id"]: c["task"] for c in result["cached"]}
|
||||
assert by_repo == {
|
||||
"Tongyi-MAI/Z-Image-Turbo": "text-to-image",
|
||||
"unsloth/Llama-3.2-1B-Instruct": None,
|
||||
}
|
||||
|
||||
|
||||
def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeypatch, tmp_path):
|
||||
|
|
@ -419,6 +454,7 @@ def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeyp
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(vision_repo.repo_path),
|
||||
"has_vision": True,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -473,6 +509,7 @@ def test_all_hf_cache_scans_survives_inaccessible_aux_cache(monkeypatch, tmp_pat
|
|||
"size_bytes": 5_000,
|
||||
"cache_path": str(tmp_path / "active"),
|
||||
"has_vision": False,
|
||||
"task": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import types
|
|||
|
||||
import pytest
|
||||
|
||||
from core.inference.diffusion import DiffusionBackend, encode_png_base64
|
||||
from core.inference.diffusion import (
|
||||
DiffusionBackend,
|
||||
_base_file_downloaded,
|
||||
encode_png_base64,
|
||||
)
|
||||
from core.inference.diffusion_families import (
|
||||
detect_family,
|
||||
resolve_base_repo,
|
||||
|
|
@ -270,6 +274,31 @@ def test_load_progress_downloading_then_finalizing(monkeypatch):
|
|||
assert backend.load_progress()["phase"] == "finalizing" # 1000/1000
|
||||
|
||||
|
||||
def test_base_file_downloaded_excludes_undownloaded():
|
||||
# Counted: the pipeline manifest + component subfolders from_pretrained fetches.
|
||||
assert _base_file_downloaded("model_index.json")
|
||||
assert _base_file_downloaded("text_encoder/model-00001-of-00003.safetensors")
|
||||
assert _base_file_downloaded("vae/diffusion_pytorch_model.safetensors")
|
||||
# Excluded: the GGUF supplies the transformer; docs/assets and top-level files
|
||||
# are never downloaded, so counting them would peg the bar short of 100%.
|
||||
assert not _base_file_downloaded("transformer/diffusion_pytorch_model-00001-of-00003.safetensors")
|
||||
assert not _base_file_downloaded("assets/Z-Image-Gallery.pdf")
|
||||
assert not _base_file_downloaded("README.md")
|
||||
assert not _base_file_downloaded(".gitattributes")
|
||||
|
||||
|
||||
def test_load_progress_fraction_clamped(monkeypatch):
|
||||
# The cache scan can exceed the estimate (e.g. a second cached quant); the
|
||||
# reported fraction must still clamp to 1.0 rather than overshoot.
|
||||
backend = DiffusionBackend()
|
||||
backend._loading = _LoadingState(repo_id = "r", base_repo = "b", expected_bytes = 1000)
|
||||
monkeypatch.setattr(DiffusionBackend, "_cache_bytes", staticmethod(lambda repo: 900))
|
||||
p = backend.load_progress() # summed 1800 > expected 1000
|
||||
assert p["phase"] == "finalizing"
|
||||
assert p["fraction"] == 1.0
|
||||
assert p["bytes_downloaded"] == 1000 # clamped to the estimate
|
||||
|
||||
|
||||
def test_begin_load_rejects_concurrent(monkeypatch):
|
||||
backend = DiffusionBackend()
|
||||
monkeypatch.setattr(DiffusionBackend, "_estimate_download_bytes", staticmethod(lambda *a, **k: 0))
|
||||
|
|
|
|||
|
|
@ -975,6 +975,22 @@ function taskMatchesFilter(repoTask: string | null | undefined, filter: HfTaskFi
|
|||
return repoTask != null && (wanted as readonly string[]).includes(repoTask);
|
||||
}
|
||||
|
||||
// Image-generation pipeline tasks: handled by the Images page, never loadable as
|
||||
// chat models. The backend reports "text-to-image" for diffusion-arch GGUFs.
|
||||
const IMAGE_GEN_TASKS: readonly string[] = [
|
||||
"text-to-image",
|
||||
"image-to-image",
|
||||
"image-text-to-image",
|
||||
];
|
||||
|
||||
// Gate an on-device model by the picker's task scope. With a filter (the Images
|
||||
// page) keep only matching tasks; with no filter (chat) drop image-generation
|
||||
// models so a downloaded diffusion GGUF doesn't show up as a loadable chat model.
|
||||
function passesTaskGate(repoTask: string | null | undefined, filter: HfTaskFilter): boolean {
|
||||
if (filter) return taskMatchesFilter(repoTask, filter);
|
||||
return !(repoTask != null && IMAGE_GEN_TASKS.includes(repoTask));
|
||||
}
|
||||
|
||||
// Module-level caches so re-mounting the popover shows results instantly
|
||||
let _cachedGgufCache: CachedGgufRepo[] = [];
|
||||
let _cachedModelsCache: CachedModelRepo[] = [];
|
||||
|
|
@ -1720,22 +1736,30 @@ export function HubModelPicker({
|
|||
return map;
|
||||
}, [results, recommendedSearch.results]);
|
||||
|
||||
// Ordered by the On Device dropdown (recent/download date/size/name). When a
|
||||
// task filter is set (e.g. Images page), drop cached repos whose architecture
|
||||
// is not that task so On Device matches the Recommended tab.
|
||||
// Ordered by the On Device dropdown (recent/download date/size/name). The task
|
||||
// gate keeps the Images picker to diffusion GGUFs and, conversely, hides those
|
||||
// diffusion GGUFs from the chat picker (where they aren't loadable models).
|
||||
const sortedCachedGguf = useMemo(
|
||||
() =>
|
||||
sortCachedRepos(
|
||||
task ? cachedGguf.filter((c) => taskMatchesFilter(c.task, task)) : cachedGguf,
|
||||
cachedGguf.filter((c) => passesTaskGate(c.task, task)),
|
||||
downloadedSort,
|
||||
loadTimes,
|
||||
),
|
||||
[cachedGguf, downloadedSort, loadTimes, task],
|
||||
);
|
||||
// Non-GGUF (safetensors) cached repos aren't single-file diffusion GGUFs, so
|
||||
// hide them entirely when a task filter is active (the Images picker).
|
||||
// hide them entirely when a task filter is active (the Images picker). In chat,
|
||||
// drop cached diffusers pipeline repos (a Z-Image / FLUX base) the same way.
|
||||
const sortedCachedModels = useMemo(
|
||||
() => (task ? [] : sortCachedRepos(cachedModels, downloadedSort, loadTimes)),
|
||||
() =>
|
||||
task
|
||||
? []
|
||||
: sortCachedRepos(
|
||||
cachedModels.filter((c) => passesTaskGate(c.task, task)),
|
||||
downloadedSort,
|
||||
loadTimes,
|
||||
),
|
||||
[cachedModels, downloadedSort, loadTimes, task],
|
||||
);
|
||||
// Each local section's search is scoped to its own models (matched by name).
|
||||
|
|
@ -1745,14 +1769,15 @@ export function HubModelPicker({
|
|||
normalizeForSearch(
|
||||
`${m.model_id ?? ""} ${m.display_name} ${m.id}`,
|
||||
).includes(localQuery);
|
||||
// A task filter (the Images page) wants diffusion GGUFs only, so local models
|
||||
// are filtered to that task (by the GGUF architecture the backend reports).
|
||||
// The Images page wants diffusion GGUFs only; chat wants everything but those.
|
||||
// passesTaskGate handles both directions off the GGUF architecture the backend
|
||||
// reports as each model's task.
|
||||
const sortedLmStudio = useMemo(
|
||||
() =>
|
||||
sortLocalModels(
|
||||
lmStudioModels.filter(
|
||||
(m) =>
|
||||
(!task || taskMatchesFilter(m.task, task)) &&
|
||||
passesTaskGate(m.task, task) &&
|
||||
localModelMatchesFormat(m, formatFilter) &&
|
||||
matchesLocalQuery(m),
|
||||
),
|
||||
|
|
@ -1770,7 +1795,7 @@ export function HubModelPicker({
|
|||
sortLocalModels(
|
||||
localDirModels.filter(
|
||||
(m) =>
|
||||
(!task || taskMatchesFilter(m.task, task)) &&
|
||||
passesTaskGate(m.task, task) &&
|
||||
(!chatOnly ||
|
||||
localModelIsGguf(m) ||
|
||||
(isMac && localModelIsMlx(m))) &&
|
||||
|
|
@ -1797,7 +1822,7 @@ export function HubModelPicker({
|
|||
sortLocalModels(
|
||||
customFolderModels.filter(
|
||||
(m) =>
|
||||
(!task || taskMatchesFilter(m.task, task)) &&
|
||||
passesTaskGate(m.task, task) &&
|
||||
localModelMatchesFormat(m, formatFilter) &&
|
||||
matchesLocalQuery(m),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -319,6 +319,9 @@ export interface CachedModelRepo {
|
|||
/** Epoch seconds of the newest downloaded weight file; sorts Downloaded
|
||||
* newest-first. Optional for older-backend compatibility. */
|
||||
last_modified?: number;
|
||||
/** HF pipeline task: "text-to-image" for a cached diffusers pipeline repo
|
||||
* (model_index.json present), so the chat picker can hide it. Absent = chat. */
|
||||
task?: string | null;
|
||||
}
|
||||
|
||||
export async function listCachedModels(): Promise<CachedModelRepo[]> {
|
||||
|
|
|
|||
|
|
@ -89,28 +89,64 @@ function downloadImage(src: string, seed: number) {
|
|||
link.click();
|
||||
}
|
||||
|
||||
// Title + percent/label for the chat progress component, from a progress poll.
|
||||
function progressView(p: DiffusionLoadProgress): {
|
||||
title: string;
|
||||
progressPercent: number | null;
|
||||
progressLabel: string | null;
|
||||
} {
|
||||
const title = p.phase === "finalizing" ? "Loading to GPU…" : "Downloading model…";
|
||||
if (p.bytes_total > 0) {
|
||||
return {
|
||||
title,
|
||||
progressPercent: p.fraction * 100,
|
||||
progressLabel: `${formatBytes(p.bytes_downloaded)} of ${formatBytes(p.bytes_total)}`,
|
||||
};
|
||||
}
|
||||
// Unknown total: show bytes counting up, no bar.
|
||||
// The chat tab's model-load toast styling, reused verbatim so the diffusion
|
||||
// load toast is visually identical (persistent, progress bar, same chrome).
|
||||
const LOAD_TOAST_CLASSNAMES = {
|
||||
toast: "chat-model-load-toast items-center gap-2.5",
|
||||
content: "gap-0.5 flex-1 min-w-0",
|
||||
title: "leading-5",
|
||||
description: "mt-0 w-full",
|
||||
} as const;
|
||||
|
||||
// Render the chat ModelLoadDescription for a progress poll. The base text-
|
||||
// encoder/VAE repo downloads alongside the GGUF, so the total can exceed the
|
||||
// picked quant's size — that's the real one-time download.
|
||||
function loadToastDescription(p: DiffusionLoadProgress) {
|
||||
// "Downloading" only when bytes actually remain to fetch — a cached model (or
|
||||
// the pre-estimate window, total still 0) shouldn't claim a download.
|
||||
const downloading = p.bytes_total > 0 && p.bytes_downloaded < p.bytes_total * 0.999;
|
||||
const title = downloading
|
||||
? "Downloading model…"
|
||||
: p.phase === "finalizing"
|
||||
? "Loading to GPU…"
|
||||
: "Starting model…";
|
||||
const hasTotal = p.bytes_total > 0;
|
||||
return (
|
||||
<ModelLoadDescription
|
||||
title={title}
|
||||
message={`Loading ${MODEL.label}. This may include downloading its base model.`}
|
||||
progressPercent={hasTotal ? p.fraction * 100 : null}
|
||||
progressLabel={
|
||||
hasTotal
|
||||
? `${formatBytes(p.bytes_downloaded)} of ${formatBytes(p.bytes_total)}`
|
||||
: p.bytes_downloaded > 0
|
||||
? `${formatBytes(p.bytes_downloaded)} downloaded`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Toast args mirroring chat's: persistent, closeable, content in `description`.
|
||||
// Pass `id` to update the existing toast in place instead of stacking a new one.
|
||||
function loadToastArgs(p: DiffusionLoadProgress, id?: string | number) {
|
||||
return {
|
||||
title,
|
||||
progressPercent: null,
|
||||
progressLabel: p.bytes_downloaded > 0 ? `${formatBytes(p.bytes_downloaded)} downloaded` : null,
|
||||
...(id != null ? { id } : {}),
|
||||
description: loadToastDescription(p),
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
classNames: LOAD_TOAST_CLASSNAMES,
|
||||
};
|
||||
}
|
||||
|
||||
const IDLE_PROGRESS: DiffusionLoadProgress = {
|
||||
phase: null,
|
||||
bytes_downloaded: 0,
|
||||
bytes_total: 0,
|
||||
fraction: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Mirrors the Train page's SliderRow (studio/sections/params-section.tsx):
|
||||
// label + standard Slider + number input, same classes.
|
||||
function SliderField({
|
||||
|
|
@ -179,13 +215,19 @@ export function ImagesPage() {
|
|||
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const [status, setStatus] = useState<DiffusionStatus | null>(null);
|
||||
const [loadProgress, setLoadProgress] = useState<DiffusionLoadProgress | null>(null);
|
||||
const [results, setResults] = useState<ResultItem[]>(() => imageSession.results);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(() => imageSession.selectedId);
|
||||
// Stable, ever-increasing ids: results are prepended, so an array index would
|
||||
// re-key every existing image on each new generation.
|
||||
const nextResultId = useRef(imageSession.nextId);
|
||||
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// The persistent load toast's id, so each poll updates it in place (chat-style).
|
||||
const loadToastId = useRef<string | number | null>(null);
|
||||
|
||||
const dismissLoadToast = useCallback(() => {
|
||||
if (loadToastId.current != null) toast.dismiss(loadToastId.current);
|
||||
loadToastId.current = null;
|
||||
}, []);
|
||||
|
||||
// Persist the gallery to module scope so a tab switch doesn't drop it.
|
||||
useEffect(() => {
|
||||
|
|
@ -217,36 +259,39 @@ export function ImagesPage() {
|
|||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
// Poll load-progress until the background load reaches "ready" or "error".
|
||||
// Poll load-progress until the background load reaches "ready" or "error",
|
||||
// updating the persistent toast in place each tick.
|
||||
const pollLoadProgress = useCallback(async () => {
|
||||
try {
|
||||
const p = await getDiffusionLoadProgress();
|
||||
setLoadProgress(p);
|
||||
if (p.phase === "ready") {
|
||||
dismissLoadToast();
|
||||
setStatus(await getDiffusionStatus());
|
||||
toast.success("Model loaded");
|
||||
setBusy(null);
|
||||
setLoadProgress(null);
|
||||
return;
|
||||
}
|
||||
if (p.phase === "error") {
|
||||
dismissLoadToast();
|
||||
toast.error(p.error || "Failed to load model");
|
||||
setBusy(null);
|
||||
setLoadProgress(null);
|
||||
return;
|
||||
}
|
||||
if (loadToastId.current != null) toast(null, loadToastArgs(p, loadToastId.current));
|
||||
} catch {
|
||||
// Transient poll failure: keep trying.
|
||||
}
|
||||
pollTimer.current = setTimeout(() => void pollLoadProgress(), 1000);
|
||||
}, []);
|
||||
}, [dismissLoadToast]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (ggufFilename: string) => {
|
||||
// Cancel any prior poll loop so two can't run at once.
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
setBusy("loading");
|
||||
setLoadProgress(null);
|
||||
// Show the chat-style toast immediately; the poll updates it by id.
|
||||
dismissLoadToast();
|
||||
loadToastId.current = toast(null, loadToastArgs(IDLE_PROGRESS));
|
||||
try {
|
||||
// Returns immediately — the load runs in the background; we poll for it.
|
||||
await loadDiffusionModel({
|
||||
|
|
@ -255,6 +300,7 @@ export function ImagesPage() {
|
|||
family_override: MODEL.family,
|
||||
});
|
||||
} catch (err) {
|
||||
dismissLoadToast();
|
||||
toast.error(err instanceof Error ? err.message : "Failed to start load");
|
||||
setBusy(null);
|
||||
void refreshStatus();
|
||||
|
|
@ -262,7 +308,7 @@ export function ImagesPage() {
|
|||
}
|
||||
void pollLoadProgress();
|
||||
},
|
||||
[pollLoadProgress, refreshStatus],
|
||||
[pollLoadProgress, refreshStatus, dismissLoadToast],
|
||||
);
|
||||
|
||||
// The chat picker emits (modelId, picked quant); load its matching GGUF.
|
||||
|
|
@ -346,7 +392,8 @@ export function ImagesPage() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* ── Top: the chat-style model selector (header padding mirrors chat) ── */}
|
||||
{/* ── Top: the chat-style model selector (header padding mirrors chat). The
|
||||
load progress shows in a chat-style toast, not here. ── */}
|
||||
<div className="flex h-[48px] shrink-0 items-start pl-2 pr-2 pt-[11px]">
|
||||
<ModelSelector
|
||||
models={MODELS}
|
||||
|
|
@ -442,6 +489,12 @@ export function ImagesPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : busy === "generating" ? (
|
||||
// First image (no past result to keep showing): spin in place.
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Spinner className="size-8" />
|
||||
<p className="text-sm">Generating…</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<HugeiconsIcon icon={ImageAdd02Icon} className="size-12" strokeWidth={1.5} />
|
||||
|
|
@ -452,33 +505,31 @@ export function ImagesPage() {
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{busy === "generating" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-sm">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
)}
|
||||
{/* Download progress, lower-right (chat-style). */}
|
||||
{busy === "loading" && loadProgress && (
|
||||
<div className="absolute bottom-4 right-4 w-72 rounded-xl bg-card p-3 shadow-lg ring-1 ring-foreground/10">
|
||||
<ModelLoadDescription {...progressView(loadProgress)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
{(results.length > 0 || busy === "generating") && (
|
||||
<div className="flex shrink-0 gap-2 overflow-x-auto border-t border-foreground/10 p-3">
|
||||
{/* In-progress generation: a placeholder tile at the front so past
|
||||
images stay visible and browsable while the new one renders. */}
|
||||
{busy === "generating" && (
|
||||
<div className="flex size-16 shrink-0 animate-pulse items-center justify-center rounded-lg bg-muted/50 ring-2 ring-primary/30">
|
||||
<Spinner className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(r.id)}
|
||||
title={`seed ${r.seed}`}
|
||||
className={cn(
|
||||
"size-16 shrink-0 overflow-hidden rounded-lg ring-2 transition-colors",
|
||||
r.id === selected?.id ? "ring-primary" : "ring-transparent hover:ring-border",
|
||||
)}
|
||||
className="relative size-16 shrink-0 overflow-hidden rounded-lg outline-none ring-1 ring-transparent transition-shadow hover:ring-border focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<img src={r.src} alt={r.prompt} className="size-full object-cover" />
|
||||
{/* Selection marker on a non-focusable overlay, so the button's
|
||||
own focus state can never mask it. */}
|
||||
{r.id === selected?.id && (
|
||||
<span className="pointer-events-none absolute inset-0 rounded-lg border-2 border-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue