Merge branch 'main' into fix/gguf-audio

This commit is contained in:
Manan Shah 2026-03-16 04:47:43 -05:00 committed by GitHub
commit 936c69585c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 917 additions and 233 deletions

View file

@ -628,7 +628,7 @@ class InferenceBackend:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
) -> Generator[str, None, None]:
"""
@ -658,7 +658,7 @@ class InferenceBackend:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
_adapter_state = None,
) -> Generator[str, None, None]:
@ -1077,7 +1077,7 @@ class InferenceBackend:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
_adapter_state = None,
) -> Generator[str, None, None]:
@ -1215,7 +1215,7 @@ class InferenceBackend:
top_k: int = 50,
min_p: float = 0.0,
max_new_tokens: int = 2048,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
use_adapter: Optional[Union[bool, str]] = None,
) -> Tuple[bytes, int]:
"""

View file

@ -535,6 +535,47 @@ class LlamaCppBackend:
logger.info(f"GGUF downloaded to: {local_path}")
return local_path
def _download_mmproj(
self,
*,
hf_repo: str,
hf_token: Optional[str] = None,
) -> Optional[str]:
"""Download the mmproj (vision projection) file from a GGUF repo.
Prefers mmproj-F16.gguf, falls back to any mmproj*.gguf file.
Returns the local path, or None if no mmproj file exists.
"""
try:
from huggingface_hub import hf_hub_download, list_repo_files
files = list_repo_files(hf_repo, token = hf_token)
mmproj_files = sorted(
f for f in files if f.endswith(".gguf") and "mmproj" in f.lower()
)
if not mmproj_files:
return None
# Prefer F16 variant
target = None
for f in mmproj_files:
if "f16" in f.lower():
target = f
break
if target is None:
target = mmproj_files[0]
logger.info(f"Downloading mmproj: {hf_repo}/{target}")
local_path = hf_hub_download(
repo_id = hf_repo,
filename = target,
token = hf_token,
)
return local_path
except Exception as e:
logger.warning(f"Could not download mmproj: {e}")
return None
# ── Lifecycle ─────────────────────────────────────────────────
def load_model(
@ -588,6 +629,12 @@ class LlamaCppBackend:
hf_variant = hf_variant,
hf_token = hf_token,
)
# Auto-download mmproj for vision models
if is_vision and not mmproj_path:
mmproj_path = self._download_mmproj(
hf_repo = hf_repo,
hf_token = hf_token,
)
elif gguf_path:
if not Path(gguf_path).is_file():
raise FileNotFoundError(f"GGUF file not found: {gguf_path}")
@ -629,7 +676,7 @@ class LlamaCppBackend:
"--port",
str(self._port),
"-c",
str(n_ctx),
"0", # 0 = use model's native context size
"--parallel",
"1", # Single-user studio, saves VRAM
"--flash-attn",
@ -892,7 +939,7 @@ class LlamaCppBackend:
top_k: int = 40,
min_p: float = 0.0,
max_tokens: Optional[int] = None,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
stop: Optional[list[str]] = None,
cancel_event: Optional[threading.Event] = None,
) -> Generator[str, None, None]:

View file

@ -78,7 +78,8 @@ class InferenceOrchestrator:
self._static_models = get_default_models()
self._top_gguf_cache: Optional[list[str]] = None
self._top_gguf_fetched = False
self._top_hub_cache: Optional[list[str]] = None
self._top_models_ready = threading.Event()
# Version tracking for subprocess reuse
self._current_transformers_major: Optional[str] = None # "4" or "5"
@ -86,9 +87,9 @@ class InferenceOrchestrator:
atexit.register(self._cleanup)
logger.info("InferenceOrchestrator initialized (subprocess mode)")
# Kick off background fetch of top GGUF models
# Kick off background fetch of top models from HF
threading.Thread(
target = self._fetch_top_gguf, daemon = True, name = "top-gguf"
target = self._fetch_top_models, daemon = True, name = "top-models"
).start()
# ------------------------------------------------------------------
@ -97,12 +98,23 @@ class InferenceOrchestrator:
@property
def default_models(self) -> list[str]:
top = self._top_gguf_cache or []
seen = set(top)
return top + [m for m in self._static_models if m not in seen]
# Wait up to 5s for background HF fetch to finish
self._top_models_ready.wait(timeout = 5)
top_gguf = self._top_gguf_cache or []
top_hub = self._top_hub_cache or []
# GGUFs first, then hub models, then static fallbacks.
# Send extras so the frontend still has 4 per category
# after removing already-downloaded models.
result: list[str] = []
seen: set[str] = set()
for m in top_gguf + top_hub + self._static_models:
if m not in seen:
result.append(m)
seen.add(m)
return result
def _fetch_top_gguf(self) -> None:
"""Fetch top 4 GGUF repos from unsloth by downloads (background)."""
def _fetch_top_models(self) -> None:
"""Fetch top GGUF and non-GGUF repos from unsloth by downloads."""
try:
import httpx
@ -112,22 +124,33 @@ class InferenceOrchestrator:
"author": "unsloth",
"sort": "downloads",
"direction": "-1",
"limit": "40",
"limit": "80",
},
timeout = 15,
)
if resp.status_code == 200:
models = resp.json()
# Top 8 GGUFs (frontend deduplicates against downloaded,
# so we fetch extra to always fill 4 slots)
gguf_ids = [
m["id"] for m in models if m.get("id", "").upper().endswith("-GGUF")
][:4]
][:8]
# Top 8 non-GGUF hub models
hub_ids = [
m["id"]
for m in models
if not m.get("id", "").upper().endswith("-GGUF")
][:8]
if gguf_ids:
self._top_gguf_cache = gguf_ids
logger.info("Top GGUF models: %s", gguf_ids)
if hub_ids:
self._top_hub_cache = hub_ids
logger.info("Top hub models: %s", hub_ids)
except Exception as e:
logger.warning("Failed to fetch top GGUF models: %s", e)
logger.warning("Failed to fetch top models: %s", e)
finally:
self._top_gguf_fetched = True
self._top_models_ready.set()
# ------------------------------------------------------------------
# Subprocess lifecycle
@ -395,7 +418,7 @@ class InferenceOrchestrator:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
use_adapter = None,
) -> Generator[str, None, None]:
@ -666,7 +689,7 @@ class InferenceOrchestrator:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
) -> Generator[str, None, None]:
"""Generate response, streaming tokens from subprocess."""
@ -712,7 +735,7 @@ class InferenceOrchestrator:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
use_adapter = None,
) -> Generator[str, None, None]:
@ -763,7 +786,7 @@ class InferenceOrchestrator:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
use_adapter = None,
) -> Generator[str, None, None]:
@ -862,7 +885,7 @@ class InferenceOrchestrator:
top_k: int = 50,
min_p: float = 0.0,
max_new_tokens: int = 2048,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
use_adapter: Optional[Union[bool, str]] = None,
) -> Tuple[bytes, int]:
"""Generate TTS audio. Returns (wav_bytes, sample_rate).
@ -949,7 +972,7 @@ class InferenceOrchestrator:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 512,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
) -> Generator[str, None, None]:
"""Audio input generation (e.g. Gemma 3n) — streams text tokens."""
@ -978,7 +1001,7 @@ class InferenceOrchestrator:
top_k: int = 40,
min_p: float = 0.0,
max_new_tokens: int = 512,
repetition_penalty: float = 1.1,
repetition_penalty: float = 1.0,
cancel_event = None,
) -> Generator[str, None, None]:
"""Shared inner logic for audio input generation (Whisper + ASR)."""

View file

@ -276,7 +276,7 @@ def _handle_generate(
"top_k": cmd.get("top_k", 40),
"min_p": cmd.get("min_p", 0.0),
"max_new_tokens": cmd.get("max_new_tokens", 256),
"repetition_penalty": cmd.get("repetition_penalty", 1.1),
"repetition_penalty": cmd.get("repetition_penalty", 1.0),
"cancel_event": cancel_event,
}
@ -348,7 +348,7 @@ def _handle_generate_audio(
top_k = cmd.get("top_k", 50),
min_p = cmd.get("min_p", 0.0),
max_new_tokens = cmd.get("max_new_tokens", 2048),
repetition_penalty = cmd.get("repetition_penalty", 1.1),
repetition_penalty = cmd.get("repetition_penalty", 1.0),
use_adapter = cmd.get("use_adapter"),
)
@ -411,7 +411,7 @@ def _handle_generate_audio_input(
top_k = cmd.get("top_k", 40),
min_p = cmd.get("min_p", 0.0),
max_new_tokens = cmd.get("max_new_tokens", 512),
repetition_penalty = cmd.get("repetition_penalty", 1.1),
repetition_penalty = cmd.get("repetition_penalty", 1.0),
cancel_event = cancel_event,
)

View file

@ -91,7 +91,7 @@ class GenerateRequest(BaseModel):
2048, ge = 1, le = 4096, description = "Maximum tokens to generate"
)
repetition_penalty: float = Field(
1.1, ge = 1.0, le = 2.0, description = "Repetition penalty"
1.0, ge = 1.0, le = 2.0, description = "Repetition penalty"
)
image_base64: Optional[str] = Field(
None, description = "Base64 encoded image for vision models"

View file

@ -252,8 +252,10 @@ async def load_model(
except Exception as e:
logger.warning(f"Could not read adapter_config.json: {e}")
# Load the model
success = backend.load_model(
# Load the model in a thread so the event loop stays free
# for download progress polling and other requests.
success = await asyncio.to_thread(
backend.load_model,
config = config,
max_seq_length = request.max_seq_length,
load_in_4bit = load_in_4bit,
@ -302,7 +304,17 @@ async def load_model(
raise
except Exception as e:
logger.error(f"Error loading model: {e}", exc_info = True)
raise HTTPException(status_code = 500, detail = f"Failed to load model: {str(e)}")
msg = str(e)
# Surface a friendlier message for models that Unsloth cannot load
not_supported_hints = [
"No config file found",
"not yet supported",
"is not supported",
"does not support",
]
if any(h.lower() in msg.lower() for h in not_supported_hints):
msg = f"This model is not supported yet. Try a different model. (Original error: {msg})"
raise HTTPException(status_code = 500, detail = f"Failed to load model: {msg}")
@router.post("/validate", response_model = ValidateModelResponse)
@ -874,6 +886,25 @@ async def openai_chat_completions(
detail = "Image provided but current GGUF model does not support vision.",
)
# Convert image to PNG for llama-server (stb_image has limited format support)
if image_b64:
try:
import base64 as _b64
from io import BytesIO as _BytesIO
from PIL import Image as _Image
raw = _b64.b64decode(image_b64)
img = _Image.open(_BytesIO(raw))
if img.mode == "RGBA":
img = img.convert("RGB")
buf = _BytesIO()
img.save(buf, format = "PNG")
image_b64 = _b64.b64encode(buf.getvalue()).decode("ascii")
except Exception as e:
raise HTTPException(
status_code = 400, detail = f"Failed to process image: {e}"
)
# Build message list with system prompt prepended
gguf_messages = []
if system_prompt:

View file

@ -702,6 +702,95 @@ async def get_gguf_download_progress(
return {"downloaded_bytes": 0, "expected_bytes": expected_bytes, "progress": 0}
@router.get("/download-progress")
async def get_download_progress(
repo_id: str = Query(..., description = "HuggingFace repo ID"),
current_subject: str = Depends(get_current_subject),
):
"""Return download progress for any HuggingFace model repo.
Checks the local HF cache for completed blobs and in-progress
(.incomplete) downloads. Uses the HF API to determine the expected
total size on the first call, then caches it for subsequent polls.
"""
_empty = {"downloaded_bytes": 0, "expected_bytes": 0, "progress": 0}
try:
if not _is_valid_repo_id(repo_id):
return _empty
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
target = f"models--{repo_id.replace('/', '--')}".lower()
completed_bytes = 0
in_progress_bytes = 0
for entry in cache_dir.iterdir():
if entry.name.lower() != target:
continue
blobs_dir = entry / "blobs"
if not blobs_dir.is_dir():
break
for f in blobs_dir.iterdir():
if not f.is_file():
continue
if f.name.endswith(".incomplete"):
in_progress_bytes += f.stat().st_size
else:
completed_bytes += f.stat().st_size
break
downloaded_bytes = completed_bytes + in_progress_bytes
if downloaded_bytes == 0:
return _empty
# Get expected size from HF API (cached per repo_id)
expected_bytes = _get_repo_size_cached(repo_id)
if expected_bytes <= 0:
# Cannot determine total; report bytes only, no percentage
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": 0,
"progress": 0,
}
# Use 95% threshold for completion (blob deduplication can make
# completed_bytes differ slightly from expected_bytes).
# Do NOT use "no .incomplete files" as a completion signal --
# HF downloads files sequentially, so between files there are
# no .incomplete files even though the download is far from done.
if completed_bytes >= expected_bytes * 0.95:
progress = 1.0
else:
progress = min(downloaded_bytes / expected_bytes, 0.99)
return {
"downloaded_bytes": downloaded_bytes,
"expected_bytes": expected_bytes,
"progress": round(progress, 3),
}
except Exception as e:
logger.warning(f"Error checking download progress for {repo_id}: {e}")
return _empty
_repo_size_cache: dict[str, int] = {}
def _get_repo_size_cached(repo_id: str) -> int:
if repo_id in _repo_size_cache:
return _repo_size_cache[repo_id]
try:
from huggingface_hub import model_info as hf_model_info
info = hf_model_info(repo_id, token = None, files_metadata = True)
total = sum(s.size for s in info.siblings if s.size)
_repo_size_cache[repo_id] = total
return total
except Exception as e:
logger.warning(f"Failed to get repo size for {repo_id}: {e}")
return 0
@router.get("/cached-gguf")
async def list_cached_gguf(
current_subject: str = Depends(get_current_subject),
@ -749,6 +838,41 @@ async def list_cached_gguf(
return {"cached": []}
@router.get("/cached-models")
async def list_cached_models(
current_subject: str = Depends(get_current_subject),
):
"""List non-GGUF model repos that have been downloaded to the HF cache."""
try:
from huggingface_hub import scan_cache_dir
hf_cache = scan_cache_dir()
seen_lower: dict[str, dict] = {}
for repo_info in hf_cache.repos:
if repo_info.repo_type != "model":
continue
repo_id = repo_info.repo_id
if repo_id.upper().endswith("-GGUF"):
continue
total_size = sum(
f.size_on_disk for rev in repo_info.revisions for f in rev.files
)
if total_size == 0:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
if existing is None or total_size > existing["size_bytes"]:
seen_lower[key] = {
"repo_id": repo_id,
"size_bytes": total_size,
}
cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"])
return {"cached": cached}
except Exception as e:
logger.error(f"Error listing cached models: {e}", exc_info = True)
return {"cached": []}
@router.get("/checkpoints", response_model = CheckpointListResponse)
async def list_checkpoints(
outputs_dir: str = Query(

View file

@ -473,10 +473,10 @@ try:
model_type = getattr(config, "model_type", "unknown")
archs = getattr(config, "architectures", [])
logger.info(json.dumps({"is_vision": is_vlm, "model_type": model_type,
print(json.dumps({"is_vision": is_vlm, "model_type": model_type,
"architectures": archs}))
except Exception as exc:
logger.info(json.dumps({"error": str(exc)}))
print(json.dumps({"error": str(exc)}))
sys.exit(1)
"""

View file

@ -8,8 +8,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { listCachedGguf, listGgufVariants } from "@/features/chat/api/chat-api";
import type { CachedGgufRepo } from "@/features/chat/api/chat-api";
import { listCachedGguf, listCachedModels, listGgufVariants } from "@/features/chat/api/chat-api";
import type { CachedGgufRepo, CachedModelRepo } from "@/features/chat/api/chat-api";
import type { GgufVariantDetail } from "@/features/chat/types/api";
import { usePlatformStore } from "@/config/env";
import {
@ -340,6 +340,10 @@ function isGgufRepo(id: string): boolean {
return id.toUpperCase().includes("-GGUF");
}
// Module-level caches so re-mounting the popover shows results instantly
let _cachedGgufCache: CachedGgufRepo[] = [];
let _cachedModelsCache: CachedModelRepo[] = [];
// ── Hub Model Picker ──────────────────────────────────────────
export function HubModelPicker({
@ -361,16 +365,41 @@ export function HubModelPicker({
// Track which GGUF repo is expanded for variant selection
const [expandedGguf, setExpandedGguf] = useState<string | null>(null);
// Cached (already downloaded) GGUF repos
const [cachedGguf, setCachedGguf] = useState<CachedGgufRepo[]>([]);
// Cached (already downloaded) repos -- use module-level cache so
// re-mounting the popover does not flash an empty "Downloaded" section.
const [cachedGguf, setCachedGguf] = useState<CachedGgufRepo[]>(_cachedGgufCache);
const [cachedModels, setCachedModels] = useState<CachedModelRepo[]>(_cachedModelsCache);
const alreadyCached = _cachedGgufCache.length > 0 || _cachedModelsCache.length > 0;
const [cachedReady, setCachedReady] = useState(alreadyCached);
useEffect(() => {
listCachedGguf().then(setCachedGguf).catch(() => {});
}, []);
if (alreadyCached) return;
let done = 0;
const check = () => { if (++done >= 2) setCachedReady(true); };
listCachedGguf().then((v) => { _cachedGgufCache = v; setCachedGguf(v); }).catch(() => {}).finally(check);
listCachedModels().then((v) => { _cachedModelsCache = v; setCachedModels(v); }).catch(() => {}).finally(check);
}, [alreadyCached]);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
);
// Deduplicate: don't show downloaded models in the recommended list.
// Compare case-insensitively since HF cache lowercases repo IDs.
const downloadedSet = useMemo(() => {
const s = new Set<string>();
for (const c of cachedGguf) s.add(c.repo_id.toLowerCase());
for (const c of cachedModels) s.add(c.repo_id.toLowerCase());
return s;
}, [cachedGguf, cachedModels]);
const recommendedIds = useMemo(() => {
const all = dedupe([...models.map((model) => model.id), value ?? ""])
.filter((id) => !downloadedSet.has(id.toLowerCase()));
// Cap at 4 GGUFs + 4 non-GGUFs so the list stays manageable
const gguf: string[] = [];
const hub: string[] = [];
for (const id of all) {
if (isGgufRepo(id) && gguf.length < 4) gguf.push(id);
else if (!isGgufRepo(id) && hub.length < 4) hub.push(id);
}
return [...gguf, ...hub];
}, [models, value, downloadedSet]);
const { paramCountById: recommendedParamCountById } =
useRecommendedModelVram(recommendedIds);
@ -392,8 +421,13 @@ export function HubModelPicker({
() =>
new Map(
results
.filter((result) => result.totalParams)
.map((result) => [result.id, formatCompact(result.totalParams!)]),
.filter((result) => result.totalParams || result.estimatedSizeBytes)
.map((result) => [
result.id,
result.estimatedSizeBytes
? `~${formatBytes(result.estimatedSizeBytes)}`
: formatCompact(result.totalParams!),
]),
),
[results],
);
@ -472,9 +506,14 @@ export function HubModelPicker({
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
<div className="p-1">
{!showHfSection && cachedGguf.length > 0 ? (
{!cachedReady && !showHfSection ? (
<div className="flex items-center gap-2 px-5 py-3">
<Spinner className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Loading models</span>
</div>
) : !showHfSection && (cachedGguf.length > 0 || cachedModels.length > 0) ? (
<>
<ListLabel>Downloaded</ListLabel>
<ListLabel>{"\uD83E\uDDA5"} Downloaded</ListLabel>
{cachedGguf.map((c) => (
<div key={c.repo_id}>
<ModelRow
@ -489,12 +528,22 @@ export function HubModelPicker({
)}
</div>
))}
{cachedModels.map((c) => (
<ModelRow
key={c.repo_id}
label={c.repo_id}
meta={formatBytes(c.size_bytes)}
selected={value === c.repo_id}
onClick={() => onSelect(c.repo_id, { source: "hub", isLora: false, isDownloaded: true })}
vramStatus={null}
/>
))}
</>
) : null}
{!showHfSection ? (
{!showHfSection && cachedReady ? (
<>
<ListLabel>Recommended</ListLabel>
<ListLabel>{"\uD83E\uDDA5"} Recommended</ListLabel>
{recommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.

View file

@ -139,7 +139,7 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
className="size-20"
/>
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-semibold text-2xl duration-200">
Run LLMs or test your fine-tune
Chat with your model
</h1>
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in text-muted-foreground text-base delay-75 duration-200">
Run GGUFs, safetensors, vision and audio models!

View file

@ -19,7 +19,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
duration={10000}
duration={5000}
icons={{
success: (
<HugeiconsIcon
@ -68,6 +68,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast: "cn-toast",
closeButton: "!top-1.5 !translate-y-0",
},
}}
{...props}

View file

@ -84,7 +84,15 @@ export async function authFetch(
headers.set("Authorization", `Bearer ${accessToken}`);
}
const response = await fetch(input, { ...init, headers });
let response: Response;
try {
response = await fetch(input, { ...init, headers });
} catch (err) {
if (err instanceof TypeError) {
throw new Error("Studio isn't running -- please relaunch it.");
}
throw err;
}
if (await isPasswordChangeRequiredResponse(response)) {
void redirectToAuth();
return response;

View file

@ -4,7 +4,14 @@
import type { ChatModelAdapter } from "@assistant-ui/react";
import type { MessageTiming } from "@assistant-ui/core";
import { toast } from "sonner";
import { generateAudio, streamChatCompletions } from "./chat-api";
import {
generateAudio,
listCachedGguf,
listCachedModels,
listGgufVariants,
loadModel,
streamChatCompletions,
} from "./chat-api";
import { db } from "../db";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import {
@ -174,17 +181,117 @@ async function resolveUseAdapter(
}
}
/** Wait for an in-progress model load to finish (polls store every 500ms). */
function waitForModelReady(abortSignal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const check = () => {
if (abortSignal?.aborted) { reject(new Error("Aborted")); return; }
if (!useChatRuntimeStore.getState().modelLoading) { resolve(); return; }
setTimeout(check, 500);
};
check();
});
}
/**
* Auto-load the smallest downloaded model when the user tries to chat
* without selecting one. Prefers GGUF (picks smallest cached variant),
* falls back to smallest cached safetensors model.
*/
async function autoLoadSmallestModel(): Promise<boolean> {
const toastId = toast("Loading a model…", {
description: "Auto-selecting the smallest downloaded model.",
duration: Infinity,
});
try {
const [ggufRepos, modelRepos] = await Promise.all([
listCachedGguf().catch(() => []),
listCachedModels().catch(() => []),
]);
// Try GGUF first: pick the repo with the smallest total size,
// then pick its smallest downloaded variant.
if (ggufRepos.length > 0) {
const sorted = [...ggufRepos].sort((a, b) => a.size_bytes - b.size_bytes);
for (const repo of sorted) {
try {
const variants = await listGgufVariants(repo.repo_id);
const downloaded = variants.variants
.filter((v) => v.downloaded)
.sort((a, b) => a.size_bytes - b.size_bytes);
if (downloaded.length > 0) {
const variant = downloaded[0];
await loadModel({
model_path: repo.repo_id,
hf_token: null,
max_seq_length: 4096,
load_in_4bit: true,
is_lora: false,
gguf_variant: variant.quant,
trust_remote_code: false,
});
useChatRuntimeStore.getState().setCheckpoint(repo.repo_id, variant.quant);
toast.success(`Loaded ${repo.repo_id} (${variant.quant})`, { id: toastId });
return true;
}
} catch {
continue;
}
}
}
// Fall back to safetensors models
if (modelRepos.length > 0) {
const sorted = [...modelRepos].sort((a, b) => a.size_bytes - b.size_bytes);
for (const repo of sorted) {
try {
await loadModel({
model_path: repo.repo_id,
hf_token: null,
max_seq_length: 4096,
load_in_4bit: true,
is_lora: false,
gguf_variant: null,
trust_remote_code: false,
});
useChatRuntimeStore.getState().setCheckpoint(repo.repo_id);
toast.success(`Loaded ${repo.repo_id}`, { id: toastId });
return true;
} catch {
continue;
}
}
}
toast.dismiss(toastId);
return false;
} catch {
toast.dismiss(toastId);
return false;
}
}
export function createOpenAIStreamAdapter(): ChatModelAdapter {
return {
async *run({ messages, abortSignal, unstable_threadId }) {
const runtime = useChatRuntimeStore.getState();
const { params } = runtime;
if (!params.checkpoint) {
toast.error("No model loaded", {
description: "Pick model in top bar, then retry.",
});
throw new Error("Load a model first.");
// Wait for in-progress model load to finish before inferring
if (runtime.modelLoading) {
toast.info("Waiting for model to finish loading…");
await waitForModelReady(abortSignal);
}
if (!useChatRuntimeStore.getState().params.checkpoint) {
// Auto-load the smallest downloaded model
const loaded = await autoLoadSmallestModel();
if (!loaded) {
toast.error("No model loaded", {
description: "Pick a model in the top bar, then retry.",
});
throw new Error("Load a model first.");
}
}
const outboundMessages = messages

View file

@ -117,12 +117,31 @@ export async function getGgufDownloadProgress(
return parseJsonOrThrow(response);
}
export async function getDownloadProgress(
repoId: string,
): Promise<{ downloaded_bytes: number; expected_bytes: number; progress: number }> {
const params = new URLSearchParams({ repo_id: repoId });
const response = await authFetch(`/api/models/download-progress?${params}`);
return parseJsonOrThrow(response);
}
export async function listCachedGguf(): Promise<CachedGgufRepo[]> {
const response = await authFetch("/api/models/cached-gguf");
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);
return data.cached;
}
export interface CachedModelRepo {
repo_id: string;
size_bytes: number;
}
export async function listCachedModels(): Promise<CachedModelRepo[]> {
const response = await authFetch("/api/models/cached-models");
const data = await parseJsonOrThrow<{ cached: CachedModelRepo[] }>(response);
return data.cached;
}
export async function listGgufVariants(
repoId: string,
hfToken?: string,

View file

@ -8,7 +8,6 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Sheet,
@ -39,6 +38,7 @@ import {
import { toast } from "sonner";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ModelLoadInlineStatus } from "./components/model-load-status";
import { db } from "./db";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
import {
@ -111,23 +111,6 @@ function messageHasImage(message: MessageRecord): boolean {
return false;
}
async function resolveActiveSingleThreadId(view: ChatView): Promise<string | undefined> {
if (view.mode !== "single") {
return undefined;
}
if (view.threadId) {
return view.threadId;
}
// New-thread flow keeps threadId undefined in local view state.
// Fall back to most recent regular base thread.
const candidates = await db.threads.where("modelType").equals("base").toArray();
const latest = candidates
.filter((thread) => !thread.archived && !thread.pairId)
.sort((a, b) => b.createdAt - a.createdAt)[0];
return latest?.id;
}
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
@ -321,7 +304,16 @@ export function ChatPage(): ReactElement {
const modelsFromStore = useChatRuntimeStore((state) => state.models);
const lorasFromStore = useChatRuntimeStore((state) => state.loras);
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const { refresh, selectModel, ejectModel, cancelLoading, loadingModel } =
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const {
refresh,
selectModel,
ejectModel,
cancelLoading,
loadingModel,
loadProgress,
loadToastDismissed,
} =
useChatModelRuntime();
const refreshRef = useRef(refresh);
const selectModelRef = useRef(selectModel);
@ -343,30 +335,27 @@ export function ChatPage(): ReactElement {
const currentVariant = store.activeGgufVariant;
if (!value || (value === currentCheckpoint && (meta?.ggufVariant ?? null) === (currentVariant ?? null))) return;
void (async () => {
let switchNote: string | undefined;
const activeThreadId = await resolveActiveSingleThreadId(view);
if (activeThreadId) {
let showImageCompatibilityWarning = false;
if (view.mode === "single" && activeThreadId) {
const thread = await db.threads.get(activeThreadId);
if (thread?.modelId && thread.modelId !== value) {
const messages = await db.messages
.where("threadId")
.equals(activeThreadId)
.toArray();
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find((model) => model.id === value);
const nonVisionWithImages = hasImage && targetModel?.isVision === false;
switchNote = nonVisionWithImages
? "Full chat history will be sent to the new model. This chat has images; text-only models may fail."
: hasImage
? "Full chat history will be sent to the new model. This chat includes images."
: "Full chat history will be sent to the new model.";
if (messages.length > 0) {
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find((model) => model.id === value);
showImageCompatibilityWarning =
hasImage && targetModel?.isVision === false;
}
}
}
if (switchNote) {
toast.warning("Model changed for this chat", {
description: switchNote,
if (showImageCompatibilityWarning) {
toast.warning("Selected model may not handle earlier images", {
description:
"This chat already includes images. Text-only models can ignore them or fail on follow-up replies.",
duration: 6000,
});
}
@ -379,13 +368,16 @@ export function ChatPage(): ReactElement {
});
})();
},
[modelsFromStore, selectModel, view],
[activeThreadId, modelsFromStore, selectModel, view],
);
const handleEject = useCallback(() => {
void ejectModel();
}, [ejectModel]);
const handleNewThread = useCallback(
() => setView({ mode: "single", newThreadNonce: crypto.randomUUID() }),
() => {
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
},
[],
);
const handleNewCompare = useCallback(
@ -606,25 +598,22 @@ export function ChatPage(): ReactElement {
contentDataTour="chat-model-selector-popover"
className="max-w-[62vw] sm:max-w-none"
/>
{loadingModel ? (
<div
className="flex items-center gap-1.5 text-muted-foreground"
{loadingModel && loadToastDismissed ? (
<ModelLoadInlineStatus
label={
loadProgress?.phase === "starting"
? "Starting model…"
: loadingModel.isDownloaded
? "Loading model…"
: "Downloading model…"
}
title={loadingModel.isDownloaded
? `Loading ${loadingModel.displayName} from cache.`
: `Loading ${loadingModel.displayName}. This may include downloading.`}
>
<Spinner className="size-3.5 shrink-0" />
<span className="text-xs">
{loadingModel.isDownloaded ? "Loading model…" : "Downloading model…"}
</span>
<button
type="button"
onClick={cancelLoading}
className="ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
>
Cancel
</button>
</div>
progressPercent={loadProgress?.percent}
progressLabel={loadProgress?.label}
onStop={cancelLoading}
/>
) : null}
</div>
{modelsError && (

View file

@ -26,6 +26,7 @@ import {
DEFAULT_INFERENCE_PARAMS,
type InferenceParams,
} from "./types/runtime";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { Switch } from "@/components/ui/switch";
export const defaultInferenceParams = DEFAULT_INFERENCE_PARAMS;
@ -45,7 +46,7 @@ const BUILTIN_PRESETS: Preset[] = [
temperature: 1.2,
topP: 0.95,
topK: 80,
repetitionPenalty: 1.05,
repetitionPenalty: 1.0,
},
},
{
@ -55,7 +56,7 @@ const BUILTIN_PRESETS: Preset[] = [
temperature: 0.2,
topP: 0.7,
topK: 20,
repetitionPenalty: 1.2,
repetitionPenalty: 1.0,
},
},
];
@ -67,6 +68,7 @@ function ParamSlider({
max,
step,
onChange,
displayValue,
}: {
label: string;
value: number;
@ -74,13 +76,14 @@ function ParamSlider({
max: number;
step: number;
onChange: (v: number) => void;
displayValue?: string;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{label}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{value}
{displayValue ?? value}
</span>
</div>
<Slider
@ -158,6 +161,7 @@ export function ChatSettingsPanel({
autoTitle,
onAutoTitleChange,
}: ChatSettingsPanelProps) {
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const [presets, setPresets] = useState<Preset[]>(BUILTIN_PRESETS);
const [activePreset, setActivePreset] = useState("Default");
const isBuiltinPreset = BUILTIN_PRESETS.some((p) => p.name === activePreset);
@ -279,7 +283,7 @@ export function ChatSettingsPanel({
<CollapsibleSection
icon={SlidersHorizontalIcon}
label="Sampling"
defaultOpen={false}
defaultOpen={true}
>
<div className="flex flex-col gap-5">
<ParamSlider
@ -322,21 +326,24 @@ export function ChatSettingsPanel({
step={0.05}
onChange={set("repetitionPenalty")}
/>
<ParamSlider
label="Max Seq Length"
value={params.maxSeqLength}
min={128}
max={32768}
step={128}
onChange={set("maxSeqLength")}
/>
{!isGguf && (
<ParamSlider
label="Max Seq Length"
value={params.maxSeqLength}
min={128}
max={32768}
step={128}
onChange={set("maxSeqLength")}
/>
)}
<ParamSlider
label="Max Tokens"
value={params.maxTokens}
min={64}
max={4096}
max={isGguf ? 131072 : 32768}
step={64}
onChange={set("maxTokens")}
displayValue={isGguf && params.maxTokens >= 131072 ? "Max" : undefined}
/>
</div>
</CollapsibleSection>

View file

@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { Button } from "@/components/ui/button";
type ModelLoadDescriptionProps = {
message?: string | null;
progressPercent?: number | null;
progressLabel?: string | null;
onStop?: () => void;
};
function clampProgress(value: number): number {
return Math.max(0, Math.min(100, value));
}
export function ModelLoadDescription({
message,
progressPercent,
progressLabel,
onStop,
}: ModelLoadDescriptionProps) {
const hasProgress = typeof progressPercent === "number";
return (
<div className="flex items-center gap-1.5">
<div className="min-w-0 flex-1">
{hasProgress ? (
<div className="w-[12.5rem] max-w-full">
<div className="flex items-center justify-between text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80">
<span>{progressLabel}</span>
<span>{Math.round(clampProgress(progressPercent))}%</span>
</div>
<Progress value={clampProgress(progressPercent)} className="h-1 bg-foreground/[0.08]" />
</div>
) : message ? (
<p className="text-xs leading-relaxed text-muted-foreground">{message}</p>
) : null}
</div>
{onStop ? (
<Button
type="button"
size="xs"
variant="outline"
className="h-5 shrink-0 px-2 text-[10px]"
onClick={onStop}
>
Stop
</Button>
) : null}
</div>
);
}
type ModelLoadInlineStatusProps = {
label: string;
title: string;
progressPercent?: number | null;
progressLabel?: string | null;
onStop?: () => void;
};
export function ModelLoadInlineStatus({
label,
title,
progressPercent,
progressLabel,
onStop,
}: ModelLoadInlineStatusProps) {
const hasProgress = typeof progressPercent === "number";
return (
<div className="flex min-w-[20rem] items-center gap-2.5 text-muted-foreground" title={title}>
<div className="flex items-center gap-1.5 shrink-0">
<Spinner className="size-3.5 shrink-0" />
<span className="text-xs">{label}</span>
</div>
{hasProgress ? (
<div className="flex min-w-0 flex-[1.35] items-center gap-2.5">
<div className="min-w-[7rem] flex-1">
<Progress value={clampProgress(progressPercent)} className="h-1 bg-foreground/[0.08]" />
</div>
<div className="flex shrink-0 items-center gap-1 text-[10px] font-medium tracking-[0.08em] text-muted-foreground/80">
<span>{progressLabel}</span>
<span>{Math.round(clampProgress(progressPercent))}%</span>
</div>
</div>
) : null}
{onStop ? (
<Button
type="button"
size="xs"
variant="outline"
className="shrink-0 text-[11px]"
onClick={onStop}
>
Stop
</Button>
) : null}
</div>
);
}

View file

@ -1,9 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useRef, useState } from "react";
import { createElement, useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { Spinner } from "@/components/ui/spinner";
import { ModelLoadDescription } from "../components/model-load-status";
import {
getDownloadProgress,
getGgufDownloadProgress,
getInferenceStatus,
listLoras,
@ -29,6 +32,15 @@ type SelectedModelInput = {
expectedBytes?: number;
};
const MODEL_LOAD_TOAST_CLASSNAMES = {
toast: "items-start gap-2.5 pr-8",
content: "gap-0.5",
title: "leading-5",
description: "mt-0",
closeButton:
"!left-auto !right-1.5 !top-1.5 !translate-x-0 !translate-y-0 !border-transparent !bg-transparent !shadow-none hover:!bg-transparent hover:opacity-70",
} as const;
const LORA_SUFFIX_RE = /_(\d{9,})$/;
function parseTrailingEpoch(input: string): number | undefined {
@ -120,9 +132,13 @@ function mergeRecommendedInference(
modelId: string,
): InferenceParams {
const inference = response.inference;
// GGUF: max tokens = 131072 (effectively unlimited, model decides)
// Non-GGUF: max tokens = 4096
const defaultMaxTokens = response.is_gguf ? 131072 : 4096;
return {
...current,
checkpoint: modelId,
maxTokens: defaultMaxTokens,
temperature:
toFiniteNumber(inference?.temperature) ?? current.temperature,
topP: toFiniteNumber(inference?.top_p) ?? current.topP,
@ -151,11 +167,47 @@ export function useChatModelRuntime() {
displayName: string;
isDownloaded?: boolean;
} | null>(null);
const [_loadAbortController, setLoadAbortController] =
useState<AbortController | null>(null);
const [loadToastDismissed, setLoadToastDismissed] = useState(false);
const [loadProgress, setLoadProgress] = useState<{
percent: number | null;
label: string | null;
phase: "downloading" | "starting";
} | null>(null);
const loadAbortRef = useRef<AbortController | null>(null);
const loadingModelRef = useRef<typeof loadingModel>(null);
const loadToastIdRef = useRef<string | number | null>(null);
const loadToastDismissedRef = useRef(false);
const setLoadToastDismissedState = useCallback((dismissed: boolean) => {
loadToastDismissedRef.current = dismissed;
setLoadToastDismissed(dismissed);
}, []);
const resetLoadingUi = useCallback(() => {
setLoadingModel(null);
setLoadProgress(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
setLoadToastDismissedState(false);
useChatRuntimeStore.getState().setModelLoading(false);
}, [setLoadToastDismissedState]);
const renderLoadDescription = useCallback(
(
message: string,
progressPercent?: number | null,
progressLabel?: string | null,
onStop?: () => void,
) =>
createElement(ModelLoadDescription, {
message,
progressPercent,
progressLabel,
onStop,
}),
[],
);
const refresh = useCallback(async () => {
setModelsError(null);
@ -182,6 +234,26 @@ export function useChatModelRuntime() {
}
}, [setCheckpoint, setLoras, setModels, setModelsError]);
const cancelLoading = useCallback(() => {
const model = loadingModelRef.current;
if (!model) return;
loadAbortRef.current?.abort();
loadAbortRef.current = null;
loadingModelRef.current = null;
const tid = loadToastIdRef.current;
loadToastIdRef.current = null;
setLoadingModel(null);
setLoadProgress(null);
setLoadToastDismissedState(false);
clearCheckpoint();
if (tid != null) toast.dismiss(tid);
toast.info("Stopped loading model", {
description: "The current download may still finish in the background.",
});
// Fire-and-forget: tell backend to stop, don't block UI
unloadModel({ model_path: model.id }).catch(() => {});
}, [clearCheckpoint, setLoadToastDismissedState]);
const selectModel = useCallback(
async (selection: string | SelectedModelInput) => {
const modelId = typeof selection === "string" ? selection : selection.id;
@ -191,6 +263,8 @@ export function useChatModelRuntime() {
if (!modelId || (params.checkpoint === modelId && (ggufVariant ?? null) === (currentVariant ?? null))) {
return;
}
// Prevent duplicate loads if already loading this model
if (loadingModelRef.current?.id === modelId) return;
const explicitIsLora =
typeof selection === "string" ? undefined : selection.isLora;
@ -217,21 +291,24 @@ export function useChatModelRuntime() {
const previousIsLora =
previousModel?.isLora ?? (previousLora ? true : false);
const loadingDescription = [
currentCheckpoint ? "Unloading previous model first." : null,
currentCheckpoint ? "Switching models." : null,
extraLoadingDescription ?? null,
isDownloaded
? "Loading cached model into memory."
: "Downloading and loading model. Large models can take a while.",
isDownloaded ? "Loading cached model into memory." : null,
]
.filter(Boolean)
.join(" ");
setModelsError(null);
setLoadToastDismissedState(false);
const loadInfo = { id: modelId, displayName, isDownloaded };
setLoadingModel(loadInfo);
useChatRuntimeStore.getState().setModelLoading(true);
setLoadProgress(
isDownloaded
? { percent: null, label: null, phase: "starting" }
: { percent: 0, label: "Preparing download", phase: "downloading" },
);
loadingModelRef.current = loadInfo;
const abortCtrl = new AbortController();
setLoadAbortController(abortCtrl);
loadAbortRef.current = abortCtrl;
try {
async function performLoad(): Promise<void> {
@ -300,114 +377,180 @@ export function useChatModelRuntime() {
}
}
const toastId = toast.loading(
isDownloaded ? "Loading model…" : "Downloading model…",
const toastId = toast(
isDownloaded ? "Starting model…" : "Downloading model…",
{
description: loadingDescription,
duration: 10000,
action: {
label: "Cancel",
onClick: () => {
abortCtrl.abort();
setLoadingModel(null);
setLoadAbortController(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
unloadModel({ model_path: modelId }).catch(() => {});
clearCheckpoint();
toast.dismiss(toastId);
toast.info("Model loading cancelled");
},
icon: createElement(Spinner, { className: "size-4" }),
description: renderLoadDescription(
loadingDescription,
isDownloaded ? null : 0,
isDownloaded ? null : "Preparing download",
cancelLoading,
),
duration: Infinity,
closeButton: true,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) {
return;
}
setLoadToastDismissedState(true);
},
},
);
loadToastIdRef.current = toastId;
// Poll download progress for non-cached models
// Poll download progress for non-cached models (GGUF and non-GGUF)
let progressInterval: ReturnType<typeof setInterval> | null = null;
if (!isDownloaded && ggufVariant) {
if (!isDownloaded) {
const expectedBytes =
typeof selection !== "string" ? selection.expectedBytes ?? 0 : 0;
if (expectedBytes > 0) {
progressInterval = setInterval(async () => {
if (abortCtrl.signal.aborted) {
if (progressInterval) clearInterval(progressInterval);
return;
}
try {
const prog = await getGgufDownloadProgress(modelId, ggufVariant ?? "", expectedBytes);
if (prog.progress > 0 && prog.progress < 1) {
const dlGb = prog.downloaded_bytes / (1024 ** 3);
const totalGb = prog.expected_bytes / (1024 ** 3);
const pct = Math.round(prog.progress * 100);
toast.loading(
`Downloading model… ${pct}%`,
{
id: toastId,
description: `${dlGb.toFixed(1)} / ${totalGb.toFixed(1)} GB`,
duration: 10000,
action: {
label: "Cancel",
onClick: () => {
abortCtrl.abort();
setLoadingModel(null);
setLoadAbortController(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
unloadModel({ model_path: modelId }).catch(() => {});
clearCheckpoint();
toast.dismiss(toastId);
toast.info("Model loading cancelled");
},
},
},
);
} else if (prog.progress >= 1) {
toast.loading("Loading model…", {
let hasShownProgress = false;
const pollProgress = async () => {
if (abortCtrl.signal.aborted || !loadingModelRef.current) {
if (progressInterval) clearInterval(progressInterval);
return;
}
try {
const prog = ggufVariant && expectedBytes > 0
? await getGgufDownloadProgress(modelId, ggufVariant, expectedBytes)
: await getDownloadProgress(modelId);
if (!loadingModelRef.current) return;
if (prog.progress > 0 && prog.progress < 1) {
hasShownProgress = true;
const dlGb = prog.downloaded_bytes / (1024 ** 3);
const totalGb = prog.expected_bytes / (1024 ** 3);
const pct = Math.round(prog.progress * 100);
const progressLabel = totalGb > 0
? `${dlGb.toFixed(1)} of ${totalGb.toFixed(1)} GB`
: `${dlGb.toFixed(1)} GB downloaded`;
setLoadProgress({
percent: pct,
label: progressLabel,
phase: "downloading",
});
if (loadToastDismissedRef.current) return;
toast(
"Downloading model…",
{
id: toastId,
description: "Download complete. Starting inference server…",
duration: 10000,
});
icon: createElement(Spinner, { className: "size-4" }),
description: renderLoadDescription(
loadingDescription,
pct,
progressLabel,
cancelLoading,
),
duration: Infinity,
closeButton: true,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
setLoadToastDismissedState(true);
},
},
);
} else if (prog.downloaded_bytes > 0 && prog.expected_bytes === 0 && prog.progress === 0) {
hasShownProgress = true;
const dlGb = prog.downloaded_bytes / (1024 ** 3);
setLoadProgress({
percent: null,
label: `${dlGb.toFixed(1)} GB downloaded`,
phase: "downloading",
});
} else if (prog.progress >= 1 && hasShownProgress) {
setLoadProgress({
percent: 100,
label: "Download complete",
phase: "starting",
});
if (loadToastDismissedRef.current) {
if (progressInterval) clearInterval(progressInterval);
return;
}
} catch {
// Ignore polling errors
toast("Starting model…", {
id: toastId,
icon: createElement(Spinner, { className: "size-4" }),
description: renderLoadDescription(
"Download complete. Loading the model into memory.",
100,
"Download complete",
cancelLoading,
),
duration: Infinity,
closeButton: true,
classNames: MODEL_LOAD_TOAST_CLASSNAMES,
onDismiss: (dismissedToast) => {
if (loadToastIdRef.current !== dismissedToast.id) return;
setLoadToastDismissedState(true);
},
});
if (progressInterval) clearInterval(progressInterval);
}
}, 2000);
}
} catch {
// Ignore polling errors
}
};
setTimeout(pollProgress, 500);
progressInterval = setInterval(pollProgress, 2000);
}
try {
await performLoad();
toast.success(`${displayName} loaded`, { id: toastId });
if (loadToastDismissedRef.current) {
toast.success(`${displayName} loaded`);
} else {
toast.success(`${displayName} loaded`, {
id: toastId,
description: undefined,
closeButton: false,
duration: 2000,
});
}
} catch (err) {
if (!abortCtrl.signal.aborted) {
toast.error(
err instanceof Error ? err.message : "Failed to load model",
{ id: toastId },
);
const message =
err instanceof Error ? err.message : "Failed to load model";
if (loadToastDismissedRef.current) {
toast.error(message);
} else {
toast.error(message, {
id: toastId,
description: undefined,
closeButton: false,
duration: 5000,
});
}
}
throw err;
} finally {
if (progressInterval) clearInterval(progressInterval);
setLoadingModel(null);
setLoadAbortController(null);
loadingModelRef.current = null;
loadAbortRef.current = null;
loadToastIdRef.current = null;
resetLoadingUi();
}
} catch (error) {
if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report
setLoadingModel(null);
loadingModelRef.current = null;
resetLoadingUi();
const message =
error instanceof Error ? error.message : "Failed to load model";
setModelsError(message);
}
},
[loras, models, params.checkpoint, refresh, setModelsError, setParams],
[
cancelLoading,
loras,
models,
params.checkpoint,
refresh,
renderLoadDescription,
resetLoadingUi,
setLoadToastDismissedState,
setModelsError,
setParams,
],
);
const ejectModel = useCallback(async () => {
@ -436,28 +579,13 @@ export function useChatModelRuntime() {
}
}, [clearCheckpoint, params.checkpoint, refresh, setModelsError]);
const cancelLoading = useCallback(() => {
const model = loadingModelRef.current;
if (!model) return;
loadAbortRef.current?.abort();
loadAbortRef.current = null;
loadingModelRef.current = null;
const tid = loadToastIdRef.current;
loadToastIdRef.current = null;
setLoadingModel(null);
setLoadAbortController(null);
clearCheckpoint();
if (tid != null) toast.dismiss(tid);
toast.info("Model loading cancelled");
// Fire-and-forget: tell backend to stop, don't block UI
unloadModel({ model_path: model.id }).catch(() => {});
}, [clearCheckpoint]);
return {
refresh,
selectModel,
ejectModel,
cancelLoading,
loadingModel,
loadProgress,
loadToastDismissed,
};
}

View file

@ -215,7 +215,7 @@ async function generateTitleWithModel(payload: {
top_p: 0.9,
max_tokens: 24,
top_k: 40,
repetition_penalty: 1.05,
repetition_penalty: 1.0,
messages: [
{
role: "system",
@ -559,6 +559,22 @@ function ThreadNewChatSwitch({
return null;
}
function ActiveThreadSync({
enabled,
}: { enabled: boolean }): ReactElement | null {
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
const setActiveThreadId = useChatRuntimeStore((state) => state.setActiveThreadId);
useEffect(() => {
if (!enabled) {
return;
}
setActiveThreadId(mainThreadId ?? null);
}, [enabled, mainThreadId, setActiveThreadId]);
return null;
}
export function ChatRuntimeProvider({
children,
modelType = "base",
@ -586,6 +602,7 @@ export function ChatRuntimeProvider({
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync enabled={modelType === "base" && !pairId} />
{initialThreadId && <ThreadAutoSwitch threadId={initialThreadId} />}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />

View file

@ -43,8 +43,11 @@ type ChatRuntimeStore = {
autoTitle: boolean;
modelsError: string | null;
activeGgufVariant: string | null;
activeThreadId: string | null;
pendingAudioBase64: string | null;
pendingAudioName: string | null;
modelLoading: boolean;
setModelLoading: (loading: boolean) => void;
setParams: (params: InferenceParams) => void;
setModels: (models: ChatModelSummary[]) => void;
setLoras: (loras: ChatLoraSummary[]) => void;
@ -52,6 +55,7 @@ type ChatRuntimeStore = {
setAutoTitle: (enabled: boolean) => void;
setModelsError: (error: string | null) => void;
setCheckpoint: (modelId: string, ggufVariant?: string | null) => void;
setActiveThreadId: (threadId: string | null) => void;
clearCheckpoint: () => void;
setPendingAudio: (base64: string, name: string) => void;
clearPendingAudio: () => void;
@ -65,8 +69,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
autoTitle: loadBool(AUTO_TITLE_KEY, false),
modelsError: null,
activeGgufVariant: null,
activeThreadId: null,
pendingAudioBase64: null,
pendingAudioName: null,
modelLoading: false,
setModelLoading: (loading) => set({ modelLoading: loading }),
setParams: (params) => set({ params }),
setModels: (models) => set({ models }),
setLoras: (loras) => set({ loras }),
@ -94,6 +101,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
},
activeGgufVariant: ggufVariant ?? null,
})),
setActiveThreadId: (activeThreadId) => set({ activeThreadId }),
clearCheckpoint: () =>
set((state) => ({
params: {

View file

@ -20,9 +20,9 @@ export const DEFAULT_INFERENCE_PARAMS: InferenceParams = {
topP: 0.9,
topK: 50,
minP: 0.01,
repetitionPenalty: 1.1,
repetitionPenalty: 1.0,
maxSeqLength: 4096,
maxTokens: 2048,
maxTokens: 8192,
systemPrompt: "",
checkpoint: "",
trustRemoteCode: false,

View file

@ -11,6 +11,7 @@ export interface HfModelResult {
downloads: number;
likes: number;
totalParams?: number;
estimatedSizeBytes?: number;
}
const EXCLUDED_TAGS = new Set([
@ -54,13 +55,33 @@ function withPopularitySort(
return fetch(url, init);
}
/** Bytes per parameter for each dtype. */
const DTYPE_BYTES: Record<string, number> = {
F64: 8, F32: 4, F16: 2, BF16: 2,
I64: 8, I32: 4, I16: 2, I8: 1, U8: 1,
// Quantized types (4-bit)
NF4: 0.5, FP4: 0.5, INT4: 0.5, GPTQ: 0.5,
};
function estimateSizeFromDtypes(
params: Record<string, number> | undefined,
): number | undefined {
if (!params) return undefined;
let total = 0;
for (const [dtype, count] of Object.entries(params)) {
const bpp = DTYPE_BYTES[dtype.toUpperCase()] ?? 2; // default BF16
total += count * bpp;
}
return total > 0 ? total : undefined;
}
function makeMapModel(excludeGguf: boolean) {
return (raw: unknown): HfModelResult | null => {
const m = raw as {
name: string;
downloads: number;
likes: number;
safetensors?: { total: number };
safetensors?: { total: number; parameters?: Record<string, number> };
tags?: string[];
};
const isEmbedding = m.tags?.some((t) => EMBEDDING_TAGS.has(t));
@ -75,6 +96,7 @@ function makeMapModel(excludeGguf: boolean) {
downloads: m.downloads,
likes: m.likes,
totalParams: m.safetensors?.total,
estimatedSizeBytes: estimateSizeFromDtypes(m.safetensors?.parameters),
};
};
}