feat installer and download model on first launch

This commit is contained in:
fsylvain committed 2026-09-19 23:53:40 +02:00
1 parent 1648b44189
commit 5d4e19f24b
6 files changed
+358 -3

No files matched your search

+51 -1
View File
@@ -1,15 +1,22 @@
import json
import os
import re
import ssl
import time
import urllib.request
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import certifi
# CUDA 13.x moved its runtime DLLs to bin/x64, but llama_cpp's loader only
# checks CUDA_PATH/bin, so point it at the right folder before importing.
# Only relevant when running from source against a system CUDA toolkit - a
# packaged build ships the runtime DLLs next to llama_cpp's own, so this is a
# no-op there (CUDA_PATH won't be set on a machine with just an NVIDIA driver).
_cuda_path = os.environ.get("CUDA_PATH")
if _cuda_path:
_cuda_bin_x64 = Path(_cuda_path) / "bin" / "x64"
@@ -19,7 +26,16 @@ if _cuda_path:
from ddgs import DDGS # noqa: E402
from llama_cpp import Llama # noqa: E402
MODEL_PATH = Path(__file__).parent / "Qwen3.5-27B-IQ4_XS.gguf"
# Model lives outside the install/source directory - a packaged build's own
# folder may be read-only (Program Files) or discarded on update, and the
# model is far too large to embed in an installer, so it's fetched into the
# user's profile on first run instead.
MODEL_FILENAME = "Qwen3.5-27B-IQ4_XS.gguf"
MODELS_DIR = Path.home() / ".dev_assist_chatbot" / "models"
MODEL_PATH = MODELS_DIR / MODEL_FILENAME
MODEL_DOWNLOAD_URL = (
"https://huggingface.co/unsloth/Qwen3.5-27B-GGUF/resolve/main/Qwen3.5-27B-IQ4_XS.gguf"
)
SYSTEM_PROMPT = (
"You are a knowledgeable developer assistant. Answer questions about "
@@ -192,6 +208,40 @@ def build_messages(history: list[tuple[str, str]], question: str) -> list[dict[s
return messages
def download_model(on_progress: Callable[[int, int], None] | None = None) -> None:
"""Fetch MODEL_PATH from MODEL_DOWNLOAD_URL if it isn't already present.
Downloads to a .part file alongside the target and renames on success, so
a crash or interrupted download can't leave a truncated file that
load_model() would then try (and fail) to use. If given, on_progress is
invoked with (bytes_downloaded, total_bytes) as each chunk arrives -
total_bytes is 0 if the server doesn't report a Content-Length."""
if MODEL_PATH.exists():
return
MODELS_DIR.mkdir(parents=True, exist_ok=True)
part_path = MODEL_PATH.with_suffix(MODEL_PATH.suffix + ".part")
request = urllib.request.Request(
MODEL_DOWNLOAD_URL, headers={"User-Agent": "dev-assist-chatbot"}
)
# A fresh/sandboxed Windows install may not have fetched the intermediate
# CA cert for huggingface.co's chain yet (normally done transparently via
# Windows Update on first need, which a locked-down or offline sandbox
# can't reach) - use certifi's bundled CA list instead of the OS trust
# store so this doesn't depend on that happening.
ssl_context = ssl.create_default_context(cafile=certifi.where())
with urllib.request.urlopen(request, context=ssl_context) as response:
total = int(response.headers.get("Content-Length", 0))
downloaded = 0
with open(part_path, "wb") as f:
while chunk := response.read(1024 * 1024):
f.write(chunk)
downloaded += len(chunk)
if on_progress is not None:
on_progress(downloaded, total)
part_path.rename(MODEL_PATH)
def load_model() -> Llama:
return Llama(
model_path=str(MODEL_PATH),