Full model-URL breakdowns, gauge clarity, CPU honesty, multi-platform buying guide, chart tooltips, runner icon
Browse files- app.py +2 -1
- engine/finetune.py +8 -0
- engine/hub_lookup.py +62 -31
- engine/real_advisor.py +152 -48
- static/app.js +96 -56
- static/icons.js +1 -0
- static/index.html +3 -7
- static/style.css +54 -1
app.py
CHANGED
|
@@ -62,11 +62,12 @@ def api_advise(payload: AdviseIn):
|
|
| 62 |
class MinSpecsIn(BaseModel):
|
| 63 |
usecase: str = "chat"
|
| 64 |
usecases: list[str] | None = None # multi-goal: union of requirements
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
@app.post("/api/minspecs")
|
| 68 |
def api_minspecs(payload: MinSpecsIn):
|
| 69 |
-
return min_specs(payload.usecases or [payload.usecase])
|
| 70 |
|
| 71 |
|
| 72 |
class LookupIn(AdviseIn):
|
|
|
|
| 62 |
class MinSpecsIn(BaseModel):
|
| 63 |
usecase: str = "chat"
|
| 64 |
usecases: list[str] | None = None # multi-goal: union of requirements
|
| 65 |
+
mode: str = "run" # "run" = inference, "finetune" = training
|
| 66 |
|
| 67 |
|
| 68 |
@app.post("/api/minspecs")
|
| 69 |
def api_minspecs(payload: MinSpecsIn):
|
| 70 |
+
return min_specs(payload.usecases or [payload.usecase], payload.mode)
|
| 71 |
|
| 72 |
|
| 73 |
class LookupIn(AdviseIn):
|
engine/finetune.py
CHANGED
|
@@ -274,9 +274,17 @@ def _llm_finetune(uc, candidates, spec, focus) -> dict:
|
|
| 274 |
f"path — see the options below."
|
| 275 |
)
|
| 276 |
scale = max(fast or total, need, 1) * 1.05
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
gauge = {
|
| 278 |
"need_gb": f"{need:g} GB needed to train",
|
| 279 |
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
|
|
|
| 280 |
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
|
| 281 |
"mark_pct": round(min((fast or total) / scale, 1.0) * 100, 1),
|
| 282 |
"breakdown": [
|
|
|
|
| 274 |
f"path — see the options below."
|
| 275 |
)
|
| 276 |
scale = max(fast or total, need, 1) * 1.05
|
| 277 |
+
has_fast = spec.has_fast_path
|
| 278 |
+
if spec.is_apple_silicon:
|
| 279 |
+
fast_label, total_label = "GPU can use", "Unified memory"
|
| 280 |
+
elif has_fast:
|
| 281 |
+
fast_label, total_label = "On the GPU (VRAM)", "GPU + system RAM"
|
| 282 |
+
else:
|
| 283 |
+
fast_label, total_label = "", "System RAM (no GPU)"
|
| 284 |
gauge = {
|
| 285 |
"need_gb": f"{need:g} GB needed to train",
|
| 286 |
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
| 287 |
+
"fast_label": fast_label, "total_label": total_label, "has_fast": has_fast,
|
| 288 |
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
|
| 289 |
"mark_pct": round(min((fast or total) / scale, 1.0) * 100, 1),
|
| 290 |
"breakdown": [
|
engine/hub_lookup.py
CHANGED
|
@@ -17,11 +17,54 @@ 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:
|
|
@@ -126,35 +169,23 @@ def lookup(repo_input: str, payload: dict, spec: HardwareSpec) -> dict:
|
|
| 126 |
"explain": explain, "option": opt, "live": via is not None or info is not None,
|
| 127 |
"focus_name": entry["name"], "uc_key": uc_key}
|
| 128 |
|
| 129 |
-
# 3)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
|
| 134 |
f"base model, so an honest estimate isn't possible."}
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
f"known base, so this is raw math from its {params_b:.1f}B parameters "
|
| 149 |
-
f"at a 4-bit setting — an estimate, not a measured figure."),
|
| 150 |
-
"option": {
|
| 151 |
-
"verdict": verdict, "model": repo_id.split("/")[-1],
|
| 152 |
-
"desc": f"{params_b:.1f}B parameters (from its files on Hugging Face)",
|
| 153 |
-
"setting": "Balanced (4-bit)",
|
| 154 |
-
"memory": "Too big" if verdict == "no" else f"{need:g} GB",
|
| 155 |
-
"feel": "", "url": f"https://huggingface.co/{repo_id}",
|
| 156 |
-
"license": "", "license_note": "", "gated": bool(getattr(info, "gated", False)),
|
| 157 |
-
"run": {}, "provenance": "estimated", "stale": False,
|
| 158 |
-
"params_b": round(params_b, 2), "active_params_b": None,
|
| 159 |
-
},
|
| 160 |
-
}
|
|
|
|
| 17 |
|
| 18 |
from .hardware import HardwareSpec
|
| 19 |
from .real_advisor import (
|
| 20 |
+
USE_CASES, _SAFETY_FILL, _evaluate, _option_json, catalogue, advise_real,
|
| 21 |
)
|
| 22 |
|
| 23 |
_RELATION = re.compile(r"^base_model:(finetune|adapter|quantized|merge):(.+)$")
|
| 24 |
|
| 25 |
+
# Hugging Face pipeline_tag -> our use-case key, so a looked-up model is
|
| 26 |
+
# compared against the right slice of the catalogue.
|
| 27 |
+
_PIPE_UC = {
|
| 28 |
+
"text-generation": "chat", "text2text-generation": "chat",
|
| 29 |
+
"image-text-to-text": "vlm", "visual-question-answering": "vlm", "image-to-text": "vlm",
|
| 30 |
+
"automatic-speech-recognition": "stt", "text-to-speech": "tts", "text-to-audio": "music",
|
| 31 |
+
"text-to-image": "imagegen", "image-to-image": "inpaint",
|
| 32 |
+
"object-detection": "detect", "image-segmentation": "segment",
|
| 33 |
+
"image-classification": "classify", "depth-estimation": "depth",
|
| 34 |
+
"feature-extraction": "embed", "sentence-similarity": "embed",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Effective bits-per-weight for a synthesised quant ladder (k-quant layouts).
|
| 38 |
+
_SYNTH_QUANTS = [
|
| 39 |
+
("Q8_0", "Near-full (8-bit)", 8.5), ("Q6_K", "High (6-bit)", 6.6),
|
| 40 |
+
("Q5_K_M", "Balanced+ (5-bit)", 5.7), ("Q4_K_M", "Balanced (4-bit)", 4.85),
|
| 41 |
+
("Q3_K_M", "Compact (3-bit)", 3.9),
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _synthetic_entry(repo_id: str, info) -> dict | None:
|
| 46 |
+
"""Build a catalogue-shaped entry for a model NOT in our catalogue, so it
|
| 47 |
+
runs through the same engine (memory gauge, speed chart, comparison). Sizes
|
| 48 |
+
are estimated from the parameter count and labelled as estimates."""
|
| 49 |
+
st = getattr(info, "safetensors", None)
|
| 50 |
+
total = getattr(st, "total", None) if st else None
|
| 51 |
+
if not total:
|
| 52 |
+
return None
|
| 53 |
+
params_b = round(total / 1e9, 2)
|
| 54 |
+
quants = [{"key": k, "plain": p, "file_gb": round(params_b * b / 8, 2)}
|
| 55 |
+
for k, p, b in _SYNTH_QUANTS]
|
| 56 |
+
card = getattr(info, "card_data", None)
|
| 57 |
+
lic = ""
|
| 58 |
+
if card:
|
| 59 |
+
lic = (card.get("license") if hasattr(card, "get") else getattr(card, "license", "")) or ""
|
| 60 |
+
return {
|
| 61 |
+
"key": "_lookup", "family": "llm", "name": repo_id.split("/")[-1],
|
| 62 |
+
"repo_id": repo_id, "params_b": params_b, "quants": quants,
|
| 63 |
+
"provenance": "estimated", "good_for": f"{params_b:g}B parameters, looked up live on Hugging Face.",
|
| 64 |
+
"license": lic, "gated": bool(getattr(info, "gated", False)),
|
| 65 |
+
"links": {"hf": f"https://huggingface.co/{repo_id}"}, "use_cases": [],
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
|
| 69 |
@lru_cache(maxsize=1)
|
| 70 |
def _index() -> dict:
|
|
|
|
| 169 |
"explain": explain, "option": opt, "live": via is not None or info is not None,
|
| 170 |
"focus_name": entry["name"], "uc_key": uc_key}
|
| 171 |
|
| 172 |
+
# 3) Not in the catalogue: build a synthetic entry from its real parameter
|
| 173 |
+
# count and run it through the FULL engine — same memory gauge, speed chart
|
| 174 |
+
# and comparison as any catalogue model, with sizes labelled as estimates.
|
| 175 |
+
synth = _synthetic_entry(repo_id, info)
|
| 176 |
+
if synth is None:
|
| 177 |
return {"error": f"'{repo_id}' exists, but doesn't share its size or a known "
|
| 178 |
f"base model, so an honest estimate isn't possible."}
|
| 179 |
+
pipe = getattr(info, "pipeline_tag", None) or ""
|
| 180 |
+
uc_key = _PIPE_UC.get(pipe, payload.get("usecase") or "chat")
|
| 181 |
+
look_payload = dict(payload)
|
| 182 |
+
look_payload["usecase"] = uc_key
|
| 183 |
+
look_payload["focus"] = synth["name"]
|
| 184 |
+
advice = advise_real(look_payload, spec, extra_entries=[synth])
|
| 185 |
+
name = repo_id.split("/")[-1]
|
| 186 |
+
explain = (f"<b>{name}</b> isn't in our catalogue, so its sizes are estimated from its "
|
| 187 |
+
f"{synth['params_b']:g}B parameters (not exact file sizes). Everything below — "
|
| 188 |
+
f"the memory breakdown, the speed chart, the comparison — is computed the same "
|
| 189 |
+
f"way as for catalogue models.")
|
| 190 |
+
return {"found": True, "match": "estimate", "chain": chain, "live": True,
|
| 191 |
+
"explain": explain, "advice": advice}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine/real_advisor.py
CHANGED
|
@@ -407,9 +407,14 @@ def _provenance_line(headline: dict | None) -> str:
|
|
| 407 |
return "The memory figure is estimated from the model's size — conservative, not measured."
|
| 408 |
|
| 409 |
|
| 410 |
-
def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
| 411 |
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
|
| 412 |
-
candidates = _by_use_case().get(uc.key, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
# Honest gap, not a fake answer: if the catalogue doesn't cover a goal yet,
|
| 415 |
# say so and point at the live lookup instead of inventing options.
|
|
@@ -470,29 +475,51 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 470 |
e, est, q = headline["entry"], headline["est"], headline["quant"]
|
| 471 |
hv = headline["verdict"]
|
| 472 |
need = est["total"]
|
|
|
|
| 473 |
where = ("on your Mac" if spec.is_apple_silicon and hv == "great" else
|
| 474 |
"on your graphics card" if hv == "great" and spec.has_fast_path else
|
|
|
|
| 475 |
"using your computer's memory" if hv == "tight" else "")
|
| 476 |
if hv == "great":
|
| 477 |
head_text = f"Yes, you can run {e['name']} {where}, today."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
elif hv == "tight":
|
| 479 |
head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
|
| 480 |
else:
|
| 481 |
head_text = f"{e['name']} won't fit on this machine."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
if e.get("quants"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
detail = (
|
| 484 |
f"For this goal, the honest pick is <b>{e['name']}</b> at the "
|
| 485 |
f"<b>{q.get('plain', q['key'])}</b> setting. {e.get('good_for','')} "
|
| 486 |
f"It needs about <b>{need:g} GB</b> "
|
| 487 |
-
f"(
|
| 488 |
-
f"
|
| 489 |
-
f"and you have roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
|
| 490 |
)
|
| 491 |
else:
|
| 492 |
detail = (
|
| 493 |
f"For this goal, the honest pick is <b>{e['name']}</b>. "
|
| 494 |
-
f"{e.get('good_for','')} It needs about <b>{need:g} GB</b>,
|
| 495 |
-
f"roughly <b>{fast:g} GB</b> fast / <b>{total:g} GB</b> total."
|
| 496 |
)
|
| 497 |
model_part, work_part = est["weights"], round(need - est["weights"], 2)
|
| 498 |
else:
|
|
@@ -523,9 +550,18 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 523 |
note_bits.append("This model is gated: accept its terms on Hugging Face once before downloading.")
|
| 524 |
|
| 525 |
scale = max(total, need, 1) * 1.05
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
gauge = {
|
| 527 |
"need_gb": f"{need:g} GB needed",
|
| 528 |
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
|
|
|
|
|
|
| 529 |
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
|
| 530 |
"mark_pct": round(min(fast / scale, 1.0) * 100, 1),
|
| 531 |
"breakdown": [
|
|
@@ -582,44 +618,96 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 582 |
|
| 583 |
|
| 584 |
# --------------------------------------------------------------------------
|
| 585 |
-
# Reverse mode: "what machine do I need for X?"
|
| 586 |
# --------------------------------------------------------------------------
|
| 587 |
|
| 588 |
-
#
|
| 589 |
-
#
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
]
|
| 607 |
|
| 608 |
|
| 609 |
-
def _spec_for_tier(
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
| 612 |
is_apple_silicon=True, form_factor="mac")
|
| 613 |
return HardwareSpec(os="windows", ram_gb=hw["ram_gb"],
|
| 614 |
gpu_vendor=hw.get("vendor", "none"),
|
| 615 |
vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
|
| 616 |
|
| 617 |
|
| 618 |
-
def min_specs(usecases) -> dict:
|
| 619 |
-
"""For one OR several goals: the cheapest
|
| 620 |
-
works (the union of requirements
|
| 621 |
-
runs great
|
| 622 |
-
Pure engine inversion — fully offline."""
|
| 623 |
if isinstance(usecases, str):
|
| 624 |
usecases = [usecases]
|
| 625 |
seen = set()
|
|
@@ -630,19 +718,29 @@ def min_specs(usecases) -> dict:
|
|
| 630 |
seen.add(uc.key)
|
| 631 |
ucs.append(uc)
|
| 632 |
|
| 633 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
minimum = comfortable = None
|
| 635 |
-
for label, hw,
|
| 636 |
-
spec = _spec_for_tier(
|
| 637 |
per_goal, all_meet, all_great = [], True, True
|
| 638 |
for uc in ucs:
|
| 639 |
-
res =
|
| 640 |
all_meet &= res["meets_goal"] and res["verdict"] in ("great", "tight")
|
| 641 |
all_great &= res["meets_goal"] and res["verdict"] == "great"
|
| 642 |
per_goal.append({"goal": uc.plain_name,
|
| 643 |
"model": res["headline_model"] or "nothing realistic",
|
| 644 |
"verdict": res["verdict"]})
|
| 645 |
-
tier = {"label": label, "price": price, "
|
|
|
|
| 646 |
"runs": "; ".join(f"{g['goal']}: {g['model']}" for g in per_goal)
|
| 647 |
if len(per_goal) > 1 else per_goal[0]["model"]}
|
| 648 |
if minimum is None and all_meet:
|
|
@@ -653,19 +751,25 @@ def min_specs(usecases) -> dict:
|
|
| 653 |
break
|
| 654 |
return minimum, comfortable
|
| 655 |
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
notes = [uc.note for uc in ucs if uc.note]
|
|
|
|
| 659 |
return {
|
| 660 |
"use_case": " + ".join(uc.plain_name for uc in ucs),
|
| 661 |
"goals": [uc.plain_name for uc in ucs],
|
|
|
|
| 662 |
"catalogue_version": catalogue_date(),
|
| 663 |
"note": " ".join(notes),
|
| 664 |
-
"
|
| 665 |
-
"
|
| 666 |
-
|
| 667 |
-
"
|
| 668 |
-
"memory math is the same conservative engine as the main check."
|
| 669 |
-
+ ("
|
| 670 |
-
|
| 671 |
}
|
|
|
|
| 407 |
return "The memory figure is estimated from the model's size — conservative, not measured."
|
| 408 |
|
| 409 |
|
| 410 |
+
def advise_real(payload: dict, spec: HardwareSpec, extra_entries: list | None = None) -> dict:
|
| 411 |
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
|
| 412 |
+
candidates = list(_by_use_case().get(uc.key, []))
|
| 413 |
+
# A live-looked-up model not in the catalogue is injected here as a synthetic
|
| 414 |
+
# candidate, so it flows through the same gauge / speed-chart / comparison
|
| 415 |
+
# pipeline as catalogue models (its sizes are estimates, labelled as such).
|
| 416 |
+
if extra_entries:
|
| 417 |
+
candidates = list(extra_entries) + candidates
|
| 418 |
|
| 419 |
# Honest gap, not a fake answer: if the catalogue doesn't cover a goal yet,
|
| 420 |
# say so and point at the live lookup instead of inventing options.
|
|
|
|
| 475 |
e, est, q = headline["entry"], headline["est"], headline["quant"]
|
| 476 |
hv = headline["verdict"]
|
| 477 |
need = est["total"]
|
| 478 |
+
no_gpu = not spec.has_fast_path
|
| 479 |
where = ("on your Mac" if spec.is_apple_silicon and hv == "great" else
|
| 480 |
"on your graphics card" if hv == "great" and spec.has_fast_path else
|
| 481 |
+
"on the processor" if hv == "tight" and no_gpu else
|
| 482 |
"using your computer's memory" if hv == "tight" else "")
|
| 483 |
if hv == "great":
|
| 484 |
head_text = f"Yes, you can run {e['name']} {where}, today."
|
| 485 |
+
elif hv == "tight" and no_gpu:
|
| 486 |
+
# CPU inference of a small quantised model is genuinely usable, just
|
| 487 |
+
# not instant — say that plainly rather than "with trade-offs".
|
| 488 |
+
head_text = f"Yes, with no graphics card {e['name']} runs {where} (slower, but it works)."
|
| 489 |
elif hv == "tight":
|
| 490 |
head_text = f"Sort of. {e['name']} will run {where}, with trade-offs."
|
| 491 |
else:
|
| 492 |
head_text = f"{e['name']} won't fit on this machine."
|
| 493 |
+
# "What you have" reads differently with vs without a dedicated GPU.
|
| 494 |
+
if spec.is_apple_silicon:
|
| 495 |
+
have_clause = (f"and your Mac shares <b>{total:g} GB</b> of unified memory between "
|
| 496 |
+
f"the processor and graphics.")
|
| 497 |
+
elif no_gpu:
|
| 498 |
+
have_clause = (f"and with no separate graphics card it runs on the processor using "
|
| 499 |
+
f"your <b>{total:g} GB</b> of system memory — slower than a GPU, but "
|
| 500 |
+
f"it works, especially at a 4-bit setting.")
|
| 501 |
+
else:
|
| 502 |
+
have_clause = (f"and you have about <b>{fast:g} GB</b> on the graphics card, or "
|
| 503 |
+
f"<b>{total:g} GB</b> counting system memory it can spill into.")
|
| 504 |
if e.get("quants"):
|
| 505 |
+
# Be honest about whether the weights figure is an exact file size
|
| 506 |
+
# (catalogue / GGUF) or a parameter-count estimate (live lookup).
|
| 507 |
+
if e.get("provenance") == "filesize":
|
| 508 |
+
weights_phrase = f"the model file is {est['weights']:g} GB — exact size on Hugging Face"
|
| 509 |
+
else:
|
| 510 |
+
weights_phrase = (f"the weights are about {est['weights']:g} GB at this setting "
|
| 511 |
+
f"(estimated from its {e.get('params_b', 0):g}B parameters)")
|
| 512 |
detail = (
|
| 513 |
f"For this goal, the honest pick is <b>{e['name']}</b> at the "
|
| 514 |
f"<b>{q.get('plain', q['key'])}</b> setting. {e.get('good_for','')} "
|
| 515 |
f"It needs about <b>{need:g} GB</b> "
|
| 516 |
+
f"({weights_phrase} plus {est['kv']:g} GB chat memory and "
|
| 517 |
+
f"{est['overhead']:g} GB working space), {have_clause}"
|
|
|
|
| 518 |
)
|
| 519 |
else:
|
| 520 |
detail = (
|
| 521 |
f"For this goal, the honest pick is <b>{e['name']}</b>. "
|
| 522 |
+
f"{e.get('good_for','')} It needs about <b>{need:g} GB</b>, {have_clause}"
|
|
|
|
| 523 |
)
|
| 524 |
model_part, work_part = est["weights"], round(need - est["weights"], 2)
|
| 525 |
else:
|
|
|
|
| 550 |
note_bits.append("This model is gated: accept its terms on Hugging Face once before downloading.")
|
| 551 |
|
| 552 |
scale = max(total, need, 1) * 1.05
|
| 553 |
+
has_fast = spec.has_fast_path
|
| 554 |
+
if spec.is_apple_silicon:
|
| 555 |
+
fast_label, total_label = "GPU can use", "Unified memory"
|
| 556 |
+
elif has_fast:
|
| 557 |
+
fast_label, total_label = "On the GPU (VRAM)", "GPU + system RAM"
|
| 558 |
+
else:
|
| 559 |
+
fast_label, total_label = "", "System RAM (no GPU)"
|
| 560 |
gauge = {
|
| 561 |
"need_gb": f"{need:g} GB needed",
|
| 562 |
"fast_gb": f"{fast:g} GB", "total_gb": f"{total:g} GB",
|
| 563 |
+
"fast_label": fast_label, "total_label": total_label,
|
| 564 |
+
"has_fast": has_fast,
|
| 565 |
"fill_pct": round(min(need / scale, 1.0) * 100, 1),
|
| 566 |
"mark_pct": round(min(fast / scale, 1.0) * 100, 1),
|
| 567 |
"breakdown": [
|
|
|
|
| 618 |
|
| 619 |
|
| 620 |
# --------------------------------------------------------------------------
|
| 621 |
+
# Reverse mode: "what machine do I need for X?" Multi-platform, 2026 hardware.
|
| 622 |
# --------------------------------------------------------------------------
|
| 623 |
|
| 624 |
+
# Each platform is a cheap -> expensive ladder of representative builds. The
|
| 625 |
+
# memory numbers feed the SAME engine (so recommendations are grounded, not
|
| 626 |
+
# hand-waved); `spec` and `detail` power the human-readable + click-to-expand
|
| 627 |
+
# card. Prices are rough mid-2026 street figures (the DRAM shortage moved them),
|
| 628 |
+
# shown as guidance, not gospel. Sources: TechPowerUp/Tom's (bandwidth), vendor
|
| 629 |
+
# pages; software-support notes from ROCm/MLX/CUDA reporting.
|
| 630 |
+
_PLATFORMS = [
|
| 631 |
+
{
|
| 632 |
+
"key": "nvidia", "name": "NVIDIA PC", "icon": "brand-nvidia",
|
| 633 |
+
"blurb": "The path of least resistance: CUDA, so every tool just works. Best if you might fine-tune.",
|
| 634 |
+
"tiers": [
|
| 635 |
+
("RTX 5060 Ti 16GB build", "~$1,000", dict(ram_gb=32, vram_gb=16, vendor="nvidia"),
|
| 636 |
+
"8-core CPU, 32 GB DDR5, RTX 5060 Ti 16GB, 1 TB NVMe",
|
| 637 |
+
"16 GB of VRAM is the real sweet spot: it runs 13-14B models comfortably and starts to fine-tune 7B with QLoRA. The 5060 Ti's narrow memory bus makes it slower than a 5070 Ti, but it is the cheapest 16 GB CUDA card. Desktop over laptop here: you get more VRAM per dollar and can swap the GPU later."),
|
| 638 |
+
("RTX 5070 Ti build", "~$1,500", dict(ram_gb=32, vram_gb=16, vendor="nvidia"),
|
| 639 |
+
"8-core CPU, 32-64 GB DDR5, RTX 5070 Ti 16GB, 2 TB NVMe",
|
| 640 |
+
"Same 16 GB ceiling but much faster generation (896 GB/s). The mainstream pick for someone who wants snappy local AI without overspending. A desktop card runs cooler and faster under sustained load than the same chip in a laptop, which throttles."),
|
| 641 |
+
("RTX 4090 / 24 GB build", "~$2,400", dict(ram_gb=64, vram_gb=24, vendor="nvidia"),
|
| 642 |
+
"12-core CPU, 64 GB DDR5, 24 GB GPU (RTX 4090, used), 2 TB NVMe",
|
| 643 |
+
"24 GB runs ~30B models comfortably and QLoRA-fine-tunes up to ~30B. The 4090 is end-of-life and scarcity-priced, so buy used carefully or wait for a 24 GB 50-series. 64 GB of system RAM lets big models spill over when needed."),
|
| 644 |
+
("RTX 5090 build", "~$3,800", dict(ram_gb=64, vram_gb=32, vendor="nvidia"),
|
| 645 |
+
"12-16 core CPU, 64-128 GB DDR5, RTX 5090 32GB, 2 TB NVMe",
|
| 646 |
+
"The fastest consumer card and the only one with 32 GB, so it runs ~30B at full speed and big diffusion models. Workstation territory in price. If you mainly fine-tune, two 24 GB cards can beat one 5090 for multi-GPU training."),
|
| 647 |
+
],
|
| 648 |
+
},
|
| 649 |
+
{
|
| 650 |
+
"key": "amd", "name": "AMD PC", "icon": "brand-amd",
|
| 651 |
+
"blurb": "Cheaper VRAM for inference. On Windows use the Vulkan path (LM Studio); rougher for training.",
|
| 652 |
+
"tiers": [
|
| 653 |
+
("RX 9070 XT build", "~$1,200", dict(ram_gb=32, vram_gb=16, vendor="amd"),
|
| 654 |
+
"8-core CPU, 32 GB DDR5, Radeon RX 9070 XT 16GB, 2 TB NVMe",
|
| 655 |
+
"16 GB at a good price; runs up to ~14B models well. For 'open a model and chat' the AMD-vs-NVIDIA gap is now mostly price. Caveat: on Windows use llama.cpp / LM Studio via Vulkan (near plug-and-play); ROCm is Linux-first and fine-tuning support lags CUDA."),
|
| 656 |
+
("RX 7900 XTX build", "~$1,500", dict(ram_gb=64, vram_gb=24, vendor="amd"),
|
| 657 |
+
"12-core CPU, 64 GB DDR5, Radeon RX 7900 XTX 24GB, 2 TB NVMe",
|
| 658 |
+
"The AMD standout for local AI: 24 GB and 960 GB/s for under $1k on the card, runs 70B at 4-bit. Best VRAM-per-dollar at this tier. Still AMD-software caveats for training, but excellent for inference."),
|
| 659 |
+
],
|
| 660 |
+
},
|
| 661 |
+
{
|
| 662 |
+
"key": "apple", "name": "Mac (Apple Silicon)", "icon": "brand-apple",
|
| 663 |
+
"blurb": "Unified memory runs models no consumer GPU can hold, quietly and efficiently. Inference-focused.",
|
| 664 |
+
"tiers": [
|
| 665 |
+
("Mac mini / Air M4, 16 GB", "~$1,000", dict(ram_gb=16, apple=True),
|
| 666 |
+
"Apple M4, 16 GB unified memory",
|
| 667 |
+
"The shared memory pool means the GPU can use almost all 16 GB. Runs small and mid models quietly. Buy the RAM you need up front: Apple memory is soldered and cannot be upgraded later, ever."),
|
| 668 |
+
("Mac M4 Pro, 36 GB", "~$1,700", dict(ram_gb=36, apple=True),
|
| 669 |
+
"Apple M4 Pro, 36 GB unified memory, 273 GB/s",
|
| 670 |
+
"A strong quiet inference machine: 36 GB holds ~30B-class models, and the Pro's higher bandwidth keeps them usable. Great everyday computer too. Weaker than NVIDIA for fine-tuning (MLX does small LoRA; real training wants CUDA)."),
|
| 671 |
+
("Mac M4 Max, 64 GB", "~$2,800", dict(ram_gb=64, apple=True),
|
| 672 |
+
"Apple M4 Max, 64 GB unified memory, 410-546 GB/s",
|
| 673 |
+
"64 GB of unified memory runs models a 24 GB GPU simply cannot load, at higher bandwidth than the mini-PC boxes. The sweet spot for running big models quietly. Studio (desktop) or MacBook Pro (laptop) both work; the Studio sustains load better."),
|
| 674 |
+
("Mac Studio M4 Max / Ultra, 128 GB+", "~$4,000+", dict(ram_gb=128, apple=True),
|
| 675 |
+
"Apple M4 Max / M3 Ultra, 128-512 GB unified memory",
|
| 676 |
+
"Capacity king: 128 GB holds 70-120B-class models, and an M3 Ultra at 512 GB holds 600B+ MoE models no other single box can. The win is capacity and quiet, not raw speed (a 5090 is faster on whatever fits its 32 GB)."),
|
| 677 |
+
],
|
| 678 |
+
},
|
| 679 |
+
{
|
| 680 |
+
"key": "mini", "name": "Mini-PC (unified memory)", "icon": "monitor",
|
| 681 |
+
"blurb": "Big models in a small, silent, low-power box. They fit huge models; they run them at a steady jog, not a sprint.",
|
| 682 |
+
"tiers": [
|
| 683 |
+
("Ryzen AI Max+ 395, 64 GB", "~$1,500", dict(ram_gb=64, unified=True, vendor="amd"),
|
| 684 |
+
"AMD Ryzen AI Max+ 395 'Strix Halo', 64 GB unified memory, ~256 GB/s",
|
| 685 |
+
"16 Zen 5 cores plus a strong integrated GPU sharing 64 GB, in a tiny quiet box, no discrete card. Runs ~30B comfortably. Bandwidth is far below a discrete GPU, so big models fit and are usable but not fast. Vulkan works well; ROCm is fiddlier."),
|
| 686 |
+
("Ryzen AI Max+ 395, 128 GB", "~$2,400", dict(ram_gb=128, unified=True, vendor="amd"),
|
| 687 |
+
"AMD Ryzen AI Max+ 395 'Strix Halo', 128 GB unified memory, ~256 GB/s",
|
| 688 |
+
"128 GB of unified memory runs 70B-class models (AMD demos 128B in LM Studio) for far less than a Mac with the same capacity, and sips power. Best capacity-per-dollar for big-model experimentation; accept moderate speed. The DGX Spark is a CUDA-compatible alternative but costs ~2x and is slower per token."),
|
| 689 |
+
],
|
| 690 |
+
},
|
| 691 |
]
|
| 692 |
|
| 693 |
|
| 694 |
+
def _spec_for_tier(hw: dict) -> HardwareSpec:
|
| 695 |
+
# Apple and unified-memory mini-PCs share one memory pool (model like Apple);
|
| 696 |
+
# a normal PC has separate VRAM + system RAM.
|
| 697 |
+
if hw.get("apple") or hw.get("unified"):
|
| 698 |
+
return HardwareSpec(os="macos" if hw.get("apple") else "linux",
|
| 699 |
+
ram_gb=hw["ram_gb"], gpu_vendor=hw.get("vendor", "apple"),
|
| 700 |
is_apple_silicon=True, form_factor="mac")
|
| 701 |
return HardwareSpec(os="windows", ram_gb=hw["ram_gb"],
|
| 702 |
gpu_vendor=hw.get("vendor", "none"),
|
| 703 |
vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
|
| 704 |
|
| 705 |
|
| 706 |
+
def min_specs(usecases, mode: str = "run") -> dict:
|
| 707 |
+
"""For one OR several goals, across every platform: the cheapest build where
|
| 708 |
+
EVERY goal genuinely works (the union of requirements), and the build where
|
| 709 |
+
every goal runs great. `mode="finetune"` sizes for training instead of
|
| 710 |
+
inference. Pure engine inversion — fully offline."""
|
| 711 |
if isinstance(usecases, str):
|
| 712 |
usecases = [usecases]
|
| 713 |
seen = set()
|
|
|
|
| 718 |
seen.add(uc.key)
|
| 719 |
ucs.append(uc)
|
| 720 |
|
| 721 |
+
finetune = mode == "finetune"
|
| 722 |
+
if finetune:
|
| 723 |
+
from .finetune import advise_finetune
|
| 724 |
+
|
| 725 |
+
def assess(uc, spec):
|
| 726 |
+
if finetune:
|
| 727 |
+
return advise_finetune({"usecase": uc.key}, spec)
|
| 728 |
+
return advise_real({"usecase": uc.key}, spec)
|
| 729 |
+
|
| 730 |
+
def walk(tiers):
|
| 731 |
minimum = comfortable = None
|
| 732 |
+
for label, price, hw, spec_line, detail in tiers:
|
| 733 |
+
spec = _spec_for_tier(hw)
|
| 734 |
per_goal, all_meet, all_great = [], True, True
|
| 735 |
for uc in ucs:
|
| 736 |
+
res = assess(uc, spec)
|
| 737 |
all_meet &= res["meets_goal"] and res["verdict"] in ("great", "tight")
|
| 738 |
all_great &= res["meets_goal"] and res["verdict"] == "great"
|
| 739 |
per_goal.append({"goal": uc.plain_name,
|
| 740 |
"model": res["headline_model"] or "nothing realistic",
|
| 741 |
"verdict": res["verdict"]})
|
| 742 |
+
tier = {"label": label, "price": price, "spec": spec_line, "detail": detail,
|
| 743 |
+
"goals": per_goal,
|
| 744 |
"runs": "; ".join(f"{g['goal']}: {g['model']}" for g in per_goal)
|
| 745 |
if len(per_goal) > 1 else per_goal[0]["model"]}
|
| 746 |
if minimum is None and all_meet:
|
|
|
|
| 751 |
break
|
| 752 |
return minimum, comfortable
|
| 753 |
|
| 754 |
+
platforms = []
|
| 755 |
+
for p in _PLATFORMS:
|
| 756 |
+
mn, comfy = walk(p["tiers"])
|
| 757 |
+
platforms.append({"key": p["key"], "name": p["name"], "icon": p["icon"],
|
| 758 |
+
"blurb": p["blurb"], "minimum": mn, "comfortable": comfy})
|
| 759 |
+
|
| 760 |
notes = [uc.note for uc in ucs if uc.note]
|
| 761 |
+
verb = "fine-tune" if finetune else "run"
|
| 762 |
return {
|
| 763 |
"use_case": " + ".join(uc.plain_name for uc in ucs),
|
| 764 |
"goals": [uc.plain_name for uc in ucs],
|
| 765 |
+
"mode": mode,
|
| 766 |
"catalogue_version": catalogue_date(),
|
| 767 |
"note": " ".join(notes),
|
| 768 |
+
"platforms": platforms,
|
| 769 |
+
"disclaimer": (f"Two honest builds per platform: the cheapest that genuinely lets you "
|
| 770 |
+
f"{verb} these goals, and the one that does it comfortably. Click any card "
|
| 771 |
+
f"for what to look for. Prices are rough mid-2026 street figures and drift; "
|
| 772 |
+
f"the memory math is the same conservative engine as the main check."
|
| 773 |
+
+ (" Builds cover the union of every goal you picked: each must work."
|
| 774 |
+
if len(ucs) > 1 else "")),
|
| 775 |
}
|
static/app.js
CHANGED
|
@@ -81,12 +81,8 @@ function applyMode() {
|
|
| 81 |
const el = $(s); if (el) el.style.display = buy ? "none" : "";
|
| 82 |
});
|
| 83 |
const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : "";
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
state.task = "run";
|
| 87 |
-
tog.classList.remove("train");
|
| 88 |
-
tog.querySelectorAll(".tt-opt").forEach(x => x.classList.toggle("active", x.dataset.task === "run"));
|
| 89 |
-
}
|
| 90 |
$("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
|
| 91 |
+ '<span class="ic" data-ic="arrow"></span>';
|
| 92 |
hydrate($("#check-btn"));
|
|
@@ -117,11 +113,21 @@ function buildPicker() {
|
|
| 117 |
state.usecases.push(uc);
|
| 118 |
p.classList.add("active");
|
| 119 |
}
|
| 120 |
-
|
| 121 |
maybeLiveUpdate();
|
| 122 |
}));
|
| 123 |
}
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
// ---- Segmented controls ---------------------------------------------------
|
| 126 |
function wireSegmented(id, key, after) {
|
| 127 |
$(id).querySelectorAll(".seg-btn").forEach(b => b.addEventListener("click", () => {
|
|
@@ -213,7 +219,6 @@ function gather() {
|
|
| 213 |
paste: $("#paste").value.trim(),
|
| 214 |
usecase: state.usecases[0],
|
| 215 |
usecases: state.usecases.slice(),
|
| 216 |
-
custom: $("#custom-uc").value.trim(),
|
| 217 |
priority: state.priority,
|
| 218 |
mode: state.task, // "run" (inference) or "finetune" (training)
|
| 219 |
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
|
@@ -234,7 +239,7 @@ async function check() {
|
|
| 234 |
if (state.mode === "buy") {
|
| 235 |
const res = await fetch("/api/minspecs", {
|
| 236 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 237 |
-
body: JSON.stringify({ usecases: state.usecases }),
|
| 238 |
});
|
| 239 |
renderBuy(await res.json());
|
| 240 |
return;
|
|
@@ -304,24 +309,21 @@ async function lookupRepo(payload) {
|
|
| 304 |
}
|
| 305 |
return;
|
| 306 |
}
|
| 307 |
-
//
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
</div>
|
| 323 |
-
</div>
|
| 324 |
-
</div>`;
|
| 325 |
hydrate($("#results"));
|
| 326 |
}
|
| 327 |
|
|
@@ -357,38 +359,50 @@ function renderMulti(results) {
|
|
| 357 |
$("#cat-version").textContent = (results[0] || {}).catalogue_version || "—";
|
| 358 |
}
|
| 359 |
|
| 360 |
-
// ---- Buy-advice render --
|
| 361 |
function renderBuy(d) {
|
| 362 |
-
const
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
<
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
<div class="
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
<
|
|
|
|
|
|
|
| 378 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
$("#results").innerHTML = `
|
| 380 |
<div class="reveal">
|
| 381 |
<div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
|
| 382 |
-
<span class="badge"><span class="dot"></span>Buying advice</span>
|
| 383 |
-
<h2>What
|
| 384 |
-
<p>
|
| 385 |
${d.note ? `<div class="note">${d.note}</div>` : ""}
|
| 386 |
</div>
|
| 387 |
-
${
|
| 388 |
-
${lane("Mac (Apple Silicon)", "brand-apple", d.mac || {})}
|
| 389 |
-
<p class="cmd-intro" style="margin-top:var(--s-4)">${d.disclaimer || ""}</p>
|
| 390 |
</div>`;
|
| 391 |
hydrate($("#results"));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
$("#cat-version").textContent = d.catalogue_version || "—";
|
| 393 |
}
|
| 394 |
|
|
@@ -468,12 +482,12 @@ function render(d) {
|
|
| 468 |
<div class="gauge-top"><span class="label" style="margin:0">Memory needed vs. what you have</span>
|
| 469 |
<span class="need">${g.need_gb}</span></div>
|
| 470 |
<div class="gauge" style="--pct:${g.fill_pct};--gcolor:${v.cls};--needpct:${g.mark_pct}">
|
| 471 |
-
<div class="gauge-fill"></div><div class="gauge-need"></div>
|
| 472 |
</div>
|
| 473 |
<div class="gauge-legend">
|
| 474 |
${(g.breakdown||[]).map(b=>`<span class="item"><span class="sw" style="background:${b.color}"></span>${b.label}</span>`).join("")}
|
| 475 |
-
<span class="item marker">
|
| 476 |
-
<span class="item marker">Total: ${g.total_gb}</span>
|
| 477 |
</div>
|
| 478 |
${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""}
|
| 479 |
</div>` : ""}
|
|
@@ -601,9 +615,10 @@ async function drawRoofline(speed) {
|
|
| 601 |
|
| 602 |
// real measurement dots, shaded by model size
|
| 603 |
const shade = p => p.params_b <= 2 ? "rl-p1" : (p.params_b <= 9 ? "rl-p8" : "rl-p14");
|
|
|
|
| 604 |
for (const p of pts) {
|
| 605 |
if (p.bw < xmin || p.tps < ymin) continue;
|
| 606 |
-
s += `<circle cx="${lx(Math.min(p.bw, xmax)).toFixed(1)}" cy="${ly(Math.min(p.tps, ymax)).toFixed(1)}" r="2.6" class="rl-dot ${shade(p)}"
|
| 607 |
}
|
| 608 |
|
| 609 |
if (speed) {
|
|
@@ -618,7 +633,7 @@ async function drawRoofline(speed) {
|
|
| 618 |
s += `<line x1="${ux}" y1="${ly(Math.min(Math.max(speed.lo, ymin), ymax))}" x2="${ux}" y2="${ly(Math.min(Math.max(speed.hi, ymin), ymax))}" class="rl-band"/>`;
|
| 619 |
// flip the label to the left when the dot sits near the right edge
|
| 620 |
const flip = ux > W * 0.72;
|
| 621 |
-
s += `<circle cx="${ux}" cy="${uy}" r="6" class="rl-you"/>` +
|
| 622 |
`<text x="${flip ? ux - 10 : ux + 10}" y="${uy + 4}" class="rl-you-label" text-anchor="${flip ? "end" : "start"}">you ≈${speed.tps} tok/s</text>`;
|
| 623 |
}
|
| 624 |
s += `</svg>
|
|
@@ -630,6 +645,31 @@ async function drawRoofline(speed) {
|
|
| 630 |
<span class="item"><span class="sw rl-you-sw"></span>your machine</span>
|
| 631 |
</div>`;
|
| 632 |
host.innerHTML = s;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
}
|
| 634 |
|
| 635 |
// ---- Live progress ticker (gradio-style elapsed seconds) -------------------
|
|
@@ -802,7 +842,7 @@ function init() {
|
|
| 802 |
wireSegmented("#provider-seg", "provider", fillGpu);
|
| 803 |
wireSegmented("#priority-seg", "priority");
|
| 804 |
wireTaskToggle();
|
| 805 |
-
["#ram","#gpu","#vram","#
|
| 806 |
$("#paste").addEventListener("input", maybeLiveUpdate);
|
| 807 |
$("#check-btn").addEventListener("click", check);
|
| 808 |
const pb = $("#parse-btn"); if (pb) pb.addEventListener("click", parsePaste);
|
|
|
|
| 81 |
const el = $(s); if (el) el.style.display = buy ? "none" : "";
|
| 82 |
});
|
| 83 |
const repo = $("#repo-field"); if (repo) repo.style.display = buy ? "none" : "";
|
| 84 |
+
// The Run/Train toggle applies in buy mode too: "what should I buy to run X"
|
| 85 |
+
// vs "to fine-tune X" are very different machines.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
$("#check-btn").innerHTML = (buy ? "What should I get? " : "Check my setup ")
|
| 87 |
+ '<span class="ic" data-ic="arrow"></span>';
|
| 88 |
hydrate($("#check-btn"));
|
|
|
|
| 113 |
state.usecases.push(uc);
|
| 114 |
p.classList.add("active");
|
| 115 |
}
|
| 116 |
+
syncCustomRepo();
|
| 117 |
maybeLiveUpdate();
|
| 118 |
}));
|
| 119 |
}
|
| 120 |
|
| 121 |
+
// When "Custom: describe it" is the goal, the model box is the way in, so drop
|
| 122 |
+
// its "(optional)" tag and flag it as the expected input.
|
| 123 |
+
function syncCustomRepo() {
|
| 124 |
+
const isCustom = state.usecases.includes("custom");
|
| 125 |
+
const opt = $("#repo-optional");
|
| 126 |
+
if (opt) opt.style.display = isCustom ? "none" : "";
|
| 127 |
+
const field = $("#repo-field");
|
| 128 |
+
if (field) field.classList.toggle("required", isCustom);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
// ---- Segmented controls ---------------------------------------------------
|
| 132 |
function wireSegmented(id, key, after) {
|
| 133 |
$(id).querySelectorAll(".seg-btn").forEach(b => b.addEventListener("click", () => {
|
|
|
|
| 219 |
paste: $("#paste").value.trim(),
|
| 220 |
usecase: state.usecases[0],
|
| 221 |
usecases: state.usecases.slice(),
|
|
|
|
| 222 |
priority: state.priority,
|
| 223 |
mode: state.task, // "run" (inference) or "finetune" (training)
|
| 224 |
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
|
|
|
| 239 |
if (state.mode === "buy") {
|
| 240 |
const res = await fetch("/api/minspecs", {
|
| 241 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 242 |
+
body: JSON.stringify({ usecases: state.usecases, mode: state.task }),
|
| 243 |
});
|
| 244 |
renderBuy(await res.json());
|
| 245 |
return;
|
|
|
|
| 309 |
}
|
| 310 |
return;
|
| 311 |
}
|
| 312 |
+
// Not in the catalogue: the engine still built a FULL breakdown from the
|
| 313 |
+
// looked-up parameter count. Render it like any result, with an estimate banner.
|
| 314 |
+
if (d.match === "estimate" && d.advice) {
|
| 315 |
+
render(d.advice);
|
| 316 |
+
const wrap = $("#results").firstElementChild;
|
| 317 |
+
if (wrap) {
|
| 318 |
+
const banner = document.createElement("div");
|
| 319 |
+
banner.className = "lookup-banner estimate";
|
| 320 |
+
banner.innerHTML = `<span class="live-tag">Live Hugging Face lookup · estimate</span><p>${d.explain || ""}</p>`;
|
| 321 |
+
wrap.prepend(banner);
|
| 322 |
+
}
|
| 323 |
+
return;
|
| 324 |
+
}
|
| 325 |
+
// Last resort: a bare error/explain with no advice payload.
|
| 326 |
+
$("#results").innerHTML = `<div class="ans-card"><h3>${payload.repo}</h3><p>${d.explain || "No estimate available."}</p></div>`;
|
|
|
|
|
|
|
|
|
|
| 327 |
hydrate($("#results"));
|
| 328 |
}
|
| 329 |
|
|
|
|
| 359 |
$("#cat-version").textContent = (results[0] || {}).catalogue_version || "—";
|
| 360 |
}
|
| 361 |
|
| 362 |
+
// ---- Buy-advice render: multi-platform, click any card for what to look for -
|
| 363 |
function renderBuy(d) {
|
| 364 |
+
const ft = d.mode === "finetune";
|
| 365 |
+
const verb = ft ? "fine-tune" : "run";
|
| 366 |
+
const multi = (d.goals || []).length > 1;
|
| 367 |
+
const tierCard = (t, kind) => {
|
| 368 |
+
if (!t) return `<div class="buy-card empty">
|
| 369 |
+
<div class="bc-head"><span class="bc-kind ${kind}">${kind === "min" ? "Minimum" : "Comfortable"}</span></div>
|
| 370 |
+
<p class="bc-none">No build on this platform ${ft ? "trains" : "runs"} ${multi ? "all of these" : "this"}${ft ? "" : " well"} — it wants more than this ladder offers.</p>
|
| 371 |
+
</div>`;
|
| 372 |
+
const goalsHtml = multi
|
| 373 |
+
? `<ul class="bc-goals">${t.goals.map(g => `<li><span>${g.goal}</span><b>${g.model}</b>${g.verdict === "tight" ? " <i>(tight)</i>" : ""}</li>`).join("")}</ul>`
|
| 374 |
+
: `<div class="bc-runs">${ft ? "Can train up to" : "Runs"} <b>${t.runs}</b>${t.goals[0] && t.goals[0].verdict === "tight" ? " <i>(tight)</i>" : ""}</div>`;
|
| 375 |
+
return `<div class="buy-card">
|
| 376 |
+
<div class="bc-head"><span class="bc-kind ${kind}">${kind === "min" ? "Minimum" : "Comfortable"}</span><span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div>
|
| 377 |
+
<div class="bc-label">${t.label}</div>
|
| 378 |
+
${goalsHtml}
|
| 379 |
+
<button class="bc-more">What to look for <span class="chev ic" data-ic="chevron"></span></button>
|
| 380 |
+
<div class="bc-detail" hidden><div class="bc-spec">${t.spec}</div><p>${t.detail}</p></div>
|
| 381 |
+
</div>`;
|
| 382 |
};
|
| 383 |
+
const platforms = (d.platforms || []).map(p => `
|
| 384 |
+
<div class="buy-platform">
|
| 385 |
+
<div class="bp-head"><span class="ic" data-ic="${p.icon}"></span><span class="bp-name">${p.name}</span></div>
|
| 386 |
+
<div class="bp-blurb">${p.blurb}</div>
|
| 387 |
+
<div class="buy-grid">${tierCard(p.minimum, "min")}${tierCard(p.comfortable, "comfy")}</div>
|
| 388 |
+
</div>`).join("");
|
| 389 |
$("#results").innerHTML = `
|
| 390 |
<div class="reveal">
|
| 391 |
<div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
|
| 392 |
+
<span class="badge"><span class="dot"></span>Buying advice · to ${verb}</span>
|
| 393 |
+
<h2>What to buy to ${verb} ${(d.use_case || "this").toLowerCase()}</h2>
|
| 394 |
+
<p>${d.disclaimer || ""}</p>
|
| 395 |
${d.note ? `<div class="note">${d.note}</div>` : ""}
|
| 396 |
</div>
|
| 397 |
+
${platforms}
|
|
|
|
|
|
|
| 398 |
</div>`;
|
| 399 |
hydrate($("#results"));
|
| 400 |
+
$("#results").querySelectorAll(".buy-card .bc-more").forEach(b => b.addEventListener("click", () => {
|
| 401 |
+
const det = b.nextElementSibling;
|
| 402 |
+
const opening = det.hasAttribute("hidden");
|
| 403 |
+
if (opening) det.removeAttribute("hidden"); else det.setAttribute("hidden", "");
|
| 404 |
+
b.classList.toggle("open", opening);
|
| 405 |
+
}));
|
| 406 |
$("#cat-version").textContent = d.catalogue_version || "—";
|
| 407 |
}
|
| 408 |
|
|
|
|
| 482 |
<div class="gauge-top"><span class="label" style="margin:0">Memory needed vs. what you have</span>
|
| 483 |
<span class="need">${g.need_gb}</span></div>
|
| 484 |
<div class="gauge" style="--pct:${g.fill_pct};--gcolor:${v.cls};--needpct:${g.mark_pct}">
|
| 485 |
+
<div class="gauge-fill"></div>${g.has_fast ? `<div class="gauge-need" data-tip="Your GPU memory limit. A model that fits left of this line runs at full GPU speed; past it, the overflow runs from slower system RAM."></div>` : ""}
|
| 486 |
</div>
|
| 487 |
<div class="gauge-legend">
|
| 488 |
${(g.breakdown||[]).map(b=>`<span class="item"><span class="sw" style="background:${b.color}"></span>${b.label}</span>`).join("")}
|
| 489 |
+
${g.has_fast && g.fast_label ? `<span class="item marker" data-tip="Your graphics card's own memory. Models that fit here run at full speed.">${g.fast_label}: ${g.fast_gb}</span>` : ""}
|
| 490 |
+
<span class="item marker" data-tip="${g.has_fast ? "GPU memory plus a slice of system RAM the model can spill into (the spilled part runs slower)." : "With no separate graphics card, the model runs on the processor using your system memory."}">${g.total_label || "Total"}: ${g.total_gb}</span>
|
| 491 |
</div>
|
| 492 |
${d.provenance ? `<div class="prov">${d.provenance}</div>` : ""}
|
| 493 |
</div>` : ""}
|
|
|
|
| 615 |
|
| 616 |
// real measurement dots, shaded by model size
|
| 617 |
const shade = p => p.params_b <= 2 ? "rl-p1" : (p.params_b <= 9 ? "rl-p8" : "rl-p14");
|
| 618 |
+
const esc = t => String(t == null ? "" : t).replace(/"/g, """);
|
| 619 |
for (const p of pts) {
|
| 620 |
if (p.bw < xmin || p.tps < ymin) continue;
|
| 621 |
+
s += `<circle cx="${lx(Math.min(p.bw, xmax)).toFixed(1)}" cy="${ly(Math.min(p.tps, ymax)).toFixed(1)}" r="2.6" class="rl-dot ${shade(p)}" data-m="${esc(p.model)}" data-a="${esc(p.accel)}" data-t="${esc(p.tps)}" data-b="${esc(Math.round(p.bw))}" data-p="${esc(p.params_b)}"></circle>`;
|
| 622 |
}
|
| 623 |
|
| 624 |
if (speed) {
|
|
|
|
| 633 |
s += `<line x1="${ux}" y1="${ly(Math.min(Math.max(speed.lo, ymin), ymax))}" x2="${ux}" y2="${ly(Math.min(Math.max(speed.hi, ymin), ymax))}" class="rl-band"/>`;
|
| 634 |
// flip the label to the left when the dot sits near the right edge
|
| 635 |
const flip = ux > W * 0.72;
|
| 636 |
+
s += `<circle cx="${ux}" cy="${uy}" r="6" class="rl-you" data-you="1" data-m="${esc(speed.model)}" data-t="${esc(speed.tps)}" data-b="${esc(Math.round(speed.eff_bw || speed.bw))}"/>` +
|
| 637 |
`<text x="${flip ? ux - 10 : ux + 10}" y="${uy + 4}" class="rl-you-label" text-anchor="${flip ? "end" : "start"}">you ≈${speed.tps} tok/s</text>`;
|
| 638 |
}
|
| 639 |
s += `</svg>
|
|
|
|
| 645 |
<span class="item"><span class="sw rl-you-sw"></span>your machine</span>
|
| 646 |
</div>`;
|
| 647 |
host.innerHTML = s;
|
| 648 |
+
|
| 649 |
+
// Hover any dot for a plain-words info bubble: what model, what hardware,
|
| 650 |
+
// how fast, at what bandwidth. The whole point of the chart is legibility.
|
| 651 |
+
host.style.position = "relative";
|
| 652 |
+
const tip = document.createElement("div");
|
| 653 |
+
tip.className = "rl-tip"; tip.hidden = true;
|
| 654 |
+
host.appendChild(tip);
|
| 655 |
+
const svg = host.querySelector("svg");
|
| 656 |
+
if (svg) {
|
| 657 |
+
svg.addEventListener("mousemove", (e) => {
|
| 658 |
+
const t = e.target;
|
| 659 |
+
const cls = t.getAttribute && t.getAttribute("class");
|
| 660 |
+
if (!cls || (!cls.includes("rl-dot") && !cls.includes("rl-you"))) { tip.hidden = true; return; }
|
| 661 |
+
const d = t.dataset;
|
| 662 |
+
tip.innerHTML = d.you
|
| 663 |
+
? `<b>Your machine</b><br>${d.m || "your pick"} ≈ <b>${d.t} tok/s</b><br><span class="rl-tip-sub">at ~${d.b} GB/s memory bandwidth</span>`
|
| 664 |
+
: `<b>${d.m || "model"}</b>${d.p ? ` · ${d.p}B` : ""}<br>${d.a || ""}<br><b>${d.t} tok/s</b> <span class="rl-tip-sub">at ~${d.b} GB/s</span>`;
|
| 665 |
+
tip.hidden = false;
|
| 666 |
+
const r = host.getBoundingClientRect();
|
| 667 |
+
const x = e.clientX - r.left, y = e.clientY - r.top;
|
| 668 |
+
tip.style.left = Math.min(x + 14, host.clientWidth - 150) + "px";
|
| 669 |
+
tip.style.top = Math.max(y - 10, 0) + "px";
|
| 670 |
+
});
|
| 671 |
+
svg.addEventListener("mouseleave", () => { tip.hidden = true; });
|
| 672 |
+
}
|
| 673 |
}
|
| 674 |
|
| 675 |
// ---- Live progress ticker (gradio-style elapsed seconds) -------------------
|
|
|
|
| 842 |
wireSegmented("#provider-seg", "provider", fillGpu);
|
| 843 |
wireSegmented("#priority-seg", "priority");
|
| 844 |
wireTaskToggle();
|
| 845 |
+
["#ram","#gpu","#vram","#repo-check"].forEach(s => { const el = $(s); if (el) el.addEventListener("change", maybeLiveUpdate); });
|
| 846 |
$("#paste").addEventListener("input", maybeLiveUpdate);
|
| 847 |
$("#check-btn").addEventListener("click", check);
|
| 848 |
const pb = $("#parse-btn"); if (pb) pb.addEventListener("click", parsePaste);
|
static/icons.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
/* Auto-generated by build_icons.py. Inline icons = offline-safe. */
|
| 2 |
window.ICONS = {
|
| 3 |
"chat": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719\" /> </svg>",
|
|
|
|
| 4 |
"writing": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\" /> <path d=\"M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z\" /> </svg>",
|
| 5 |
"coding": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m18 16 4-4-4-4\" /> <path d=\"m6 8-4 4 4 4\" /> <path d=\"m14.5 4-5 16\" /> </svg>",
|
| 6 |
"agents": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 8V4H8\" /> <rect x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" /> </svg>",
|
|
|
|
| 1 |
/* Auto-generated by build_icons.py. Inline icons = offline-safe. */
|
| 2 |
window.ICONS = {
|
| 3 |
"chat": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719\" /> </svg>",
|
| 4 |
+
"run": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <circle cx=\"14\" cy=\"4\" r=\"1.8\" /> <path d=\"m5 19 3.5-4.5-2-3.5 4.5-2.5\" /> <path d=\"m9.5 10.5 4.5 2 .8 6.5\" /> <path d=\"M11.5 8 15.5 6.5l1.7 3.2 3.3.8\" /> </svg>",
|
| 5 |
"writing": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\" /> <path d=\"M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z\" /> </svg>",
|
| 6 |
"coding": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"m18 16 4-4-4-4\" /> <path d=\"m6 8-4 4 4 4\" /> <path d=\"m14.5 4-5 16\" /> </svg>",
|
| 7 |
"agents": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" > <path d=\"M12 8V4H8\" /> <rect x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" /> </svg>",
|
static/index.html
CHANGED
|
@@ -102,19 +102,15 @@
|
|
| 102 |
<div class="step-head"><span class="step-num">2</span><h2>What do you want to do? <span class="optional">(pick one or several)</span></h2></div>
|
| 103 |
<div class="task-toggle" id="task-toggle" role="tablist"
|
| 104 |
data-tip="Running a model and fine-tuning it need very different memory. Switch this and the recommendations below recompute for training.">
|
| 105 |
-
<button class="tt-opt active" data-task="run" role="tab"><span class="ic" data-ic="
|
| 106 |
<button class="tt-opt" data-task="finetune" role="tab"><span class="ic" data-ic="finetune"></span>Train / fine-tune</button>
|
| 107 |
<span class="tt-slider" aria-hidden="true"></span>
|
| 108 |
</div>
|
| 109 |
<div id="usecase-picker"><!-- rendered by app.js --></div>
|
| 110 |
-
<div class="field" id="custom-uc-field" style="display:none; margin-top:var(--s-3)">
|
| 111 |
-
<span class="label">Describe what you want to build</span>
|
| 112 |
-
<input type="text" id="custom-uc" placeholder="e.g. real-time hand tracking from my webcam" />
|
| 113 |
-
</div>
|
| 114 |
<div class="field" id="repo-field" style="margin-top:var(--s-4); margin-bottom:0">
|
| 115 |
-
<span class="label">
|
| 116 |
<input type="text" id="repo-check" placeholder="Paste a Hugging Face model id or link, e.g. lerobot/smolvla_base" />
|
| 117 |
-
<div class="hint">
|
| 118 |
</div>
|
| 119 |
</div>
|
| 120 |
|
|
|
|
| 102 |
<div class="step-head"><span class="step-num">2</span><h2>What do you want to do? <span class="optional">(pick one or several)</span></h2></div>
|
| 103 |
<div class="task-toggle" id="task-toggle" role="tablist"
|
| 104 |
data-tip="Running a model and fine-tuning it need very different memory. Switch this and the recommendations below recompute for training.">
|
| 105 |
+
<button class="tt-opt active" data-task="run" role="tab"><span class="ic" data-ic="run"></span>Run a model</button>
|
| 106 |
<button class="tt-opt" data-task="finetune" role="tab"><span class="ic" data-ic="finetune"></span>Train / fine-tune</button>
|
| 107 |
<span class="tt-slider" aria-hidden="true"></span>
|
| 108 |
</div>
|
| 109 |
<div id="usecase-picker"><!-- rendered by app.js --></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
<div class="field" id="repo-field" style="margin-top:var(--s-4); margin-bottom:0">
|
| 111 |
+
<span class="label">Check a specific model <span class="optional" id="repo-optional">(optional)</span></span>
|
| 112 |
<input type="text" id="repo-check" placeholder="Paste a Hugging Face model id or link, e.g. lerobot/smolvla_base" />
|
| 113 |
+
<div class="hint">Paste any model and FitCheck gives the full breakdown for it — memory, speed, and how it compares — <b>even models not in our catalogue</b>, via a live Hugging Face lookup. (The rest of FitCheck runs fully offline.)</div>
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
|
static/style.css
CHANGED
|
@@ -448,6 +448,46 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 448 |
.tool .tinstall { font-size: 12.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
|
| 449 |
.tool .tinstall .ic { font-size: 14px; }
|
| 450 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
/* Command block */
|
| 452 |
.cmd-intro { font-size: 14px; color: var(--text-secondary); margin: var(--s-2) 0 var(--s-3); }
|
| 453 |
.cmd { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
|
|
@@ -470,7 +510,17 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 470 |
.rl-grid { stroke: var(--border); stroke-width: 1; opacity: .5; }
|
| 471 |
.rl-tick { fill: var(--text-muted); font: 500 10.5px var(--font-body); }
|
| 472 |
.rl-axis { fill: var(--text-secondary); font: 600 11.5px var(--font-body); }
|
| 473 |
-
.rl-dot { opacity: .6; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
.rl-p1 { fill: #2DD4BF; } /* ~1B runs: teal */
|
| 475 |
.rl-p8 { fill: #F472B6; } /* ~8B runs: pink */
|
| 476 |
.rl-p14 { fill: #93C5FD; } /* ~14B runs: light blue */
|
|
@@ -543,6 +593,9 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 543 |
border-radius: var(--r-md); padding: var(--s-3) var(--s-4); margin-bottom: var(--s-4);
|
| 544 |
}
|
| 545 |
.lookup-banner p { color: var(--text-secondary); font-size: 13.5px; margin: 0; }
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
/* Ask a follow-up (the model brick) */
|
| 548 |
.ask-row { display: flex; gap: var(--s-2); }
|
|
|
|
| 448 |
.tool .tinstall { font-size: 12.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
|
| 449 |
.tool .tinstall .ic { font-size: 14px; }
|
| 450 |
|
| 451 |
+
/* Buy advice: per-platform, click-to-expand cards */
|
| 452 |
+
.buy-platform { margin-top: var(--s-5); }
|
| 453 |
+
.bp-head { display: flex; align-items: center; gap: 9px; }
|
| 454 |
+
.bp-head .ic { font-size: 19px; color: var(--text-secondary); }
|
| 455 |
+
.bp-head .ic.brand { color: var(--text-primary); }
|
| 456 |
+
.bp-name { font-family: var(--font-head); font-weight: 800; font-size: 16px; }
|
| 457 |
+
.bp-blurb { font-size: 13px; color: var(--text-muted); margin: 4px 0 var(--s-3); line-height: 1.5; }
|
| 458 |
+
.buy-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
|
| 459 |
+
@media (max-width: 620px) { .buy-grid { grid-template-columns: 1fr; } }
|
| 460 |
+
.buy-card {
|
| 461 |
+
background: var(--bg-raised); border: 1px solid var(--border);
|
| 462 |
+
border-radius: var(--r-md); padding: var(--s-3) var(--s-4);
|
| 463 |
+
}
|
| 464 |
+
.buy-card.empty { opacity: .7; }
|
| 465 |
+
.bc-head { display: flex; align-items: center; justify-content: space-between; }
|
| 466 |
+
.bc-kind { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
|
| 467 |
+
.bc-kind.min { color: var(--warn); } .bc-kind.comfy { color: var(--ok); }
|
| 468 |
+
.bc-label { font-family: var(--font-head); font-weight: 700; font-size: 15px; margin: 6px 0; }
|
| 469 |
+
.bc-runs { font-size: 13.5px; color: var(--text-secondary); }
|
| 470 |
+
.bc-runs b { color: var(--text-primary); }
|
| 471 |
+
.bc-none { font-size: 13px; color: var(--text-muted); margin: 6px 0 0; line-height: 1.5; }
|
| 472 |
+
.bc-goals { margin: 6px 0 0; padding: 0; list-style: none; font-size: 13px; }
|
| 473 |
+
.bc-goals li { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-top: 1px solid var(--border); }
|
| 474 |
+
.bc-goals li:first-child { border-top: 0; }
|
| 475 |
+
.bc-goals b { color: var(--text-primary); font-weight: 600; text-align: right; }
|
| 476 |
+
.bc-more {
|
| 477 |
+
margin-top: var(--s-3); background: none; border: 0; cursor: pointer; padding: 0;
|
| 478 |
+
color: var(--accent); font-size: 12.5px; font-weight: 600;
|
| 479 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 480 |
+
}
|
| 481 |
+
.bc-more .chev { font-size: 14px; transition: transform .2s; }
|
| 482 |
+
.bc-more.open .chev { transform: rotate(180deg); }
|
| 483 |
+
.bc-detail { margin-top: var(--s-3); border-top: 1px solid var(--border); padding-top: var(--s-3); }
|
| 484 |
+
.bc-spec {
|
| 485 |
+
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px;
|
| 486 |
+
color: var(--text-secondary); background: var(--bg-base);
|
| 487 |
+
border: 1px solid var(--border); border-radius: var(--r-sm); padding: 7px 10px; margin-bottom: var(--s-2);
|
| 488 |
+
}
|
| 489 |
+
.bc-detail p { font-size: 13px; color: var(--text-secondary); line-height: 1.6; margin: 0; }
|
| 490 |
+
|
| 491 |
/* Command block */
|
| 492 |
.cmd-intro { font-size: 14px; color: var(--text-secondary); margin: var(--s-2) 0 var(--s-3); }
|
| 493 |
.cmd { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-3); }
|
|
|
|
| 510 |
.rl-grid { stroke: var(--border); stroke-width: 1; opacity: .5; }
|
| 511 |
.rl-tick { fill: var(--text-muted); font: 500 10.5px var(--font-body); }
|
| 512 |
.rl-axis { fill: var(--text-secondary); font: 600 11.5px var(--font-body); }
|
| 513 |
+
.rl-dot { opacity: .6; transition: r .1s, opacity .1s; cursor: pointer; }
|
| 514 |
+
.rl-dot:hover { opacity: 1; r: 4.5; }
|
| 515 |
+
.rl-you { cursor: pointer; }
|
| 516 |
+
/* Hover info bubble for chart points */
|
| 517 |
+
.rl-tip {
|
| 518 |
+
position: absolute; z-index: 20; pointer-events: none;
|
| 519 |
+
background: var(--bg-raised); border: 1px solid var(--border-hi);
|
| 520 |
+
border-radius: var(--r-sm); padding: 7px 10px; font-size: 12px; line-height: 1.5;
|
| 521 |
+
color: var(--text-primary); box-shadow: var(--shadow-lg); max-width: 200px;
|
| 522 |
+
}
|
| 523 |
+
.rl-tip-sub { color: var(--text-muted); }
|
| 524 |
.rl-p1 { fill: #2DD4BF; } /* ~1B runs: teal */
|
| 525 |
.rl-p8 { fill: #F472B6; } /* ~8B runs: pink */
|
| 526 |
.rl-p14 { fill: #93C5FD; } /* ~14B runs: light blue */
|
|
|
|
| 593 |
border-radius: var(--r-md); padding: var(--s-3) var(--s-4); margin-bottom: var(--s-4);
|
| 594 |
}
|
| 595 |
.lookup-banner p { color: var(--text-secondary); font-size: 13.5px; margin: 0; }
|
| 596 |
+
.lookup-banner.estimate { background: var(--warn-soft); border-color: var(--warn); }
|
| 597 |
+
/* When the goal is "Custom", the model box is the expected input, not optional. */
|
| 598 |
+
#repo-field.required input { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
| 599 |
|
| 600 |
/* Ask a follow-up (the model brick) */
|
| 601 |
.ask-row { display: flex; gap: var(--s-2); }
|