Real catalogue: 83 verified models, buy-advice mode, live model lookup, license-aware cards
Browse files- app.py +41 -240
- catalogue.json +0 -0
- engine/hub_lookup.py +155 -0
- engine/real_advisor.py +538 -0
- static/app.js +143 -7
- static/index.html +19 -4
- static/style.css +29 -0
app.py
CHANGED
|
@@ -1,20 +1,24 @@
|
|
| 1 |
"""
|
| 2 |
FitCheck — what AI can your computer actually run?
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
- /api/advise
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
- /
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
-
import re
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
import gradio as gr
|
|
@@ -22,8 +26,8 @@ from fastapi.responses import FileResponse
|
|
| 22 |
from fastapi.staticfiles import StaticFiles
|
| 23 |
from pydantic import BaseModel
|
| 24 |
|
| 25 |
-
from engine import
|
| 26 |
-
from engine.ui_adapter import
|
| 27 |
from model_brick import ask as model_ask
|
| 28 |
|
| 29 |
STATIC = Path(__file__).parent / "static"
|
|
@@ -31,227 +35,6 @@ STATIC = Path(__file__).parent / "static"
|
|
| 31 |
app = gr.Server()
|
| 32 |
|
| 33 |
|
| 34 |
-
# ==========================================================================
|
| 35 |
-
# PLACEHOLDER engine — vision / image-gen / audio / data goals only.
|
| 36 |
-
# The real engine (engine/) covers LLM goals; these families aren't modelled
|
| 37 |
-
# there yet, so they keep these plausible, conservative placeholder numbers.
|
| 38 |
-
# ==========================================================================
|
| 39 |
-
|
| 40 |
-
_COLORS = {"model": "#818CF8", "chat": "#A5F3C4", "work": "#868E9C"}
|
| 41 |
-
|
| 42 |
-
# Which broad family each goal belongs to (drives models/tools/commands).
|
| 43 |
-
_FAMILY = {
|
| 44 |
-
"chat": "llm", "writing": "llm", "coding": "llm", "agents": "llm",
|
| 45 |
-
"rag": "llm", "translate": "llm", "finetune": "llm",
|
| 46 |
-
"detect": "vision", "segment": "vision", "pose": "vision",
|
| 47 |
-
"classify": "vision", "depth": "vision", "ocr": "vision", "train-vision": "vision",
|
| 48 |
-
"imagegen": "gen", "inpaint": "gen", "upscale": "gen", "videogen": "gen", "bgremove": "gen",
|
| 49 |
-
"stt": "audio", "tts": "audio", "music": "audio",
|
| 50 |
-
"embed": "data", "forecast": "data", "tabular": "data",
|
| 51 |
-
"custom": "llm",
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
# variant = (name, description, memory-need-GB at sensible setting, setting label)
|
| 55 |
-
_VARIANTS = {
|
| 56 |
-
"llm": [
|
| 57 |
-
("Huge (70B)", "Top open quality. Serious hardware only.", 42, "4-bit"),
|
| 58 |
-
("Very large (32B)", "Near-premium. Wants a strong GPU.", 20, "4-bit"),
|
| 59 |
-
("Large (14B)", "Noticeably smarter and more reliable.", 9, "4-bit"),
|
| 60 |
-
("Medium (7-9B)", "Solid all-rounder: chat, coding, reasoning.", 5.5, "4-bit"),
|
| 61 |
-
("Small (3-4B)", "Surprisingly capable everyday assistant.", 2.5, "4-bit"),
|
| 62 |
-
("Tiny (~1B)", "Quick simple chat. Runs on almost anything.", 1.2, "4-bit"),
|
| 63 |
-
],
|
| 64 |
-
"vision": [
|
| 65 |
-
("Extra-large model", "Highest accuracy, slowest.", 6.0, "full"),
|
| 66 |
-
("Large model", "Great accuracy for real work.", 3.5, "full"),
|
| 67 |
-
("Medium model", "Balanced accuracy and speed.", 2.0, "full"),
|
| 68 |
-
("Small model", "Fast, good for live video.", 1.2, "full"),
|
| 69 |
-
("Nano model", "Real-time even on weak hardware.", 0.6, "full"),
|
| 70 |
-
],
|
| 71 |
-
"gen": [
|
| 72 |
-
("Flux.1 (dev)", "State-of-the-art image quality.", 18, "full"),
|
| 73 |
-
("Flux.1 (schnell)", "Near-top quality, much faster.", 12, "8-bit"),
|
| 74 |
-
("SDXL", "Excellent 1024px images.", 8, "full"),
|
| 75 |
-
("SDXL Turbo", "Fast SDXL, fewer steps.", 7, "full"),
|
| 76 |
-
("Stable Diffusion 1.5", "Light, fast, huge community.", 3.5, "full"),
|
| 77 |
-
],
|
| 78 |
-
"audio": [
|
| 79 |
-
("Whisper large-v3", "Best transcription accuracy.", 5.0, "full"),
|
| 80 |
-
("Whisper medium", "Strong accuracy, lighter.", 3.0, "full"),
|
| 81 |
-
("Whisper small", "Good for clear audio, fast.", 1.5, "full"),
|
| 82 |
-
("Whisper base", "Quick drafts, light hardware.", 0.8, "full"),
|
| 83 |
-
],
|
| 84 |
-
"data": [
|
| 85 |
-
("Large embedder", "Best search relevance.", 2.5, "full"),
|
| 86 |
-
("Base embedder", "Great balance for most search.", 1.2, "full"),
|
| 87 |
-
("Small embedder", "Fast, runs on anything.", 0.5, "full"),
|
| 88 |
-
],
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
_TOOLS = {
|
| 92 |
-
"llm": [
|
| 93 |
-
("Ollama", "Type one line; it downloads and runs the model for you.", "Get it from ollama.com", "Easiest"),
|
| 94 |
-
("LM Studio", "A point-and-click app with a chat window, no commands.", "Download from lmstudio.ai", "Easy"),
|
| 95 |
-
("llama.cpp", "The lightweight engine under the hood. Runs GGUF files directly.", "Releases on GitHub", "Advanced"),
|
| 96 |
-
],
|
| 97 |
-
"vision": [
|
| 98 |
-
("Ultralytics", "One pip install, then detect objects from a webcam or file.", "pip install ultralytics", "Easiest"),
|
| 99 |
-
("Roboflow", "Browser tools to label data and run models, little code.", "roboflow.com", "Easy"),
|
| 100 |
-
("PyTorch", "Full control for custom pipelines and training.", "pytorch.org", "Advanced"),
|
| 101 |
-
],
|
| 102 |
-
"gen": [
|
| 103 |
-
("Fooocus", "Image generation that 'just works': one folder, double-click.", "Download from GitHub", "Easiest"),
|
| 104 |
-
("ComfyUI", "Powerful visual node editor for image/video pipelines.", "Download from GitHub", "Moderate"),
|
| 105 |
-
("Automatic1111", "The classic full-featured Stable Diffusion web UI.", "Download from GitHub", "Moderate"),
|
| 106 |
-
],
|
| 107 |
-
"audio": [
|
| 108 |
-
("faster-whisper", "Fast, accurate transcription with a tiny install.", "pip install faster-whisper", "Easiest"),
|
| 109 |
-
("whisper.cpp", "Runs Whisper efficiently on CPU and small machines.", "Build from GitHub", "Advanced"),
|
| 110 |
-
],
|
| 111 |
-
"data": [
|
| 112 |
-
("sentence-transformers", "Turn text into searchable vectors in a few lines.", "pip install sentence-transformers", "Easiest"),
|
| 113 |
-
("Chroma", "A simple local database to store and search those vectors.", "pip install chromadb", "Easy"),
|
| 114 |
-
],
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
_COMMANDS = {
|
| 118 |
-
"llm": [
|
| 119 |
-
("Easy way (Ollama)", "ollama run llama3.1:8b"),
|
| 120 |
-
("Power way (llama.cpp)", "llama-server -hf bartowski/Meta-Llama-3.1-8B-Instruct-GGUF:Q4_K_M -c 4096"),
|
| 121 |
-
],
|
| 122 |
-
"vision": [
|
| 123 |
-
("Install", "pip install ultralytics"),
|
| 124 |
-
("Detect from your webcam", "yolo predict model=yolo11n.pt source=0 show=True"),
|
| 125 |
-
],
|
| 126 |
-
"gen": [
|
| 127 |
-
("Easiest (Fooocus)", "# Download Fooocus, unzip, double-click run.bat"),
|
| 128 |
-
("Power (ComfyUI)", "git clone https://github.com/comfyanonymous/ComfyUI && cd ComfyUI && python main.py"),
|
| 129 |
-
],
|
| 130 |
-
"audio": [
|
| 131 |
-
("Install", "pip install faster-whisper"),
|
| 132 |
-
("Transcribe a file", 'python -c "from faster_whisper import WhisperModel; m=WhisperModel(\'small\'); print(list(m.transcribe(\'audio.mp3\')[0]))"'),
|
| 133 |
-
],
|
| 134 |
-
"data": [
|
| 135 |
-
("Install", "pip install sentence-transformers"),
|
| 136 |
-
("Make searchable vectors", 'python -c "from sentence_transformers import SentenceTransformer as S; print(S(\'all-MiniLM-L6-v2\').encode(\'hello world\').shape)"'),
|
| 137 |
-
],
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def _parse_vram(label: str) -> float:
|
| 142 |
-
m = re.search(r"(\d+(?:\.\d+)?)\s*GB", label or "")
|
| 143 |
-
return float(m.group(1)) if m else 0.0
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _budgets(p: dict):
|
| 147 |
-
ram = float(p.get("ram_gb") or 16)
|
| 148 |
-
provider = p.get("provider", "none")
|
| 149 |
-
computer = p.get("computer", "Windows laptop")
|
| 150 |
-
|
| 151 |
-
if provider == "apple":
|
| 152 |
-
fast = round(ram * 0.70, 1)
|
| 153 |
-
return fast, fast, ram, True
|
| 154 |
-
|
| 155 |
-
vram = p.get("vram_gb")
|
| 156 |
-
if not vram:
|
| 157 |
-
vram = _parse_vram(p.get("gpu", ""))
|
| 158 |
-
vram = float(vram or 0)
|
| 159 |
-
|
| 160 |
-
fast = round(vram * 0.85, 1)
|
| 161 |
-
reserve = 1.0 if "Mini PC" in computer else (3.0 if "desktop" in computer.lower() or computer == "Mac" else 3.5)
|
| 162 |
-
total = round(vram + max(0.0, ram - reserve) * 0.9, 1)
|
| 163 |
-
return fast, vram, total, False
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
def advise_mock(p: dict) -> dict:
|
| 167 |
-
fam = _FAMILY.get(p.get("usecase", "chat"), "llm")
|
| 168 |
-
variants = _VARIANTS[fam]
|
| 169 |
-
fast, _vram, total, is_apple = _budgets(p)
|
| 170 |
-
is_finetune = p.get("usecase") in ("finetune", "train-vision")
|
| 171 |
-
factor = 2.4 if is_finetune else 1.0
|
| 172 |
-
|
| 173 |
-
# Classify each variant.
|
| 174 |
-
def verdict_for(need):
|
| 175 |
-
need *= factor
|
| 176 |
-
if fast >= 1 and need <= fast * 0.9:
|
| 177 |
-
return "great", need
|
| 178 |
-
if need <= total * 0.9:
|
| 179 |
-
return "tight", need
|
| 180 |
-
return "no", need
|
| 181 |
-
|
| 182 |
-
options = []
|
| 183 |
-
for name, desc, need, setting in variants:
|
| 184 |
-
vd, real_need = verdict_for(need)
|
| 185 |
-
feel = {"great": "Snappy on your graphics card",
|
| 186 |
-
"tight": "Runs, but slower (uses normal memory)",
|
| 187 |
-
"no": "Not enough memory"}[vd]
|
| 188 |
-
options.append({
|
| 189 |
-
"verdict": vd, "model": name, "desc": desc,
|
| 190 |
-
"setting": setting, "memory": ("—" if vd == "no" else f"{round(real_need,1):g} GB"),
|
| 191 |
-
"feel": feel,
|
| 192 |
-
})
|
| 193 |
-
|
| 194 |
-
# Headline = best that runs great, else best that's tight, else smallest.
|
| 195 |
-
great = [o for o in options if o["verdict"] == "great"]
|
| 196 |
-
tight = [o for o in options if o["verdict"] == "tight"]
|
| 197 |
-
head = great[0] if great else (tight[0] if tight else options[-1])
|
| 198 |
-
hv = head["verdict"]
|
| 199 |
-
need_gb = float(re.search(r"[\d.]+", head["memory"]).group()) if head["memory"] != "—" else verdict_for(variants[-1][2])[1]
|
| 200 |
-
|
| 201 |
-
if is_apple and hv == "great":
|
| 202 |
-
where = "on your Mac"
|
| 203 |
-
elif hv == "great" and fast >= 1:
|
| 204 |
-
where = "on your graphics card"
|
| 205 |
-
elif hv == "tight":
|
| 206 |
-
where = "using your computer's memory"
|
| 207 |
-
else:
|
| 208 |
-
where = ""
|
| 209 |
-
verdict_word = {"great": "Runs great", "tight": "Tight, but works", "no": "Won't fit"}[hv]
|
| 210 |
-
if hv == "great":
|
| 211 |
-
headline = f"Yes, you can run the {head['model']} {where}, today."
|
| 212 |
-
elif hv == "tight":
|
| 213 |
-
headline = f"Sort of. The {head['model']} will run {where}, with trade-offs."
|
| 214 |
-
else:
|
| 215 |
-
headline = "This goal is a stretch on this machine. Here's the honest picture."
|
| 216 |
-
|
| 217 |
-
detail = (f"For this goal, the sweet spot is a <b>{head['model']}</b> model "
|
| 218 |
-
f"at the <b>{head['setting']}</b> setting. {head['desc']} "
|
| 219 |
-
f"It needs about <b>{round(need_gb,1):g} GB</b>, and you have roughly "
|
| 220 |
-
f"<b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total to work with.")
|
| 221 |
-
note = ("Fine-tuning needs roughly 2 to 3 times the memory of just using a model. That's baked in above."
|
| 222 |
-
if is_finetune else "")
|
| 223 |
-
|
| 224 |
-
# Gauge
|
| 225 |
-
scale = max(total, need_gb, 1) * 1.05
|
| 226 |
-
gauge = {
|
| 227 |
-
"need_gb": f"{round(need_gb,1):g} GB needed",
|
| 228 |
-
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
| 229 |
-
"fill_pct": round(need_gb / scale * 100, 1),
|
| 230 |
-
"mark_pct": round(fast / scale * 100, 1),
|
| 231 |
-
"breakdown": [
|
| 232 |
-
{"label": f"Model {round(need_gb*0.8,1):g} GB", "color": _COLORS["model"]},
|
| 233 |
-
{"label": f"Working space {round(need_gb*0.2,1):g} GB", "color": _COLORS["work"]},
|
| 234 |
-
],
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
tools = [{"name": n, "what": w, "install": i, "tag": t} for n, w, i, t in _TOOLS[fam]]
|
| 238 |
-
cmd_intro = ("These get you a running model in minutes. Pick the easy one or the power one; "
|
| 239 |
-
"they do the same job.")
|
| 240 |
-
commands = {"intro": cmd_intro,
|
| 241 |
-
"items": [{"label": l, "code": c} for l, c in _COMMANDS[fam]]}
|
| 242 |
-
|
| 243 |
-
return {
|
| 244 |
-
"catalogue_version": CATALOGUE_VERSION,
|
| 245 |
-
"verdict": hv, "verdict_word": verdict_word,
|
| 246 |
-
"headline": headline, "detail": detail, "note": note,
|
| 247 |
-
"gauge": gauge, "options": options, "tools": tools, "commands": commands,
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
# ==========================================================================
|
| 252 |
-
# Connector endpoint + static frontend
|
| 253 |
-
# ==========================================================================
|
| 254 |
-
|
| 255 |
class AdviseIn(BaseModel):
|
| 256 |
computer: str = "Windows laptop"
|
| 257 |
ram_gb: float | None = 16
|
|
@@ -267,10 +50,28 @@ class AdviseIn(BaseModel):
|
|
| 267 |
@app.post("/api/advise")
|
| 268 |
def api_advise(payload: AdviseIn):
|
| 269 |
p = payload.model_dump()
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
@app.api(name="ask")
|
|
@@ -279,7 +80,7 @@ def api_ask(question: str, facts: str = "") -> dict:
|
|
| 279 |
|
| 280 |
Exposed at /gradio_api/call/ask (NOT a plain POST) so it runs through
|
| 281 |
Gradio's queue and gets a ZeroGPU allocation. `facts` is the JSON string of
|
| 282 |
-
the last /api/advise result. Returns {headline, why, next_step}.
|
| 283 |
"""
|
| 284 |
return model_ask(question, facts)
|
| 285 |
|
|
|
|
| 1 |
"""
|
| 2 |
FitCheck — what AI can your computer actually run?
|
| 3 |
|
| 4 |
+
Four bricks behind a `gr.Server` (which IS a FastAPI app) serving the
|
| 5 |
+
hand-built frontend in static/:
|
| 6 |
+
|
| 7 |
+
- /api/advise : the honest verdict. Deterministic engine (engine/) over
|
| 8 |
+
catalogue.json — 83 real models with exact GGUF file sizes,
|
| 9 |
+
licenses, and links, refreshed from the Hugging Face API at
|
| 10 |
+
build time. The running app makes no network calls here.
|
| 11 |
+
- /api/minspecs : the reverse question — "what machine do I need for X?"
|
| 12 |
+
Same engine, inverted over a hardware ladder. Offline.
|
| 13 |
+
- /api/lookup : OPTIONAL live check of any pasted HF repo id. Walks the
|
| 14 |
+
model-tree (finetune -> base) to a catalogue entry, or does
|
| 15 |
+
labelled raw math. The one endpoint that touches the
|
| 16 |
+
network, and the UI says so.
|
| 17 |
+
- /gradio_api/call/ask : the model brick (model_brick.ask) — a small local
|
| 18 |
+
LLM that explains the engine's numbers in plain words.
|
| 19 |
+
@app.api so it runs on Gradio's queue and gets ZeroGPU.
|
| 20 |
"""
|
| 21 |
|
|
|
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
import gradio as gr
|
|
|
|
| 26 |
from fastapi.staticfiles import StaticFiles
|
| 27 |
from pydantic import BaseModel
|
| 28 |
|
| 29 |
+
from engine.real_advisor import advise_real, min_specs
|
| 30 |
+
from engine.ui_adapter import spec_from_payload
|
| 31 |
from model_brick import ask as model_ask
|
| 32 |
|
| 33 |
STATIC = Path(__file__).parent / "static"
|
|
|
|
| 35 |
app = gr.Server()
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
class AdviseIn(BaseModel):
|
| 39 |
computer: str = "Windows laptop"
|
| 40 |
ram_gb: float | None = 16
|
|
|
|
| 50 |
@app.post("/api/advise")
|
| 51 |
def api_advise(payload: AdviseIn):
|
| 52 |
p = payload.model_dump()
|
| 53 |
+
return advise_real(p, spec_from_payload(p))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class MinSpecsIn(BaseModel):
|
| 57 |
+
usecase: str = "chat"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.post("/api/minspecs")
|
| 61 |
+
def api_minspecs(payload: MinSpecsIn):
|
| 62 |
+
return min_specs(payload.usecase)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class LookupIn(AdviseIn):
|
| 66 |
+
repo: str = ""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.post("/api/lookup")
|
| 70 |
+
def api_lookup(payload: LookupIn):
|
| 71 |
+
"""Live lookup of one HF repo id (labelled online in the UI)."""
|
| 72 |
+
from engine.hub_lookup import lookup
|
| 73 |
+
p = payload.model_dump()
|
| 74 |
+
return lookup(p.get("repo", ""), p, spec_from_payload(p))
|
| 75 |
|
| 76 |
|
| 77 |
@app.api(name="ask")
|
|
|
|
| 80 |
|
| 81 |
Exposed at /gradio_api/call/ask (NOT a plain POST) so it runs through
|
| 82 |
Gradio's queue and gets a ZeroGPU allocation. `facts` is the JSON string of
|
| 83 |
+
the last /api/advise result. Returns {headline, why, next_step} or {error}.
|
| 84 |
"""
|
| 85 |
return model_ask(question, facts)
|
| 86 |
|
catalogue.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
engine/hub_lookup.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Optional ONLINE lookup: "will this exact Hugging Face model run on my machine?"
|
| 3 |
+
|
| 4 |
+
Deterministic — no AI involved. Given any repo id (or model page URL), this:
|
| 5 |
+
1. checks the local catalogue (offline) by repo id and aliases;
|
| 6 |
+
2. otherwise makes ONE metadata call to the Hub, reads the model-tree tags
|
| 7 |
+
(base_model:finetune/adapter/quantized/merge), and walks up to 3 hops to
|
| 8 |
+
find a catalogue ancestor — "your finetune runs because its base runs";
|
| 9 |
+
3. otherwise falls back to raw parameter-count math, clearly labelled.
|
| 10 |
+
|
| 11 |
+
This is the only part of FitCheck that touches the network at runtime, and the
|
| 12 |
+
UI labels it as a live lookup. The core advisor stays fully offline.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
|
| 18 |
+
from .hardware import HardwareSpec
|
| 19 |
+
from .real_advisor import (
|
| 20 |
+
USE_CASES, _SAFETY_FILL, _evaluate, _option_json, catalogue,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
_RELATION = re.compile(r"^base_model:(finetune|adapter|quantized|merge):(.+)$")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@lru_cache(maxsize=1)
|
| 27 |
+
def _index() -> dict:
|
| 28 |
+
idx = {}
|
| 29 |
+
for e in catalogue()["entries"]:
|
| 30 |
+
idx[e["repo_id"].lower()] = e
|
| 31 |
+
for a in e.get("aliases", []):
|
| 32 |
+
idx[a.lower()] = e
|
| 33 |
+
return idx
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def normalize_repo_id(text: str) -> str:
|
| 37 |
+
"""Accept a bare repo id or any huggingface.co URL."""
|
| 38 |
+
text = (text or "").strip().rstrip("/")
|
| 39 |
+
m = re.search(r"huggingface\.co/([\w.-]+/[\w.-]+)", text)
|
| 40 |
+
if m:
|
| 41 |
+
return m.group(1)
|
| 42 |
+
return text
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _relations(info) -> list[tuple[str, str]]:
|
| 46 |
+
out = []
|
| 47 |
+
for t in (getattr(info, "tags", None) or []):
|
| 48 |
+
m = _RELATION.match(t)
|
| 49 |
+
if m:
|
| 50 |
+
out.append((m.group(1), m.group(2)))
|
| 51 |
+
if not out:
|
| 52 |
+
# cardData fallback only when tags carry no typed relation — the tag
|
| 53 |
+
# knows whether it's a finetune or a quantized copy; cardData doesn't.
|
| 54 |
+
card = getattr(info, "card_data", None)
|
| 55 |
+
if card:
|
| 56 |
+
base = card.get("base_model") if hasattr(card, "get") else getattr(card, "base_model", None)
|
| 57 |
+
if isinstance(base, str):
|
| 58 |
+
out.append(("finetune", base))
|
| 59 |
+
elif isinstance(base, list):
|
| 60 |
+
out.extend(("finetune", b) for b in base if isinstance(b, str))
|
| 61 |
+
return out
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict:
|
| 65 |
+
"""Returns {found, model, chain, verdict-ish fields} or {error}."""
|
| 66 |
+
repo_id = normalize_repo_id(repo_input)
|
| 67 |
+
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo_id):
|
| 68 |
+
return {"error": f"'{repo_input}' doesn't look like a Hugging Face repo id "
|
| 69 |
+
f"(expected something like author/model-name)."}
|
| 70 |
+
|
| 71 |
+
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
|
| 72 |
+
chain = [repo_id]
|
| 73 |
+
|
| 74 |
+
# 1) Offline: direct catalogue hit (also via aliases).
|
| 75 |
+
entry = _index().get(repo_id.lower())
|
| 76 |
+
via = None
|
| 77 |
+
|
| 78 |
+
# 2) Online: one metadata call + base-model walk.
|
| 79 |
+
info = None
|
| 80 |
+
if entry is None:
|
| 81 |
+
from huggingface_hub import HfApi
|
| 82 |
+
api = HfApi()
|
| 83 |
+
current = repo_id
|
| 84 |
+
try:
|
| 85 |
+
info = api.model_info(current, expand=["tags", "safetensors", "cardData",
|
| 86 |
+
"pipeline_tag", "gated"])
|
| 87 |
+
except Exception as exc: # noqa: BLE001 — surface the real failure
|
| 88 |
+
return {"error": f"Couldn't find '{repo_id}' on Hugging Face "
|
| 89 |
+
f"({type(exc).__name__}). Check the spelling?"}
|
| 90 |
+
hop_info = info
|
| 91 |
+
for _hop in range(3):
|
| 92 |
+
rels = _relations(hop_info)
|
| 93 |
+
if not rels:
|
| 94 |
+
break
|
| 95 |
+
# Prefer finetune/merge (same memory as base) over quantized.
|
| 96 |
+
rels.sort(key=lambda r: 0 if r[0] in ("finetune", "merge", "adapter") else 1)
|
| 97 |
+
rel, parent = rels[0]
|
| 98 |
+
chain.append(parent)
|
| 99 |
+
entry = _index().get(parent.lower())
|
| 100 |
+
if entry is not None:
|
| 101 |
+
via = {"relation": rel, "base": parent}
|
| 102 |
+
break
|
| 103 |
+
try:
|
| 104 |
+
hop_info = api.model_info(parent, expand=["tags", "cardData"])
|
| 105 |
+
except Exception: # noqa: BLE001 — chain ends here
|
| 106 |
+
break
|
| 107 |
+
|
| 108 |
+
if entry is not None:
|
| 109 |
+
r = _evaluate(entry, spec, uc)
|
| 110 |
+
opt = _option_json(r, spec)
|
| 111 |
+
explain = f"<b>{repo_id.split('/')[-1]}</b> "
|
| 112 |
+
if via:
|
| 113 |
+
word = {"finetune": "is fine-tuned from", "merge": "is merged from",
|
| 114 |
+
"adapter": "is an adapter on", "quantized": "is a compressed copy of"}[via["relation"]]
|
| 115 |
+
explain += (f"{word} <b>{entry['name']}</b> — if the base runs, this runs, "
|
| 116 |
+
f"with the same memory needs.")
|
| 117 |
+
if via["relation"] == "adapter":
|
| 118 |
+
explain += " Add roughly 0.1–0.5 GB for the adapter file."
|
| 119 |
+
else:
|
| 120 |
+
explain += f"is <b>{entry['name']}</b> in our catalogue."
|
| 121 |
+
return {"found": True, "match": "catalogue", "chain": chain,
|
| 122 |
+
"explain": explain, "option": opt, "live": via is not None or info is not None}
|
| 123 |
+
|
| 124 |
+
# 3) Raw math from parameter count — clearly labelled estimate.
|
| 125 |
+
st = getattr(info, "safetensors", None)
|
| 126 |
+
total = getattr(st, "total", None) if st else None
|
| 127 |
+
if not total:
|
| 128 |
+
return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
|
| 129 |
+
f"base model, so an honest estimate isn't possible."}
|
| 130 |
+
params_b = total / 1e9
|
| 131 |
+
weights_4bit = round(params_b * 4.85 / 8, 2) # effective 4-bit bits/weight
|
| 132 |
+
need = round(weights_4bit * 1.25 + 0.58, 2)
|
| 133 |
+
fast, total_b = spec.fast_budget_gb, spec.total_budget_gb
|
| 134 |
+
if spec.has_fast_path and need <= fast * _SAFETY_FILL:
|
| 135 |
+
verdict = "great"
|
| 136 |
+
elif need <= total_b * _SAFETY_FILL:
|
| 137 |
+
verdict = "tight"
|
| 138 |
+
else:
|
| 139 |
+
verdict = "no"
|
| 140 |
+
return {
|
| 141 |
+
"found": True, "match": "estimate", "chain": chain, "live": True,
|
| 142 |
+
"explain": (f"<b>{repo_id.split('/')[-1]}</b> isn't in our catalogue and lists no "
|
| 143 |
+
f"known base, so this is raw math from its {params_b:.1f}B parameters "
|
| 144 |
+
f"at a 4-bit setting — an estimate, not a measured figure."),
|
| 145 |
+
"option": {
|
| 146 |
+
"verdict": verdict, "model": repo_id.split("/")[-1],
|
| 147 |
+
"desc": f"{params_b:.1f}B parameters (from its files on Hugging Face)",
|
| 148 |
+
"setting": "Balanced (4-bit)",
|
| 149 |
+
"memory": "Too big" if verdict == "no" else f"{need:g} GB",
|
| 150 |
+
"feel": "", "url": f"https://huggingface.co/{repo_id}",
|
| 151 |
+
"license": "", "license_note": "", "gated": bool(getattr(info, "gated", False)),
|
| 152 |
+
"run": {}, "provenance": "estimated", "stale": False,
|
| 153 |
+
"params_b": round(params_b, 2), "active_params_b": None,
|
| 154 |
+
},
|
| 155 |
+
}
|
engine/real_advisor.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Engine v2: honest verdicts over REAL models from catalogue.json.
|
| 3 |
+
|
| 4 |
+
This replaces both the size-class advisor and the placeholder families. Every
|
| 5 |
+
option it returns is an actual model with a Hugging Face link, a license, and
|
| 6 |
+
memory figures with provenance:
|
| 7 |
+
|
| 8 |
+
- LLM / VLM weights = the EXACT GGUF file size in bytes from the Hub
|
| 9 |
+
(ground truth — better than any params-times-bits estimate).
|
| 10 |
+
- Chat memory (KV cache) = GQA-aware math from the model's real config
|
| 11 |
+
(layers, hidden, kv-heads) when available; a conservative parameter-count
|
| 12 |
+
heuristic when the repo is gated (labelled as estimated).
|
| 13 |
+
- Working space includes a +0.577 GB buffer — the 95% load-success margin
|
| 14 |
+
oobabooga fitted over 19,517 real measurements (gguf-vram-formula).
|
| 15 |
+
- Non-GGUF families (vision / image gen / audio / embeddings / data) carry a
|
| 16 |
+
single memory figure whose provenance is vendor-published, community-
|
| 17 |
+
reported, or estimated — and the UI says which.
|
| 18 |
+
|
| 19 |
+
The catalogue is baked into the repo at build time (refreshed by
|
| 20 |
+
scripts/refresh_catalogue.py), so the running app makes no network calls.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
from functools import lru_cache
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
from .hardware import HardwareSpec
|
| 28 |
+
from .runtimes import pick_runtimes
|
| 29 |
+
|
| 30 |
+
_CATALOGUE_PATH = Path(__file__).resolve().parent.parent / "catalogue.json"
|
| 31 |
+
|
| 32 |
+
# We only fill a budget to this fraction — the rest is breathing room.
|
| 33 |
+
_SAFETY_FILL = 0.90
|
| 34 |
+
# oobabooga's fitted 95%-load-success buffer (GB), cited in the UI footnote.
|
| 35 |
+
_CONFIDENCE_BUFFER_GB = 0.577
|
| 36 |
+
|
| 37 |
+
_VERDICT_WORD = {"great": "Runs great", "tight": "Tight, but works", "no": "Won't fit"}
|
| 38 |
+
_C_MODEL = "#818CF8"
|
| 39 |
+
_C_WORK = "#868E9C"
|
| 40 |
+
|
| 41 |
+
# Quant ladder quality order (matches scripts/refresh_catalogue.py).
|
| 42 |
+
_QUANT_ORDER = ["Q8_0", "Q6_K", "Q5_K_M", "Q4_K_M", "IQ4_XS", "Q3_K_M", "Q2_K"]
|
| 43 |
+
_FOUR_BIT_RANK = _QUANT_ORDER.index("IQ4_XS") # >= this index quality = sub-4-bit
|
| 44 |
+
_COMPROMISE_QUANTS = ["Q4_K_M", "IQ4_XS", "Q3_K_M", "Q2_K"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --------------------------------------------------------------------------
|
| 48 |
+
# Use cases
|
| 49 |
+
# --------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
class UC:
|
| 52 |
+
def __init__(self, key, plain, family, ctx=4096, min_b=0.5, good_b=3.0,
|
| 53 |
+
factor=1.0, note=""):
|
| 54 |
+
self.key, self.plain_name, self.family = key, plain, family
|
| 55 |
+
self.context_tokens, self.min_b, self.good_b = ctx, min_b, good_b
|
| 56 |
+
self.overhead_factor, self.note = factor, note
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
USE_CASES = {u.key: u for u in [
|
| 60 |
+
UC("chat", "Just chatting / asking questions", "llm", 4096, 0.5, 3.0),
|
| 61 |
+
UC("writing", "Writing & summarising", "llm", 4096, 1.5, 7.0),
|
| 62 |
+
UC("coding", "Coding help", "llm", 8192, 3.0, 7.0,
|
| 63 |
+
note="Bigger models are much more reliable for code."),
|
| 64 |
+
UC("agents", "Agents & tool use", "llm", 8192, 7.0, 7.0, 1.15,
|
| 65 |
+
note="Needs steady instruction-following — go medium or larger."),
|
| 66 |
+
UC("rag", "Chat with your documents", "llm", 16384, 3.0, 7.0,
|
| 67 |
+
note="Long documents use extra memory for context — that's included here."),
|
| 68 |
+
UC("translate", "Translation", "llm", 4096, 1.5, 7.0),
|
| 69 |
+
UC("finetune", "Fine-tune an LLM (LoRA)", "llm", 2048, 3.0, 7.0, 2.2,
|
| 70 |
+
note="Training needs roughly 2-3x the memory of just chatting. That's baked into these numbers."),
|
| 71 |
+
UC("custom", "Your custom goal", "llm", 4096, 0.5, 7.0),
|
| 72 |
+
UC("vlm", "Chat about images & video", "vlm", 4096, 2.0, 4.0),
|
| 73 |
+
UC("detect", "Object detection", "vision"),
|
| 74 |
+
UC("segment", "Image segmentation", "vision"),
|
| 75 |
+
UC("pose", "Pose estimation (2D & 6-DoF)", "vision"),
|
| 76 |
+
UC("classify", "Image classification", "vision"),
|
| 77 |
+
UC("depth", "Depth estimation", "vision"),
|
| 78 |
+
UC("ocr", "Read text from images (OCR)", "vision"),
|
| 79 |
+
UC("train-vision", "Train a vision model", "vision", factor=3.0,
|
| 80 |
+
note="Training needs roughly 3x the memory of running the same model."),
|
| 81 |
+
UC("imagegen", "Generate images", "imagegen"),
|
| 82 |
+
UC("inpaint", "Edit / inpaint images", "imagegen"),
|
| 83 |
+
UC("upscale", "Upscale / restore images", "imagegen"),
|
| 84 |
+
UC("videogen", "Generate video", "imagegen"),
|
| 85 |
+
UC("bgremove", "Remove backgrounds", "imagegen"),
|
| 86 |
+
UC("stt", "Speech to text", "audio"),
|
| 87 |
+
UC("tts", "Text to speech / voice", "audio"),
|
| 88 |
+
UC("music", "Generate music", "audio"),
|
| 89 |
+
UC("embed", "Semantic search / embeddings", "embed"),
|
| 90 |
+
UC("forecast", "Time-series forecasting", "data"),
|
| 91 |
+
UC("tabular", "Predict from spreadsheets", "data"),
|
| 92 |
+
]}
|
| 93 |
+
|
| 94 |
+
# Use cases answered by the whole LLM family (entries don't list these).
|
| 95 |
+
_TEXT_UCS = {"chat", "writing", "coding", "agents", "rag", "translate",
|
| 96 |
+
"finetune", "custom"}
|
| 97 |
+
|
| 98 |
+
_TOOLS = {
|
| 99 |
+
"llm": [
|
| 100 |
+
{"name": "Ollama", "what": "Type one line; it downloads and runs the model for you.",
|
| 101 |
+
"install": "Get it from ollama.com", "tag": "Easiest"},
|
| 102 |
+
{"name": "LM Studio", "what": "A point-and-click app with a chat window, no commands.",
|
| 103 |
+
"install": "Download from lmstudio.ai", "tag": "Easy"},
|
| 104 |
+
{"name": "llama.cpp", "what": "The lightweight engine under the hood. Runs GGUF files directly.",
|
| 105 |
+
"install": "Releases on GitHub", "tag": "Advanced"},
|
| 106 |
+
],
|
| 107 |
+
"vision": [
|
| 108 |
+
{"name": "Ultralytics", "what": "One pip install, then detect objects from a webcam or file.",
|
| 109 |
+
"install": "pip install ultralytics", "tag": "Easiest"},
|
| 110 |
+
{"name": "PyTorch", "what": "Full control for custom pipelines and training.",
|
| 111 |
+
"install": "pytorch.org", "tag": "Advanced"},
|
| 112 |
+
],
|
| 113 |
+
"imagegen": [
|
| 114 |
+
{"name": "ComfyUI", "what": "Powerful visual node editor for image/video pipelines.",
|
| 115 |
+
"install": "Download from GitHub", "tag": "Moderate"},
|
| 116 |
+
{"name": "diffusers", "what": "Hugging Face's Python library for generation pipelines.",
|
| 117 |
+
"install": "pip install diffusers", "tag": "Moderate"},
|
| 118 |
+
{"name": "Fooocus", "what": "Image generation that 'just works': one folder, double-click.",
|
| 119 |
+
"install": "Download from GitHub", "tag": "Easiest"},
|
| 120 |
+
],
|
| 121 |
+
"audio": [
|
| 122 |
+
{"name": "faster-whisper", "what": "Fast, accurate transcription with a tiny install.",
|
| 123 |
+
"install": "pip install faster-whisper", "tag": "Easiest"},
|
| 124 |
+
{"name": "whisper.cpp", "what": "Runs Whisper efficiently on CPU and small machines.",
|
| 125 |
+
"install": "Build from GitHub", "tag": "Advanced"},
|
| 126 |
+
],
|
| 127 |
+
"embed": [
|
| 128 |
+
{"name": "sentence-transformers", "what": "Turn text into searchable vectors in a few lines.",
|
| 129 |
+
"install": "pip install sentence-transformers", "tag": "Easiest"},
|
| 130 |
+
{"name": "Chroma", "what": "A simple local database to store and search those vectors.",
|
| 131 |
+
"install": "pip install chromadb", "tag": "Easy"},
|
| 132 |
+
],
|
| 133 |
+
"data": [
|
| 134 |
+
{"name": "Python + pip", "what": "These models ship as small Python packages.",
|
| 135 |
+
"install": "pip install (see the model card)", "tag": "Easiest"},
|
| 136 |
+
],
|
| 137 |
+
}
|
| 138 |
+
_TOOLS["vlm"] = _TOOLS["llm"]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# --------------------------------------------------------------------------
|
| 142 |
+
# Catalogue access
|
| 143 |
+
# --------------------------------------------------------------------------
|
| 144 |
+
|
| 145 |
+
@lru_cache(maxsize=1)
|
| 146 |
+
def catalogue() -> dict:
|
| 147 |
+
return json.loads(_CATALOGUE_PATH.read_text(encoding="utf-8"))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@lru_cache(maxsize=1)
|
| 151 |
+
def _by_use_case() -> dict:
|
| 152 |
+
out: dict[str, list[dict]] = {}
|
| 153 |
+
for e in catalogue()["entries"]:
|
| 154 |
+
if e["family"] in ("llm", "vlm"):
|
| 155 |
+
ucs = list(_TEXT_UCS) if e["family"] == "llm" else ["vlm"]
|
| 156 |
+
else:
|
| 157 |
+
ucs = e.get("use_cases", [])
|
| 158 |
+
for uc in ucs:
|
| 159 |
+
out.setdefault(uc, []).append(e)
|
| 160 |
+
for uc in out:
|
| 161 |
+
out[uc].sort(key=lambda e: e.get("params_b", 0), reverse=True)
|
| 162 |
+
return out
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def catalogue_date() -> str:
|
| 166 |
+
return catalogue().get("generated_at", "")[:10]
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# --------------------------------------------------------------------------
|
| 170 |
+
# Memory math
|
| 171 |
+
# --------------------------------------------------------------------------
|
| 172 |
+
|
| 173 |
+
# Fallback architecture shapes by parameter count (conservative typicals),
|
| 174 |
+
# used only when a gated repo hides its config.json.
|
| 175 |
+
_ARCH_FALLBACK = [
|
| 176 |
+
(1.5, 24, 2048), (4.5, 28, 3072), (9.0, 32, 4096),
|
| 177 |
+
(16.0, 40, 5120), (40.0, 48, 6656), (1e9, 80, 8192),
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _kv_gb(entry: dict, ctx: int) -> tuple[float, bool]:
|
| 182 |
+
"""KV-cache GB for `ctx` tokens. Returns (gb, exact?)."""
|
| 183 |
+
ctx = min(ctx, entry.get("context_len") or ctx)
|
| 184 |
+
arch = entry.get("arch")
|
| 185 |
+
if arch:
|
| 186 |
+
per_layer = arch["hidden"] * arch["n_kv_heads"] / arch["n_heads"]
|
| 187 |
+
return 2 * arch["n_layers"] * per_layer * ctx * 2 / 1e9, True
|
| 188 |
+
params = entry.get("params_b", 4.0)
|
| 189 |
+
for cap, layers, hidden in _ARCH_FALLBACK:
|
| 190 |
+
if params <= cap:
|
| 191 |
+
return 2 * layers * hidden * ctx * 2 * 0.30 / 1e9, False
|
| 192 |
+
return 1.0, False
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _overhead_gb(weights: float, factor: float) -> float:
|
| 196 |
+
if factor >= 2.0: # training: optimizer state + activations dominate
|
| 197 |
+
return round(_CONFIDENCE_BUFFER_GB + weights * (factor - 1.0), 2)
|
| 198 |
+
return round((_CONFIDENCE_BUFFER_GB + 0.08 * weights) * factor, 2)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _estimate(entry: dict, quant: dict, ctx: int, factor: float) -> dict:
|
| 202 |
+
weights = quant["file_gb"]
|
| 203 |
+
kv, kv_exact = _kv_gb(entry, ctx)
|
| 204 |
+
kv = round(kv, 2)
|
| 205 |
+
overhead = _overhead_gb(weights, factor)
|
| 206 |
+
return {"weights": weights, "kv": kv, "overhead": overhead,
|
| 207 |
+
"total": round(weights + kv + overhead, 2), "kv_exact": kv_exact}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# --------------------------------------------------------------------------
|
| 211 |
+
# Per-entry evaluation
|
| 212 |
+
# --------------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
def _quant_rank(key: str) -> int:
|
| 215 |
+
return _QUANT_ORDER.index(key) if key in _QUANT_ORDER else len(_QUANT_ORDER)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _feel(entry: dict, verdict: str, spec: HardwareSpec) -> str:
|
| 219 |
+
if verdict == "no":
|
| 220 |
+
return "—"
|
| 221 |
+
active = entry.get("active_params_b") or entry.get("params_b", 4)
|
| 222 |
+
if verdict == "tight":
|
| 223 |
+
if entry.get("active_params_b"):
|
| 224 |
+
return f"Usable even part-offloaded (only {entry['active_params_b']:g}B active per word)"
|
| 225 |
+
return "Slow — usable for short tasks, not snappy chat"
|
| 226 |
+
if active <= 4:
|
| 227 |
+
return "Fast — replies feel instant"
|
| 228 |
+
if active <= 14:
|
| 229 |
+
return "Comfortable — quick enough for live chat"
|
| 230 |
+
return "Steady — fine, just not instant on big answers"
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _eval_gguf(entry: dict, spec: HardwareSpec, uc: UC) -> dict:
|
| 234 |
+
"""Verdict for an LLM/VLM entry with a real quant ladder."""
|
| 235 |
+
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
| 236 |
+
quants = sorted(entry.get("quants", []), key=lambda q: _quant_rank(q["key"]))
|
| 237 |
+
ctx, factor = uc.context_tokens, uc.overhead_factor
|
| 238 |
+
|
| 239 |
+
# Fast path: best quality quant >= 4-bit that fits the GPU budget.
|
| 240 |
+
if spec.has_fast_path:
|
| 241 |
+
for q in quants:
|
| 242 |
+
if _quant_rank(q["key"]) > _FOUR_BIT_RANK:
|
| 243 |
+
break # don't call a sub-4-bit squeeze "runs great"
|
| 244 |
+
est = _estimate(entry, q, ctx, factor)
|
| 245 |
+
if est["total"] <= fast * _SAFETY_FILL:
|
| 246 |
+
return {"verdict": "great", "quant": q, "est": est}
|
| 247 |
+
|
| 248 |
+
# Compromise: spill into ordinary RAM, shrinking quality only if needed.
|
| 249 |
+
for qkey in _COMPROMISE_QUANTS:
|
| 250 |
+
q = next((x for x in quants if x["key"] == qkey), None)
|
| 251 |
+
if not q:
|
| 252 |
+
continue
|
| 253 |
+
est = _estimate(entry, q, ctx, factor)
|
| 254 |
+
if est["total"] <= total * _SAFETY_FILL:
|
| 255 |
+
return {"verdict": "tight", "quant": q, "est": est}
|
| 256 |
+
|
| 257 |
+
q = quants[-1] if quants else {"key": "Q4_K_M", "plain": "Balanced (4-bit)",
|
| 258 |
+
"file_gb": entry.get("params_b", 4) * 0.6}
|
| 259 |
+
return {"verdict": "no", "quant": q, "est": _estimate(entry, q, ctx, factor)}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _eval_flat(entry: dict, spec: HardwareSpec, uc: UC) -> dict:
|
| 263 |
+
"""Verdict for a non-GGUF entry with one memory figure."""
|
| 264 |
+
need = round(entry.get("mem_gb", 4.0) * uc.overhead_factor, 2)
|
| 265 |
+
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
| 266 |
+
est = {"weights": need, "kv": 0.0, "overhead": 0.0, "total": need, "kv_exact": False}
|
| 267 |
+
setting = {"key": "full", "plain": "Full model", "file_gb": need}
|
| 268 |
+
if spec.has_fast_path and need <= fast * _SAFETY_FILL:
|
| 269 |
+
return {"verdict": "great", "quant": setting, "est": est}
|
| 270 |
+
# Image/video generation without a GPU is minutes-per-image: say so.
|
| 271 |
+
if entry["family"] == "imagegen" and not spec.has_fast_path and need > 4:
|
| 272 |
+
return {"verdict": "no", "quant": setting, "est": est}
|
| 273 |
+
if need <= total * _SAFETY_FILL:
|
| 274 |
+
return {"verdict": "tight", "quant": setting, "est": est}
|
| 275 |
+
return {"verdict": "no", "quant": setting, "est": est}
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _evaluate(entry: dict, spec: HardwareSpec, uc: UC) -> dict:
|
| 279 |
+
if entry.get("quants"):
|
| 280 |
+
r = _eval_gguf(entry, spec, uc)
|
| 281 |
+
else:
|
| 282 |
+
r = _eval_flat(entry, spec, uc)
|
| 283 |
+
r["entry"] = entry
|
| 284 |
+
return r
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# --------------------------------------------------------------------------
|
| 288 |
+
# Advise: full UI-shaped result
|
| 289 |
+
# --------------------------------------------------------------------------
|
| 290 |
+
|
| 291 |
+
def _option_json(r: dict, spec: HardwareSpec) -> dict:
|
| 292 |
+
e, v = r["entry"], r["verdict"]
|
| 293 |
+
feel = _feel(e, v, spec)
|
| 294 |
+
if not e.get("quants") and v == "tight" and not spec.has_fast_path:
|
| 295 |
+
feel = "Runs on the processor — slow but workable"
|
| 296 |
+
lic_label = e.get("license", "")
|
| 297 |
+
return {
|
| 298 |
+
"verdict": v,
|
| 299 |
+
"model": e["name"],
|
| 300 |
+
"desc": e.get("good_for", ""),
|
| 301 |
+
"setting": r["quant"].get("plain", "Full model"),
|
| 302 |
+
"memory": "Too big" if v == "no" else f"{r['est']['total']:g} GB",
|
| 303 |
+
"feel": feel,
|
| 304 |
+
"params_b": e.get("params_b"),
|
| 305 |
+
"active_params_b": e.get("active_params_b"),
|
| 306 |
+
"url": (e.get("links") or {}).get("hf") or (e.get("links") or {}).get("home", ""),
|
| 307 |
+
"license": lic_label,
|
| 308 |
+
"license_note": e.get("license_note", ""),
|
| 309 |
+
"gated": e.get("gated", False),
|
| 310 |
+
"run": e.get("run", {}),
|
| 311 |
+
"provenance": e.get("provenance", "estimated"),
|
| 312 |
+
"stale": e.get("stale", False),
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _pick_headline(results: list[dict], uc: UC) -> tuple[dict | None, bool]:
|
| 317 |
+
great = [r for r in results if r["verdict"] == "great"]
|
| 318 |
+
tight = [r for r in results if r["verdict"] == "tight"]
|
| 319 |
+
|
| 320 |
+
def params(r):
|
| 321 |
+
return r["entry"].get("params_b", 0)
|
| 322 |
+
|
| 323 |
+
great_ok = [r for r in great if params(r) >= uc.min_b]
|
| 324 |
+
tight_ok = [r for r in tight if params(r) >= uc.min_b]
|
| 325 |
+
if great_ok:
|
| 326 |
+
# Fast-and-capable is the best answer: biggest model that runs great.
|
| 327 |
+
return max(great_ok, key=params), True
|
| 328 |
+
if tight_ok:
|
| 329 |
+
# Compromise: close to the ideal size, not needlessly oversized-and-slow.
|
| 330 |
+
below = [r for r in tight_ok if params(r) <= uc.good_b * 1.5]
|
| 331 |
+
return (max(below, key=params) if below else min(tight_ok, key=params)), True
|
| 332 |
+
if great:
|
| 333 |
+
return max(great, key=params), False
|
| 334 |
+
if tight:
|
| 335 |
+
return min(tight, key=params), False
|
| 336 |
+
return None, False
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _provenance_line(headline: dict | None) -> str:
|
| 340 |
+
if not headline:
|
| 341 |
+
return ""
|
| 342 |
+
e = headline["entry"]
|
| 343 |
+
prov = e.get("provenance", "estimated")
|
| 344 |
+
if prov == "filesize":
|
| 345 |
+
line = ("Model size is the exact file size on Hugging Face. Chat memory and "
|
| 346 |
+
"working space are conservative estimates with a 0.58 GB safety buffer "
|
| 347 |
+
"(the 95% load-success margin fitted from ~19,500 real measurements).")
|
| 348 |
+
if not headline["est"].get("kv_exact"):
|
| 349 |
+
line += " This repo hides its exact shape, so chat memory is estimated from its size."
|
| 350 |
+
return line
|
| 351 |
+
if prov == "vendor":
|
| 352 |
+
return "The memory figure is the maker's own published number."
|
| 353 |
+
if prov == "community":
|
| 354 |
+
return "The memory figure is community-reported, not vendor-published — treat it as a good estimate."
|
| 355 |
+
return "The memory figure is estimated from the model's size — conservative, not measured."
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
| 359 |
+
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
|
| 360 |
+
candidates = _by_use_case().get(uc.key, [])
|
| 361 |
+
results = [_evaluate(e, spec, uc) for e in candidates]
|
| 362 |
+
|
| 363 |
+
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
| 364 |
+
headline, meets_goal = _pick_headline(results, uc)
|
| 365 |
+
|
| 366 |
+
options = [_option_json(r, spec) for r in results]
|
| 367 |
+
|
| 368 |
+
if headline:
|
| 369 |
+
e, est, q = headline["entry"], headline["est"], headline["quant"]
|
| 370 |
+
hv = headline["verdict"]
|
| 371 |
+
need = est["total"]
|
| 372 |
+
where = ("on your Mac" if spec.is_apple_silicon and hv == "great" else
|
| 373 |
+
"on your graphics card" if hv == "great" and spec.has_fast_path else
|
| 374 |
+
"using your computer's memory" if hv == "tight" else "")
|
| 375 |
+
if hv == "great":
|
| 376 |
+
head_text = f"Yes, you can run {e['name']} {where}, today."
|
| 377 |
+
else:
|
| 378 |
+
head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
|
| 379 |
+
if e.get("quants"):
|
| 380 |
+
detail = (
|
| 381 |
+
f"For this goal, the honest pick is <b>{e['name']}</b> at the "
|
| 382 |
+
f"<b>{q.get('plain', q['key'])}</b> setting. {e.get('good_for','')} "
|
| 383 |
+
f"It needs about <b>{need:g} GB</b> "
|
| 384 |
+
f"(the model file is {est['weights']:g} GB — exact size on Hugging Face — "
|
| 385 |
+
f"plus {est['kv']:g} GB chat memory and {est['overhead']:g} GB working space), "
|
| 386 |
+
f"and you have roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
|
| 387 |
+
)
|
| 388 |
+
else:
|
| 389 |
+
detail = (
|
| 390 |
+
f"For this goal, the honest pick is <b>{e['name']}</b>. "
|
| 391 |
+
f"{e.get('good_for','')} It needs about <b>{need:g} GB</b>, and you have "
|
| 392 |
+
f"roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
|
| 393 |
+
)
|
| 394 |
+
model_part, work_part = est["weights"], round(need - est["weights"], 2)
|
| 395 |
+
else:
|
| 396 |
+
hv = "no"
|
| 397 |
+
smallest = min(results, key=lambda r: r["est"]["total"], default=None)
|
| 398 |
+
need = smallest["est"]["total"] if smallest else 1.0
|
| 399 |
+
head_text = "This goal is a stretch on this machine. Here's the honest picture."
|
| 400 |
+
detail = (
|
| 401 |
+
f"Even the lightest option here needs about <b>{need:g} GB</b>, but this "
|
| 402 |
+
f"machine can offer only about <b>{total:g} GB</b> once the operating system "
|
| 403 |
+
f"has its share. That's not a failure — small computers just have small "
|
| 404 |
+
f"budgets. Adding memory, or a free cloud notebook, would open this up."
|
| 405 |
+
)
|
| 406 |
+
model_part, work_part = round(need * 0.8, 2), round(need * 0.2, 2)
|
| 407 |
+
|
| 408 |
+
note_bits = []
|
| 409 |
+
if headline and not meets_goal:
|
| 410 |
+
note_bits.append(
|
| 411 |
+
f"This is the best this machine can do, but it's on the small side for "
|
| 412 |
+
f"{uc.plain_name.lower()} — treat results as 'okay', not great.")
|
| 413 |
+
if uc.note:
|
| 414 |
+
note_bits.append(uc.note)
|
| 415 |
+
if headline and headline["entry"].get("mem_note"):
|
| 416 |
+
note_bits.append(headline["entry"]["mem_note"])
|
| 417 |
+
if headline and headline["entry"].get("license_note"):
|
| 418 |
+
note_bits.append(headline["entry"]["license_note"])
|
| 419 |
+
if headline and headline["entry"].get("gated"):
|
| 420 |
+
note_bits.append("This model is gated: accept its terms on Hugging Face once before downloading.")
|
| 421 |
+
|
| 422 |
+
scale = max(total, need, 1) * 1.05
|
| 423 |
+
gauge = {
|
| 424 |
+
"need_gb": f"{need:g} GB needed",
|
| 425 |
+
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
| 426 |
+
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
|
| 427 |
+
"mark_pct": round(min(fast / scale, 1.0) * 100, 1),
|
| 428 |
+
"breakdown": [
|
| 429 |
+
{"label": f"Model {model_part:g} GB", "color": _C_MODEL},
|
| 430 |
+
{"label": f"Chat memory + working space {work_part:g} GB", "color": _C_WORK},
|
| 431 |
+
],
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
if uc.family == "llm":
|
| 435 |
+
tools = [{"name": r.name, "what": r.plain_what, "install": r.install_hint,
|
| 436 |
+
"tag": r.difficulty} for r in pick_runtimes(spec)]
|
| 437 |
+
else:
|
| 438 |
+
tools = _TOOLS.get(uc.family, [])
|
| 439 |
+
|
| 440 |
+
commands = {"intro": "These get you running in minutes — real commands for the exact pick above.",
|
| 441 |
+
"items": []}
|
| 442 |
+
if headline:
|
| 443 |
+
run = headline["entry"].get("run", {})
|
| 444 |
+
if run.get("ollama"):
|
| 445 |
+
commands["items"].append({"label": "Easy way (Ollama)", "code": run["ollama"]})
|
| 446 |
+
if run.get("llamacpp"):
|
| 447 |
+
commands["items"].append({"label": "Power way (llama.cpp)", "code": run["llamacpp"]})
|
| 448 |
+
if run.get("pip"):
|
| 449 |
+
commands["items"].append({"label": "Install", "code": run["pip"]})
|
| 450 |
+
|
| 451 |
+
return {
|
| 452 |
+
"catalogue_version": catalogue_date(),
|
| 453 |
+
"verdict": hv,
|
| 454 |
+
"verdict_word": _VERDICT_WORD[hv],
|
| 455 |
+
"headline": head_text,
|
| 456 |
+
"detail": detail,
|
| 457 |
+
"note": " ".join(note_bits),
|
| 458 |
+
"gauge": gauge,
|
| 459 |
+
"options": options,
|
| 460 |
+
"tools": tools,
|
| 461 |
+
"commands": commands,
|
| 462 |
+
"provenance": _provenance_line(headline),
|
| 463 |
+
"meets_goal": meets_goal,
|
| 464 |
+
"use_case": uc.plain_name,
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
# --------------------------------------------------------------------------
|
| 469 |
+
# Reverse mode: "what machine do I need for X?"
|
| 470 |
+
# --------------------------------------------------------------------------
|
| 471 |
+
|
| 472 |
+
# Ladders are cheap -> expensive. Budget hints are rough 2026 street prices for
|
| 473 |
+
# a whole sensible build, shown as guidance, not gospel.
|
| 474 |
+
_PC_LADDER = [
|
| 475 |
+
("Any old laptop (8 GB RAM, no GPU)", dict(ram_gb=8, vram_gb=0, vendor="none"), "what you may already own"),
|
| 476 |
+
("16 GB RAM laptop, no GPU", dict(ram_gb=16, vram_gb=0, vendor="none"), "~$500"),
|
| 477 |
+
("16 GB RAM + RTX 4060 (8 GB)", dict(ram_gb=16, vram_gb=8, vendor="nvidia"), "~$800"),
|
| 478 |
+
("16 GB RAM + RTX 3060 (12 GB)", dict(ram_gb=16, vram_gb=12, vendor="nvidia"), "~$900"),
|
| 479 |
+
("32 GB RAM + RTX 5070 (12 GB)", dict(ram_gb=32, vram_gb=12, vendor="nvidia"), "~$1,300"),
|
| 480 |
+
("32 GB RAM + RTX 5070 Ti (16 GB)", dict(ram_gb=32, vram_gb=16, vendor="nvidia"), "~$1,600"),
|
| 481 |
+
("32 GB RAM + RTX 4090 (24 GB)", dict(ram_gb=32, vram_gb=24, vendor="nvidia"), "~$2,500"),
|
| 482 |
+
("64 GB RAM + RTX 5090 (32 GB)", dict(ram_gb=64, vram_gb=32, vendor="nvidia"), "~$3,500+"),
|
| 483 |
+
]
|
| 484 |
+
_MAC_LADDER = [
|
| 485 |
+
("Mac with 16 GB unified memory", dict(ram_gb=16), "~$1,000"),
|
| 486 |
+
("Mac with 24 GB unified memory", dict(ram_gb=24), "~$1,400"),
|
| 487 |
+
("Mac with 32 GB unified memory", dict(ram_gb=32), "~$1,800"),
|
| 488 |
+
("Mac with 64 GB unified memory", dict(ram_gb=64), "~$2,800"),
|
| 489 |
+
("Mac with 128 GB unified memory", dict(ram_gb=128), "~$4,500+"),
|
| 490 |
+
]
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def _spec_for_tier(kind: str, hw: dict) -> HardwareSpec:
|
| 494 |
+
if kind == "mac":
|
| 495 |
+
return HardwareSpec(os="macos", ram_gb=hw["ram_gb"], gpu_vendor="apple",
|
| 496 |
+
is_apple_silicon=True, form_factor="mac")
|
| 497 |
+
return HardwareSpec(os="windows", ram_gb=hw["ram_gb"],
|
| 498 |
+
gpu_vendor=hw.get("vendor", "none"),
|
| 499 |
+
vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def min_specs(usecase: str) -> dict:
|
| 503 |
+
"""For a goal: cheapest tier that works at all, the comfortable tier, and
|
| 504 |
+
what each would actually run. Pure engine inversion — fully offline."""
|
| 505 |
+
uc = USE_CASES.get(usecase, USE_CASES["chat"])
|
| 506 |
+
|
| 507 |
+
def walk(kind, ladder):
|
| 508 |
+
minimum = comfortable = None
|
| 509 |
+
for label, hw, price in ladder:
|
| 510 |
+
spec = _spec_for_tier(kind, hw)
|
| 511 |
+
res = advise_real({"usecase": uc.key}, spec)
|
| 512 |
+
great = [o for o in res["options"] if o["verdict"] == "great"]
|
| 513 |
+
fits = [o for o in res["options"] if o["memory"] != "Too big"]
|
| 514 |
+
best = (max(great, key=lambda o: o.get("params_b") or 0) if great
|
| 515 |
+
else (max(fits, key=lambda o: o.get("params_b") or 0) if fits else None))
|
| 516 |
+
tier = {"label": label, "price": price,
|
| 517 |
+
"runs": best["model"] if best else "",
|
| 518 |
+
"verdict": res["verdict"]}
|
| 519 |
+
if minimum is None and res["verdict"] in ("great", "tight") and res["meets_goal"]:
|
| 520 |
+
minimum = tier
|
| 521 |
+
if comfortable is None and res["verdict"] == "great" and res["meets_goal"]:
|
| 522 |
+
comfortable = tier
|
| 523 |
+
if minimum and comfortable:
|
| 524 |
+
break
|
| 525 |
+
return minimum, comfortable
|
| 526 |
+
|
| 527 |
+
pc_min, pc_comfy = walk("pc", _PC_LADDER)
|
| 528 |
+
mac_min, mac_comfy = walk("mac", _MAC_LADDER)
|
| 529 |
+
return {
|
| 530 |
+
"use_case": uc.plain_name,
|
| 531 |
+
"catalogue_version": catalogue_date(),
|
| 532 |
+
"note": uc.note,
|
| 533 |
+
"pc": {"minimum": pc_min, "comfortable": pc_comfy},
|
| 534 |
+
"mac": {"minimum": mac_min, "comfortable": mac_comfy},
|
| 535 |
+
"disclaimer": ("Price hints are rough 2026 street prices for a sensible whole "
|
| 536 |
+
"build — they vary a lot by region and second-hand luck. The "
|
| 537 |
+
"memory math is the same conservative engine as the main check."),
|
| 538 |
+
}
|
static/app.js
CHANGED
|
@@ -24,6 +24,7 @@ const USE_CASES = [
|
|
| 24 |
{ id: "translate", icon: "translate", label: "Translation" },
|
| 25 |
]},
|
| 26 |
{ icon: "cat-vision", name: "See & understand images", items: [
|
|
|
|
| 27 |
{ id: "detect", icon: "detect", label: "Object detection (YOLO)" },
|
| 28 |
{ id: "segment", icon: "segment", label: "Image segmentation" },
|
| 29 |
{ id: "pose", icon: "pose", label: "Pose / 6-DoF (FoundationPose)" },
|
|
@@ -73,9 +74,47 @@ const GPUS = {
|
|
| 73 |
};
|
| 74 |
|
| 75 |
const $ = (s) => document.querySelector(s);
|
| 76 |
-
const state = { computer: "Windows laptop", provider: "none", priority: "balanced", usecase: "chat", checked: false };
|
| 77 |
let lastAdvice = null; // the most recent /api/advise result — facts the model explains
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
// ---- Build the use-case picker -------------------------------------------
|
| 80 |
function buildPicker() {
|
| 81 |
const wrap = $("#usecase-picker");
|
|
@@ -164,6 +203,7 @@ function gather() {
|
|
| 164 |
usecase: state.usecase,
|
| 165 |
custom: $("#custom-uc").value.trim(),
|
| 166 |
priority: state.priority,
|
|
|
|
| 167 |
};
|
| 168 |
}
|
| 169 |
|
|
@@ -176,19 +216,100 @@ function maybeLiveUpdate() {
|
|
| 176 |
|
| 177 |
async function check() {
|
| 178 |
state.checked = true;
|
|
|
|
| 179 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
const res = await fetch("/api/advise", {
|
| 181 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 182 |
-
body: JSON.stringify(
|
| 183 |
});
|
| 184 |
render(await res.json());
|
|
|
|
| 185 |
} catch (e) {
|
| 186 |
$("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div>
|
| 187 |
-
<p>Couldn't reach the advisor
|
| 188 |
hydrate($("#results"));
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
// ---- Render results -------------------------------------------------------
|
| 193 |
const VMAP = {
|
| 194 |
great: { cls: "var(--ok)", soft: "var(--ok-soft)", word: "Runs great", em: "✓" },
|
|
@@ -202,12 +323,23 @@ function render(d) {
|
|
| 202 |
const g = d.gauge || {};
|
| 203 |
$("#cat-version").textContent = d.catalogue_version || "—";
|
| 204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
const opts = (d.options || []).map(o => {
|
| 206 |
const ov = VMAP[o.verdict] || VMAP.tight;
|
|
|
|
|
|
|
| 207 |
return `<div class="opt" style="--status:${ov.cls};--status-soft:${ov.soft}">
|
| 208 |
<div class="vdot">${ov.em}</div>
|
| 209 |
-
<div><div class="name">${o
|
| 210 |
-
<div class="meta"><b>${o.memory}</b><div class="feel">${o.setting} ·
|
| 211 |
</div>`;
|
| 212 |
}).join("");
|
| 213 |
|
|
@@ -227,6 +359,7 @@ function render(d) {
|
|
| 227 |
|
| 228 |
$("#results").innerHTML = `
|
| 229 |
<div class="reveal">
|
|
|
|
| 230 |
<div class="verdict" style="--status:${v.cls};--status-soft:${v.soft}">
|
| 231 |
<span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span>
|
| 232 |
<h2>${d.headline || ""}</h2>
|
|
@@ -246,9 +379,10 @@ function render(d) {
|
|
| 246 |
<span class="item marker">Fast: ${g.fast_gb}</span>
|
| 247 |
<span class="item marker">Total: ${g.total_gb}</span>
|
| 248 |
</div>
|
|
|
|
| 249 |
</div>` : ""}
|
| 250 |
|
| 251 |
-
${opts ? `<div class="section-title">What you can run <span class="sub">
|
| 252 |
<div class="opt-grid">${opts}</div>` : ""}
|
| 253 |
|
| 254 |
${tools ? `<div class="section-title">How to actually run it</div>
|
|
@@ -364,14 +498,16 @@ async function callAskRaw(question, facts) {
|
|
| 364 |
function init() {
|
| 365 |
hydrate(document);
|
| 366 |
buildPicker();
|
|
|
|
| 367 |
wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); });
|
| 368 |
wireSegmented("#provider-seg", "provider", fillGpu);
|
| 369 |
wireSegmented("#priority-seg", "priority");
|
| 370 |
-
["#ram","#gpu","#vram","#custom-uc"].forEach(s => $(s).addEventListener("change", maybeLiveUpdate));
|
| 371 |
$("#paste").addEventListener("input", maybeLiveUpdate);
|
| 372 |
$("#check-btn").addEventListener("click", check);
|
| 373 |
fillGpu();
|
| 374 |
$("#find-specs-body").innerHTML = findSpecsText();
|
|
|
|
| 375 |
// Pre-filled share/preview links: /?go renders results immediately.
|
| 376 |
if (new URLSearchParams(location.search).has("go")) check();
|
| 377 |
}
|
|
|
|
| 24 |
{ id: "translate", icon: "translate", label: "Translation" },
|
| 25 |
]},
|
| 26 |
{ icon: "cat-vision", name: "See & understand images", items: [
|
| 27 |
+
{ id: "vlm", icon: "classify", label: "Chat about images (VLM)" },
|
| 28 |
{ id: "detect", icon: "detect", label: "Object detection (YOLO)" },
|
| 29 |
{ id: "segment", icon: "segment", label: "Image segmentation" },
|
| 30 |
{ id: "pose", icon: "pose", label: "Pose / 6-DoF (FoundationPose)" },
|
|
|
|
| 74 |
};
|
| 75 |
|
| 76 |
const $ = (s) => document.querySelector(s);
|
| 77 |
+
const state = { mode: "have", computer: "Windows laptop", provider: "none", priority: "balanced", usecase: "chat", checked: false };
|
| 78 |
let lastAdvice = null; // the most recent /api/advise result — facts the model explains
|
| 79 |
|
| 80 |
+
// ---- Buy-vs-check mode -----------------------------------------------------
|
| 81 |
+
function applyMode() {
|
| 82 |
+
const buy = state.mode === "buy";
|
| 83 |
+
["#machine-step", "#priority-step", "#find-specs"].forEach(s => {
|
| 84 |
+
const el = $(s); if (el) el.style.display = buy ? "none" : "";
|
| 85 |
+
});
|
| 86 |
+
const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : "";
|
| 87 |
+
$("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
|
| 88 |
+
+ '<span class="ic" data-ic="arrow"></span>';
|
| 89 |
+
hydrate($("#check-btn"));
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ---- Best-effort hardware hints from the browser (honest: vendor + floor) --
|
| 93 |
+
async function detectHardware() {
|
| 94 |
+
const bits = [];
|
| 95 |
+
try {
|
| 96 |
+
if (navigator.gpu) {
|
| 97 |
+
const ad = await navigator.gpu.requestAdapter();
|
| 98 |
+
const v = ((ad && ad.info && (ad.info.vendor || ad.info.description)) || "").toLowerCase();
|
| 99 |
+
for (const vendor of ["nvidia", "amd", "apple", "intel"]) {
|
| 100 |
+
if (v.includes(vendor)) {
|
| 101 |
+
state.provider = vendor;
|
| 102 |
+
setActive("#provider-seg", vendor);
|
| 103 |
+
fillGpu();
|
| 104 |
+
bits.push(`a ${vendor.toUpperCase().replace("APPLE","Apple")} GPU`);
|
| 105 |
+
break;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
} catch (e) { /* detection is best-effort only */ }
|
| 110 |
+
if (navigator.deviceMemory) bits.push(`at least ${navigator.deviceMemory} GB of RAM`);
|
| 111 |
+
if (bits.length) {
|
| 112 |
+
const h = $("#detect-hint");
|
| 113 |
+
h.style.display = "";
|
| 114 |
+
h.textContent = `Your browser reports ${bits.join(" and ")} — browsers can't see exact specs, so please confirm below.`;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
// ---- Build the use-case picker -------------------------------------------
|
| 119 |
function buildPicker() {
|
| 120 |
const wrap = $("#usecase-picker");
|
|
|
|
| 203 |
usecase: state.usecase,
|
| 204 |
custom: $("#custom-uc").value.trim(),
|
| 205 |
priority: state.priority,
|
| 206 |
+
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
| 207 |
};
|
| 208 |
}
|
| 209 |
|
|
|
|
| 216 |
|
| 217 |
async function check() {
|
| 218 |
state.checked = true;
|
| 219 |
+
const payload = gather();
|
| 220 |
try {
|
| 221 |
+
if (state.mode === "buy") {
|
| 222 |
+
const res = await fetch("/api/minspecs", {
|
| 223 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 224 |
+
body: JSON.stringify({ usecase: state.usecase }),
|
| 225 |
+
});
|
| 226 |
+
renderBuy(await res.json());
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
const res = await fetch("/api/advise", {
|
| 230 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 231 |
+
body: JSON.stringify(payload),
|
| 232 |
});
|
| 233 |
render(await res.json());
|
| 234 |
+
if (payload.repo) lookupRepo(payload); // optional live check, appended on top
|
| 235 |
} catch (e) {
|
| 236 |
$("#results").innerHTML = `<div class="empty-state"><div class="big"><span class="ic" data-ic="monitor"></span></div>
|
| 237 |
+
<p>Couldn't reach the advisor: ${e && e.message ? e.message : e}</p></div>`;
|
| 238 |
hydrate($("#results"));
|
| 239 |
}
|
| 240 |
}
|
| 241 |
|
| 242 |
+
// ---- Live single-model lookup (the one online feature, labelled as such) ---
|
| 243 |
+
async function lookupRepo(payload) {
|
| 244 |
+
const holder = $("#lookup-result");
|
| 245 |
+
if (!holder) return;
|
| 246 |
+
holder.innerHTML = `<div class="ans-loading"><span class="spinner"></span>Looking up ${payload.repo} on Hugging Face…</div>`;
|
| 247 |
+
try {
|
| 248 |
+
const res = await fetch("/api/lookup", {
|
| 249 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 250 |
+
body: JSON.stringify(payload),
|
| 251 |
+
});
|
| 252 |
+
const d = await res.json();
|
| 253 |
+
if (d.error) {
|
| 254 |
+
holder.innerHTML = `<div class="ans-card ans-error"><h3>Couldn't check that model</h3><p>${d.error}</p></div>`;
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
const o = d.option || {};
|
| 258 |
+
const v = VMAP[o.verdict] || VMAP.tight;
|
| 259 |
+
holder.innerHTML = `
|
| 260 |
+
<div class="lookup-card reveal" style="--status:${v.cls};--status-soft:${v.soft}">
|
| 261 |
+
<div class="lookup-head">
|
| 262 |
+
<span class="badge"><span class="dot"></span>${v.word}</span>
|
| 263 |
+
<span class="live-tag">Live Hugging Face lookup</span>
|
| 264 |
+
</div>
|
| 265 |
+
<p class="lookup-explain">${d.explain || ""}</p>
|
| 266 |
+
<div class="lookup-meta">
|
| 267 |
+
${o.memory && o.memory !== "Too big" ? `<span><b>${o.memory}</b> needed (${o.setting})</span>` : `<span><b>Too big</b> for this machine</span>`}
|
| 268 |
+
${o.url ? `<a href="${o.url}" target="_blank" rel="noopener">View on Hugging Face</a>` : ""}
|
| 269 |
+
</div>
|
| 270 |
+
${o.run && o.run.ollama ? `<div class="cmd-box" style="margin-top:10px"><div class="cmd-label">Run it<button class="copy-btn" data-code="${encodeURIComponent(o.run.ollama)}">Copy</button></div><pre><code>${o.run.ollama}</code></pre></div>` : ""}
|
| 271 |
+
</div>`;
|
| 272 |
+
holder.querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => {
|
| 273 |
+
navigator.clipboard.writeText(decodeURIComponent(b.dataset.code));
|
| 274 |
+
b.textContent = "Copied ✓";
|
| 275 |
+
setTimeout(() => { b.textContent = "Copy"; }, 1500);
|
| 276 |
+
}));
|
| 277 |
+
} catch (e) {
|
| 278 |
+
holder.innerHTML = `<div class="ans-card ans-error"><h3>Lookup failed</h3><p>${e && e.message ? e.message : e}</p></div>`;
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// ---- Buy-advice render ------------------------------------------------------
|
| 283 |
+
function renderBuy(d) {
|
| 284 |
+
const lane = (title, icon, lanes) => {
|
| 285 |
+
const tier = (t, kind) => t ? `
|
| 286 |
+
<div class="tool" style="border-left:3px solid ${kind === "min" ? "var(--warn)" : "var(--ok)"}">
|
| 287 |
+
<div class="tool-head"><span class="tname">${kind === "min" ? "Minimum" : "Comfortable"}</span>
|
| 288 |
+
<span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div>
|
| 289 |
+
<div class="twhat"><b>${t.label}</b></div>
|
| 290 |
+
<div class="twhat">Runs: ${t.runs}${kind === "min" && t.verdict === "tight" ? " (with trade-offs)" : ""}</div>
|
| 291 |
+
</div>` : `
|
| 292 |
+
<div class="tool"><div class="twhat">No tier on this ladder handles it comfortably — this goal wants workstation hardware.</div></div>`;
|
| 293 |
+
return `
|
| 294 |
+
<div class="section-title"><span class="ic" data-ic="${icon}"></span>${title}</div>
|
| 295 |
+
<div class="tool-grid">${tier(lanes.minimum, "min")}${tier(lanes.comfortable, "comfy")}</div>`;
|
| 296 |
+
};
|
| 297 |
+
$("#results").innerHTML = `
|
| 298 |
+
<div class="reveal">
|
| 299 |
+
<div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
|
| 300 |
+
<span class="badge"><span class="dot"></span>Buying advice</span>
|
| 301 |
+
<h2>What you need for ${(d.use_case || "this").toLowerCase()}</h2>
|
| 302 |
+
<p>Two honest tiers per platform: the cheapest setup that genuinely works, and the one that feels good daily.</p>
|
| 303 |
+
${d.note ? `<div class="note">${d.note}</div>` : ""}
|
| 304 |
+
</div>
|
| 305 |
+
${lane("Windows / Linux PC", "brand-windows", d.pc || {})}
|
| 306 |
+
${lane("Mac (Apple Silicon)", "brand-apple", d.mac || {})}
|
| 307 |
+
<p class="cmd-intro" style="margin-top:var(--s-4)">${d.disclaimer || ""}</p>
|
| 308 |
+
</div>`;
|
| 309 |
+
hydrate($("#results"));
|
| 310 |
+
$("#cat-version").textContent = d.catalogue_version || "—";
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
// ---- Render results -------------------------------------------------------
|
| 314 |
const VMAP = {
|
| 315 |
great: { cls: "var(--ok)", soft: "var(--ok-soft)", word: "Runs great", em: "✓" },
|
|
|
|
| 323 |
const g = d.gauge || {};
|
| 324 |
$("#cat-version").textContent = d.catalogue_version || "—";
|
| 325 |
|
| 326 |
+
const licChip = (o) => {
|
| 327 |
+
if (!o.license) return "";
|
| 328 |
+
const note = (o.license_note || "").toLowerCase();
|
| 329 |
+
const warn = note.includes("non-commercial") || note.includes("agpl") || note.includes("research");
|
| 330 |
+
const label = o.license.replace("apache-2.0", "Apache 2.0").replace("mit", "MIT")
|
| 331 |
+
.replace("agpl-3.0", "AGPL").replace("cc-by-nc-4.0", "CC-NC");
|
| 332 |
+
return `<span class="lic${warn ? " warn" : ""}" title="${o.license_note || o.license}">${label}</span>`
|
| 333 |
+
+ (o.gated ? `<span class="lic gatechip" title="Accept the terms on Hugging Face once before downloading">gated</span>` : "");
|
| 334 |
+
};
|
| 335 |
const opts = (d.options || []).map(o => {
|
| 336 |
const ov = VMAP[o.verdict] || VMAP.tight;
|
| 337 |
+
const name = o.url
|
| 338 |
+
? `<a href="${o.url}" target="_blank" rel="noopener">${o.model}</a>` : o.model;
|
| 339 |
return `<div class="opt" style="--status:${ov.cls};--status-soft:${ov.soft}">
|
| 340 |
<div class="vdot">${ov.em}</div>
|
| 341 |
+
<div><div class="name">${name}${licChip(o)}</div><div class="desc">${o.desc}</div></div>
|
| 342 |
+
<div class="meta"><b>${o.memory}</b><div class="feel">${o.setting}${o.feel && o.feel !== "—" ? " · " + o.feel : ""}</div></div>
|
| 343 |
</div>`;
|
| 344 |
}).join("");
|
| 345 |
|
|
|
|
| 359 |
|
| 360 |
$("#results").innerHTML = `
|
| 361 |
<div class="reveal">
|
| 362 |
+
<div id="lookup-result"></div>
|
| 363 |
<div class="verdict" style="--status:${v.cls};--status-soft:${v.soft}">
|
| 364 |
<span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span>
|
| 365 |
<h2>${d.headline || ""}</h2>
|
|
|
|
| 379 |
<span class="item marker">Fast: ${g.fast_gb}</span>
|
| 380 |
<span class="item marker">Total: ${g.total_gb}</span>
|
| 381 |
</div>
|
| 382 |
+
${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""}
|
| 383 |
</div>` : ""}
|
| 384 |
|
| 385 |
+
${opts ? `<div class="section-title">What you can run <span class="sub">real models, biggest to smallest — names link to Hugging Face</span></div>
|
| 386 |
<div class="opt-grid">${opts}</div>` : ""}
|
| 387 |
|
| 388 |
${tools ? `<div class="section-title">How to actually run it</div>
|
|
|
|
| 498 |
function init() {
|
| 499 |
hydrate(document);
|
| 500 |
buildPicker();
|
| 501 |
+
wireSegmented("#mode-seg", "mode", applyMode);
|
| 502 |
wireSegmented("#computer-seg", "computer", () => { syncProviderForComputer(); $("#find-specs-body").innerHTML = findSpecsText(); });
|
| 503 |
wireSegmented("#provider-seg", "provider", fillGpu);
|
| 504 |
wireSegmented("#priority-seg", "priority");
|
| 505 |
+
["#ram","#gpu","#vram","#custom-uc","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); });
|
| 506 |
$("#paste").addEventListener("input", maybeLiveUpdate);
|
| 507 |
$("#check-btn").addEventListener("click", check);
|
| 508 |
fillGpu();
|
| 509 |
$("#find-specs-body").innerHTML = findSpecsText();
|
| 510 |
+
detectHardware();
|
| 511 |
// Pre-filled share/preview links: /?go renders results immediately.
|
| 512 |
if (new URLSearchParams(location.search).has("go")) check();
|
| 513 |
}
|
static/index.html
CHANGED
|
@@ -32,9 +32,18 @@
|
|
| 32 |
<div class="layout">
|
| 33 |
<!-- ============================== INPUTS ============================== -->
|
| 34 |
<aside class="panel form-panel">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
<!-- Step 1: machine -->
|
| 36 |
-
<div class="step">
|
| 37 |
<div class="step-head"><span class="step-num">1</span><h2>Your computer</h2></div>
|
|
|
|
| 38 |
|
| 39 |
<div class="field">
|
| 40 |
<span class="label">What kind of computer?</span>
|
|
@@ -94,10 +103,15 @@
|
|
| 94 |
<span class="label">Describe what you want to build</span>
|
| 95 |
<input type="text" id="custom-uc" placeholder="e.g. real-time hand tracking from my webcam" />
|
| 96 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
</div>
|
| 98 |
|
| 99 |
<!-- Step 3: preference -->
|
| 100 |
-
<div class="step">
|
| 101 |
<div class="step-head"><span class="step-num">3</span><h2>What matters most? <span class="optional">(optional)</span></h2></div>
|
| 102 |
<div class="field" style="margin-bottom:0">
|
| 103 |
<div class="segmented" id="priority-seg">
|
|
@@ -144,8 +158,9 @@
|
|
| 144 |
|
| 145 |
<footer class="foot" id="how">
|
| 146 |
<p>FitCheck gives <b>conservative</b> estimates from a transparent rules engine.
|
| 147 |
-
|
| 148 |
-
|
|
|
|
| 149 |
</footer>
|
| 150 |
</main>
|
| 151 |
|
|
|
|
| 32 |
<div class="layout">
|
| 33 |
<!-- ============================== INPUTS ============================== -->
|
| 34 |
<aside class="panel form-panel">
|
| 35 |
+
<!-- Mode: check what I own, or advise what to buy -->
|
| 36 |
+
<div class="field" style="margin-bottom:var(--s-5)">
|
| 37 |
+
<div class="segmented" id="mode-seg">
|
| 38 |
+
<button class="seg-btn active" data-val="have"><span class="ic" data-ic="monitor"></span>Check my computer</button>
|
| 39 |
+
<button class="seg-btn" data-val="buy"><span class="ic" data-ic="search"></span>Help me pick one</button>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
<!-- Step 1: machine -->
|
| 44 |
+
<div class="step" id="machine-step">
|
| 45 |
<div class="step-head"><span class="step-num">1</span><h2>Your computer</h2></div>
|
| 46 |
+
<div class="hint" id="detect-hint" style="display:none; margin-bottom:var(--s-3)"></div>
|
| 47 |
|
| 48 |
<div class="field">
|
| 49 |
<span class="label">What kind of computer?</span>
|
|
|
|
| 103 |
<span class="label">Describe what you want to build</span>
|
| 104 |
<input type="text" id="custom-uc" placeholder="e.g. real-time hand tracking from my webcam" />
|
| 105 |
</div>
|
| 106 |
+
<div class="field" id="repo-field" style="margin-top:var(--s-4); margin-bottom:0">
|
| 107 |
+
<span class="label">Have a specific model in mind? <span class="optional">(optional)</span></span>
|
| 108 |
+
<input type="text" id="repo-check" placeholder="Paste a Hugging Face model id or link, e.g. lerobot/smolvla_base" />
|
| 109 |
+
<div class="hint">Checks that exact model with a live Hugging Face lookup (the rest of FitCheck runs fully offline).</div>
|
| 110 |
+
</div>
|
| 111 |
</div>
|
| 112 |
|
| 113 |
<!-- Step 3: preference -->
|
| 114 |
+
<div class="step" id="priority-step">
|
| 115 |
<div class="step-head"><span class="step-num">3</span><h2>What matters most? <span class="optional">(optional)</span></h2></div>
|
| 116 |
<div class="field" style="margin-bottom:0">
|
| 117 |
<div class="segmented" id="priority-seg">
|
|
|
|
| 158 |
|
| 159 |
<footer class="foot" id="how">
|
| 160 |
<p>FitCheck gives <b>conservative</b> estimates from a transparent rules engine.
|
| 161 |
+
Model sizes are the <b>exact file sizes on Hugging Face</b>; the rest is careful, deliberately
|
| 162 |
+
pessimistic math. It would rather under-promise than over-promise. Real speed depends on your exact chip, drivers and settings.</p>
|
| 163 |
+
<p style="margin-top:8px">Catalogue of real models last verified <b id="cat-version">—</b>. Built for the Hugging Face Build Small hackathon.</p>
|
| 164 |
</footer>
|
| 165 |
</main>
|
| 166 |
|
static/style.css
CHANGED
|
@@ -326,6 +326,20 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 326 |
display: grid; place-items: center; font-size: 15px; font-weight: 700;
|
| 327 |
}
|
| 328 |
.opt .name { font-weight: 700; font-family: var(--font-head); font-size: 15px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
.opt .desc { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
| 330 |
.opt .meta { text-align: right; font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
| 331 |
.opt .meta b { color: var(--text-primary); font-family: var(--font-head); }
|
|
@@ -363,6 +377,21 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 363 |
.copy-btn:hover { color: var(--text-primary); border-color: var(--border-hi); }
|
| 364 |
.copy-btn.done { color: var(--ok); border-color: var(--ok); }
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
/* Ask a follow-up (the model brick) */
|
| 367 |
.ask-row { display: flex; gap: var(--s-2); }
|
| 368 |
.ask-row input {
|
|
|
|
| 326 |
display: grid; place-items: center; font-size: 15px; font-weight: 700;
|
| 327 |
}
|
| 328 |
.opt .name { font-weight: 700; font-family: var(--font-head); font-size: 15px; }
|
| 329 |
+
.opt .name a { color: var(--text-primary); }
|
| 330 |
+
.opt .name a:hover { color: var(--accent); text-decoration: none; }
|
| 331 |
+
.lic {
|
| 332 |
+
display: inline-block; margin-left: 8px; padding: 1px 8px; vertical-align: 1px;
|
| 333 |
+
border-radius: var(--r-pill); border: 1px solid var(--border-hi);
|
| 334 |
+
font: 600 10.5px/1.6 var(--font-body); letter-spacing: .02em;
|
| 335 |
+
color: var(--text-muted); text-transform: uppercase;
|
| 336 |
+
}
|
| 337 |
+
.lic.warn { color: var(--warn); border-color: var(--warn); opacity: .9; }
|
| 338 |
+
.lic.gatechip { color: var(--accent); border-color: var(--accent); }
|
| 339 |
+
.prov {
|
| 340 |
+
margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border);
|
| 341 |
+
font-size: 12px; color: var(--text-muted); line-height: 1.55;
|
| 342 |
+
}
|
| 343 |
.opt .desc { font-size: 13px; color: var(--text-muted); margin-top: 2px; }
|
| 344 |
.opt .meta { text-align: right; font-size: 13px; color: var(--text-secondary); white-space: nowrap; }
|
| 345 |
.opt .meta b { color: var(--text-primary); font-family: var(--font-head); }
|
|
|
|
| 377 |
.copy-btn:hover { color: var(--text-primary); border-color: var(--border-hi); }
|
| 378 |
.copy-btn.done { color: var(--ok); border-color: var(--ok); }
|
| 379 |
|
| 380 |
+
/* Live single-model lookup card */
|
| 381 |
+
.lookup-card {
|
| 382 |
+
border: 1px solid var(--border); border-left: 4px solid var(--status, var(--accent));
|
| 383 |
+
border-radius: var(--r-md); background: var(--bg-raised);
|
| 384 |
+
padding: var(--s-4) var(--s-5); margin-bottom: var(--s-5);
|
| 385 |
+
}
|
| 386 |
+
.lookup-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--s-2); }
|
| 387 |
+
.live-tag {
|
| 388 |
+
font: 600 11px/1 var(--font-body); text-transform: uppercase; letter-spacing: .05em;
|
| 389 |
+
color: var(--accent); border: 1px dashed var(--accent); border-radius: var(--r-pill);
|
| 390 |
+
padding: 4px 10px; opacity: .85;
|
| 391 |
+
}
|
| 392 |
+
.lookup-explain { color: var(--text-secondary); font-size: 14.5px; }
|
| 393 |
+
.lookup-meta { display: flex; gap: var(--s-4); margin-top: var(--s-2); font-size: 13.5px; color: var(--text-secondary); }
|
| 394 |
+
|
| 395 |
/* Ask a follow-up (the model brick) */
|
| 396 |
.ask-row { display: flex; gap: var(--s-2); }
|
| 397 |
.ask-row input {
|