Studio: keep automatic model loading toast visible until completion (#7425)
This commit is contained in:
parent
36e83de336
commit
ba512f69e4
5 changed files with 125 additions and 31 deletions
|
|
@ -8,8 +8,8 @@ import {
|
|||
MultiplicationSignCircleIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useTheme } from "@/features/settings/stores/theme-store";
|
||||
import { createLoadingToastIcon } from "@/lib/toast";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
// Make toast text selectable. Sonner's onPointerDown calls setPointerCapture(),
|
||||
|
|
@ -78,7 +78,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||
/>
|
||||
),
|
||||
// App-wide arc spinner so loading toasts match the "Downloading model" toast.
|
||||
loading: <Spinner className="size-4 text-muted-foreground" />,
|
||||
loading: createLoadingToastIcon(),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { resolveInitialConfig } from "@/features/model-picker";
|
|||
import { projectHasSources } from "@/features/rag/api/rag-api";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { parseParamCountB } from "@/lib/model-size";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { createLoadingToastIcon, toast } from "@/lib/toast";
|
||||
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
|
||||
import type { ChatModelAdapter } from "@assistant-ui/react";
|
||||
import { parsePartialJsonObject } from "assistant-stream/utils";
|
||||
|
|
@ -1512,13 +1512,38 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
const trustRemoteCode = store.params.trustRemoteCode ?? false;
|
||||
const specSettings = resolveSpeculativeSettingsForLoad();
|
||||
const lastLoaded = readLastLocalModelLoad();
|
||||
const toastId = toast("Loading a model…", {
|
||||
let autoLoadToastDismissed = false;
|
||||
const toastId = toast.message("Loading a model…", {
|
||||
description: lastLoaded
|
||||
? "Loading last used model."
|
||||
: "Auto-selecting the smallest downloaded model.",
|
||||
duration: 5000,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
closeButton: true,
|
||||
icon: createLoadingToastIcon(),
|
||||
onDismiss: () => {
|
||||
autoLoadToastDismissed = true;
|
||||
},
|
||||
});
|
||||
const updateAutoLoadToast = (message: string, description: string): void => {
|
||||
if (autoLoadToastDismissed) return;
|
||||
toast.message(message, {
|
||||
id: toastId,
|
||||
description,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
};
|
||||
const showAutoLoadSuccess = (message: string): void => {
|
||||
const options = {
|
||||
description: undefined,
|
||||
duration: 5000,
|
||||
icon: undefined,
|
||||
};
|
||||
if (autoLoadToastDismissed) {
|
||||
toast.success(message, options);
|
||||
return;
|
||||
}
|
||||
toast.success(message, { ...options, id: toastId });
|
||||
};
|
||||
let blockedByTrustRemoteCode = false;
|
||||
let hadNonTrustFailure = false;
|
||||
let loadAttempts = 0;
|
||||
|
|
@ -1774,7 +1799,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
ggufVariant: candidate.ggufVariant,
|
||||
});
|
||||
}
|
||||
toast.success(candidate.successLabel, { id: toastId });
|
||||
showAutoLoadSuccess(candidate.successLabel);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
|
|
@ -1800,11 +1825,10 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
isAutoLoadableGgufVariant(entry),
|
||||
);
|
||||
if (variant) {
|
||||
toast("Loading last used model…", {
|
||||
id: toastId,
|
||||
description: `${repo.repo_id} (${variant.quant})`,
|
||||
duration: 5000,
|
||||
});
|
||||
updateAutoLoadToast(
|
||||
"Loading last used model…",
|
||||
`${repo.repo_id} (${variant.quant})`,
|
||||
);
|
||||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
|
|
@ -1829,11 +1853,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
const repo = findCachedRepo(modelRepos, lastLoaded.id);
|
||||
if (repo) {
|
||||
try {
|
||||
toast("Loading last used model…", {
|
||||
id: toastId,
|
||||
description: repo.repo_id,
|
||||
duration: 5000,
|
||||
});
|
||||
updateAutoLoadToast("Loading last used model…", repo.repo_id);
|
||||
if (
|
||||
await loadAutoLoadCandidate({
|
||||
id: repo.repo_id,
|
||||
|
|
@ -1854,11 +1874,10 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
}
|
||||
}
|
||||
}
|
||||
toast("Loading a model…", {
|
||||
id: toastId,
|
||||
description: "Auto-selecting the smallest downloaded model.",
|
||||
duration: 5000,
|
||||
});
|
||||
updateAutoLoadToast(
|
||||
"Loading a model…",
|
||||
"Auto-selecting the smallest downloaded model.",
|
||||
);
|
||||
}
|
||||
|
||||
// GGUF first: smallest-total-size repo, then its smallest variant.
|
||||
|
|
@ -1949,12 +1968,10 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
}
|
||||
|
||||
// No cached models — try downloading a small default GGUF.
|
||||
toast("Downloading a small model…", {
|
||||
id: toastId,
|
||||
description:
|
||||
"No downloaded models found. Fetching Qwen3.5-4B-MTP (UD-Q4_K_XL).",
|
||||
duration: 30000,
|
||||
});
|
||||
updateAutoLoadToast(
|
||||
"Downloading a small model…",
|
||||
"No downloaded models found. Fetching Qwen3.5-4B-MTP (UD-Q4_K_XL).",
|
||||
);
|
||||
try {
|
||||
const rt = useChatRuntimeStore.getState();
|
||||
if (
|
||||
|
|
@ -2050,7 +2067,7 @@ async function autoLoadSmallestModel(): Promise<{
|
|||
kind: "gguf",
|
||||
ggufVariant: "UD-Q4_K_XL",
|
||||
});
|
||||
toast.success("Loaded Qwen3.5-4B-MTP (UD-Q4_K_XL)", { id: toastId });
|
||||
showAutoLoadSuccess("Loaded Qwen3.5-4B-MTP (UD-Q4_K_XL)");
|
||||
return { loaded: true, blockedByTrustRemoteCode: false };
|
||||
} catch {
|
||||
toast.dismiss(toastId);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { getInferenceStatus, loadModel } from "@/features/chat";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { createLoadingToastIcon, toast } from "@/lib/toast";
|
||||
import { toastError } from "@/shared/toast";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
|
@ -238,8 +238,15 @@ async function loadLocalModelSelection(
|
|||
): Promise<string | null> {
|
||||
const { target, ggufVariant } = selection;
|
||||
const modelLabel = ggufVariant ? `${target} (${ggufVariant})` : target;
|
||||
const toastId = toast.loading(`Loading ${modelLabel}...`, {
|
||||
let loadToastDismissed = false;
|
||||
const toastId = toast.message(`Loading ${modelLabel}...`, {
|
||||
description: "Starting the local inference server for this recipe.",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
closeButton: true,
|
||||
icon: createLoadingToastIcon(),
|
||||
onDismiss: () => {
|
||||
loadToastDismissed = true;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const isGguf = GGUF_MODEL_PATTERN.test(target) || Boolean(ggufVariant);
|
||||
|
|
@ -267,7 +274,16 @@ async function loadLocalModelSelection(
|
|||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
tensor_parallel: false,
|
||||
});
|
||||
toast.success(`Loaded ${modelLabel}`, { id: toastId, duration: 2000 });
|
||||
const successOptions = {
|
||||
description: undefined,
|
||||
duration: 2000,
|
||||
icon: undefined,
|
||||
};
|
||||
if (loadToastDismissed) {
|
||||
toast.success(`Loaded ${modelLabel}`, successOptions);
|
||||
} else {
|
||||
toast.success(`Loaded ${modelLabel}`, { ...successOptions, id: toastId });
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
toast.dismiss(toastId);
|
||||
|
|
|
|||
|
|
@ -4,5 +4,15 @@
|
|||
// Re-export of sonner. Swipe blocking lives on the Toaster via
|
||||
// `swipeDirections={[]}`, so no per-toast dismissible override.
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { createElement } from "react";
|
||||
|
||||
function createLoadingToastIcon() {
|
||||
return createElement(Spinner, {
|
||||
className: "size-4 text-muted-foreground",
|
||||
});
|
||||
}
|
||||
|
||||
export { toast } from "sonner";
|
||||
export type { ExternalToast } from "sonner";
|
||||
export { createLoadingToastIcon };
|
||||
|
|
|
|||
|
|
@ -73,6 +73,57 @@ def test_autoload_records_backend_loaded_model_identity():
|
|||
assert "m.id === loadedModelId" in autoload
|
||||
|
||||
|
||||
def test_chat_autoload_toast_is_persistent_and_dismissible():
|
||||
"""Send-triggered autoload stays visible until it settles but remains
|
||||
dismissible, matching the explicit model-loading toast's lifetime."""
|
||||
src = _read("features/chat/api/chat-adapter.ts")
|
||||
auto_load = src.split("async function autoLoadSmallestModel", 1)[1]
|
||||
auto_load = auto_load.split("export function createOpenAIStreamAdapter", 1)[0]
|
||||
assert "toast.loading(" not in auto_load
|
||||
assert "const updateAutoLoadToast =" in auto_load
|
||||
assert "if (autoLoadToastDismissed) return;" in auto_load
|
||||
assert auto_load.count("toast.message(") == 2
|
||||
assert auto_load.count("updateAutoLoadToast(") >= 4
|
||||
assert "duration: Number.POSITIVE_INFINITY" in auto_load
|
||||
assert "closeButton: true" in auto_load
|
||||
assert "icon: createLoadingToastIcon()" in auto_load
|
||||
assert "onDismiss:" in auto_load
|
||||
# Terminal success uses a fresh finite toast after manual progress dismissal.
|
||||
assert "showAutoLoadSuccess" in auto_load
|
||||
assert "description: undefined" in auto_load
|
||||
assert "icon: undefined" in auto_load
|
||||
assert "duration: 5000" in auto_load
|
||||
assert "duration: 30000" not in auto_load
|
||||
assert auto_load.count("toast.dismiss(toastId)") >= 4
|
||||
|
||||
explicit_load = _read("features/chat/hooks/use-chat-model-runtime.ts")
|
||||
assert "duration: Infinity" in explicit_load
|
||||
|
||||
|
||||
def test_recipe_model_load_toast_is_persistent_and_dismissible():
|
||||
"""Recipe model loading uses the same dismissible persistent lifecycle as
|
||||
chat loading because both call the non-abortable loadModel API."""
|
||||
src = _read("features/recipe-studio/hooks/use-recipe-executions.ts")
|
||||
model_load = src.split("async function loadLocalModelSelection", 1)[1]
|
||||
model_load = model_load.split("function getLocalModelLoadPlanForPayload", 1)[0]
|
||||
assert "toast.loading(" not in model_load
|
||||
assert "toast.message(" in model_load
|
||||
assert "duration: Number.POSITIVE_INFINITY" in model_load
|
||||
assert "closeButton: true" in model_load
|
||||
assert "icon: createLoadingToastIcon()" in model_load
|
||||
assert "onDismiss:" in model_load
|
||||
assert "description: undefined" in model_load
|
||||
assert "icon: undefined" in model_load
|
||||
assert "duration: 2000" in model_load
|
||||
|
||||
toast_lib = _read("lib/toast.ts")
|
||||
assert "createElement(Spinner" in toast_lib
|
||||
assert 'className: "size-4 text-muted-foreground"' in toast_lib
|
||||
|
||||
sonner = _read("components/ui/sonner.tsx")
|
||||
assert "loading: createLoadingToastIcon()" in sonner
|
||||
|
||||
|
||||
def test_rollback_restores_native_lease_expiry_with_token():
|
||||
"""A failed model switch that rolls back to a previously loaded picked GGUF
|
||||
must restore the lease expiry paired with the token, never the token alone
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue