Spaces:
Running on Zero
Running on Zero
Multi-goal checks (union buying advice), fix vision/audio quality bars, honest niche fallback
Browse files- app.py +2 -1
- engine/real_advisor.py +66 -22
- model_brick.py +2 -2
- static/app.js +74 -10
- static/index.html +1 -1
- static/style.css +38 -1
app.py
CHANGED
|
@@ -55,11 +55,12 @@ def api_advise(payload: AdviseIn):
|
|
| 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):
|
|
|
|
| 55 |
|
| 56 |
class MinSpecsIn(BaseModel):
|
| 57 |
usecase: str = "chat"
|
| 58 |
+
usecases: list[str] | None = None # multi-goal: union of requirements
|
| 59 |
|
| 60 |
|
| 61 |
@app.post("/api/minspecs")
|
| 62 |
def api_minspecs(payload: MinSpecsIn):
|
| 63 |
+
return min_specs(payload.usecases or [payload.usecase])
|
| 64 |
|
| 65 |
|
| 66 |
class LookupIn(AdviseIn):
|
engine/real_advisor.py
CHANGED
|
@@ -49,8 +49,12 @@ _COMPROMISE_QUANTS = ["Q4_K_M", "IQ4_XS", "Q3_K_M", "Q2_K"]
|
|
| 49 |
# --------------------------------------------------------------------------
|
| 50 |
|
| 51 |
class UC:
|
| 52 |
-
def __init__(self, key, plain, family, ctx=4096, min_b=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
|
|
@@ -69,7 +73,7 @@ USE_CASES = {u.key: u for u in [
|
|
| 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,
|
| 73 |
UC("detect", "Object detection", "vision"),
|
| 74 |
UC("segment", "Image segmentation", "vision"),
|
| 75 |
UC("pose", "Pose estimation (2D & 6-DoF)", "vision"),
|
|
@@ -326,9 +330,12 @@ def _pick_headline(results: list[dict], uc: UC) -> tuple[dict | None, bool]:
|
|
| 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 |
-
|
| 330 |
-
|
| 331 |
-
|
|
|
|
|
|
|
|
|
|
| 332 |
if great:
|
| 333 |
return max(great, key=params), False
|
| 334 |
if tight:
|
|
@@ -358,6 +365,25 @@ def _provenance_line(headline: dict | None) -> str:
|
|
| 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
|
|
@@ -462,6 +488,7 @@ def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
|
| 462 |
"provenance": _provenance_line(headline),
|
| 463 |
"meets_goal": meets_goal,
|
| 464 |
"use_case": uc.plain_name,
|
|
|
|
| 465 |
}
|
| 466 |
|
| 467 |
|
|
@@ -499,26 +526,39 @@ def _spec_for_tier(kind: str, hw: dict) -> HardwareSpec:
|
|
| 499 |
vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
|
| 500 |
|
| 501 |
|
| 502 |
-
def min_specs(
|
| 503 |
-
"""For
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
|
|
|
|
|
|
|
|
|
| 520 |
minimum = tier
|
| 521 |
-
if comfortable is None and
|
| 522 |
comfortable = tier
|
| 523 |
if minimum and comfortable:
|
| 524 |
break
|
|
@@ -526,13 +566,17 @@ def min_specs(usecase: str) -> dict:
|
|
| 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":
|
| 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 |
}
|
|
|
|
| 49 |
# --------------------------------------------------------------------------
|
| 50 |
|
| 51 |
class UC:
|
| 52 |
+
def __init__(self, key, plain, family, ctx=4096, min_b=0.0, good_b=0.0,
|
| 53 |
factor=1.0, note=""):
|
| 54 |
+
# min_b/good_b are LLM-quality bars in billions of params. They default
|
| 55 |
+
# to 0 because they're meaningless for vision/audio/etc. — a 0.003B
|
| 56 |
+
# YOLO is a complete, excellent model, not a too-small LLM. Only the
|
| 57 |
+
# text use cases set them explicitly.
|
| 58 |
self.key, self.plain_name, self.family = key, plain, family
|
| 59 |
self.context_tokens, self.min_b, self.good_b = ctx, min_b, good_b
|
| 60 |
self.overhead_factor, self.note = factor, note
|
|
|
|
| 73 |
UC("finetune", "Fine-tune an LLM (LoRA)", "llm", 2048, 3.0, 7.0, 2.2,
|
| 74 |
note="Training needs roughly 2-3x the memory of just chatting. That's baked into these numbers."),
|
| 75 |
UC("custom", "Your custom goal", "llm", 4096, 0.5, 7.0),
|
| 76 |
+
UC("vlm", "Chat about images & video", "vlm", 4096, 1.5, 4.0),
|
| 77 |
UC("detect", "Object detection", "vision"),
|
| 78 |
UC("segment", "Image segmentation", "vision"),
|
| 79 |
UC("pose", "Pose estimation (2D & 6-DoF)", "vision"),
|
|
|
|
| 330 |
# Fast-and-capable is the best answer: biggest model that runs great.
|
| 331 |
return max(great_ok, key=params), True
|
| 332 |
if tight_ok:
|
| 333 |
+
if uc.good_b > 0:
|
| 334 |
+
# LLMs: close to the ideal size, not needlessly oversized-and-slow.
|
| 335 |
+
below = [r for r in tight_ok if params(r) <= uc.good_b * 1.5]
|
| 336 |
+
return (max(below, key=params) if below else min(tight_ok, key=params)), True
|
| 337 |
+
# Non-LLM families: the biggest model that fits is simply the best one.
|
| 338 |
+
return max(tight_ok, key=params), True
|
| 339 |
if great:
|
| 340 |
return max(great, key=params), False
|
| 341 |
if tight:
|
|
|
|
| 365 |
def advise_real(payload: dict, spec: HardwareSpec) -> dict:
|
| 366 |
uc = USE_CASES.get(payload.get("usecase", "chat"), USE_CASES["chat"])
|
| 367 |
candidates = _by_use_case().get(uc.key, [])
|
| 368 |
+
|
| 369 |
+
# Honest gap, not a fake answer: if the catalogue doesn't cover a goal yet,
|
| 370 |
+
# say so and point at the live lookup instead of inventing options.
|
| 371 |
+
if not candidates:
|
| 372 |
+
return {
|
| 373 |
+
"catalogue_version": catalogue_date(),
|
| 374 |
+
"verdict": "tight", "verdict_word": "Not covered yet",
|
| 375 |
+
"headline": "Our catalogue doesn't cover this goal yet.",
|
| 376 |
+
"detail": ("FitCheck only answers from verified model data, and nothing in the "
|
| 377 |
+
"current catalogue serves this goal — so rather than guess, we'd "
|
| 378 |
+
"rather say so. If you know a specific model for it, paste its "
|
| 379 |
+
"Hugging Face id in the <b>'Have a specific model in mind?'</b> box "
|
| 380 |
+
"and we'll check that exact model against your machine."),
|
| 381 |
+
"note": "The catalogue grows every night; niche goals are next in line.",
|
| 382 |
+
"gauge": {}, "options": [], "tools": _TOOLS.get(uc.family, []),
|
| 383 |
+
"commands": {"intro": "", "items": []}, "provenance": "",
|
| 384 |
+
"meets_goal": False, "use_case": uc.plain_name,
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
results = [_evaluate(e, spec, uc) for e in candidates]
|
| 388 |
|
| 389 |
fast, total = spec.fast_budget_gb, spec.total_budget_gb
|
|
|
|
| 488 |
"provenance": _provenance_line(headline),
|
| 489 |
"meets_goal": meets_goal,
|
| 490 |
"use_case": uc.plain_name,
|
| 491 |
+
"headline_model": headline["entry"]["name"] if headline else "",
|
| 492 |
}
|
| 493 |
|
| 494 |
|
|
|
|
| 526 |
vram_gb=hw.get("vram_gb", 0.0), form_factor="desktop")
|
| 527 |
|
| 528 |
|
| 529 |
+
def min_specs(usecases) -> dict:
|
| 530 |
+
"""For one OR several goals: the cheapest tier where EVERY goal genuinely
|
| 531 |
+
works (the union of requirements, not a sum), the tier where every goal
|
| 532 |
+
runs great, and what each goal would actually run on those tiers.
|
| 533 |
+
Pure engine inversion — fully offline."""
|
| 534 |
+
if isinstance(usecases, str):
|
| 535 |
+
usecases = [usecases]
|
| 536 |
+
seen = set()
|
| 537 |
+
ucs = []
|
| 538 |
+
for u in usecases or ["chat"]:
|
| 539 |
+
uc = USE_CASES.get(u, USE_CASES["chat"])
|
| 540 |
+
if uc.key not in seen:
|
| 541 |
+
seen.add(uc.key)
|
| 542 |
+
ucs.append(uc)
|
| 543 |
|
| 544 |
def walk(kind, ladder):
|
| 545 |
minimum = comfortable = None
|
| 546 |
for label, hw, price in ladder:
|
| 547 |
spec = _spec_for_tier(kind, hw)
|
| 548 |
+
per_goal, all_meet, all_great = [], True, True
|
| 549 |
+
for uc in ucs:
|
| 550 |
+
res = advise_real({"usecase": uc.key}, spec)
|
| 551 |
+
all_meet &= res["meets_goal"] and res["verdict"] in ("great", "tight")
|
| 552 |
+
all_great &= res["meets_goal"] and res["verdict"] == "great"
|
| 553 |
+
per_goal.append({"goal": uc.plain_name,
|
| 554 |
+
"model": res["headline_model"] or "nothing realistic",
|
| 555 |
+
"verdict": res["verdict"]})
|
| 556 |
+
tier = {"label": label, "price": price, "goals": per_goal,
|
| 557 |
+
"runs": "; ".join(f"{g['goal']}: {g['model']}" for g in per_goal)
|
| 558 |
+
if len(per_goal) > 1 else per_goal[0]["model"]}
|
| 559 |
+
if minimum is None and all_meet:
|
| 560 |
minimum = tier
|
| 561 |
+
if comfortable is None and all_great:
|
| 562 |
comfortable = tier
|
| 563 |
if minimum and comfortable:
|
| 564 |
break
|
|
|
|
| 566 |
|
| 567 |
pc_min, pc_comfy = walk("pc", _PC_LADDER)
|
| 568 |
mac_min, mac_comfy = walk("mac", _MAC_LADDER)
|
| 569 |
+
notes = [uc.note for uc in ucs if uc.note]
|
| 570 |
return {
|
| 571 |
+
"use_case": " + ".join(uc.plain_name for uc in ucs),
|
| 572 |
+
"goals": [uc.plain_name for uc in ucs],
|
| 573 |
"catalogue_version": catalogue_date(),
|
| 574 |
+
"note": " ".join(notes),
|
| 575 |
"pc": {"minimum": pc_min, "comfortable": pc_comfy},
|
| 576 |
"mac": {"minimum": mac_min, "comfortable": mac_comfy},
|
| 577 |
"disclaimer": ("Price hints are rough 2026 street prices for a sensible whole "
|
| 578 |
"build — they vary a lot by region and second-hand luck. The "
|
| 579 |
+
"memory math is the same conservative engine as the main check."
|
| 580 |
+
+ (" Tiers are the union of every goal you picked: each one has "
|
| 581 |
+
"to genuinely work." if len(ucs) > 1 else "")),
|
| 582 |
}
|
model_brick.py
CHANGED
|
@@ -229,7 +229,7 @@ if _should_load():
|
|
| 229 |
_state["tok"] = tok
|
| 230 |
_state["model"] = model.to("cuda").eval()
|
| 231 |
|
| 232 |
-
@spaces.GPU(duration=
|
| 233 |
def _generate(question: str, facts_text: str) -> str:
|
| 234 |
if _state["model"] is None:
|
| 235 |
_load()
|
|
@@ -248,7 +248,7 @@ if _should_load():
|
|
| 248 |
prompt_len = inputs["input_ids"].shape[1]
|
| 249 |
with torch.no_grad():
|
| 250 |
out = model.generate(
|
| 251 |
-
**inputs, max_new_tokens=
|
| 252 |
pad_token_id=tok.eos_token_id,
|
| 253 |
)
|
| 254 |
return tok.decode(out[0][prompt_len:], skip_special_tokens=True).strip()
|
|
|
|
| 229 |
_state["tok"] = tok
|
| 230 |
_state["model"] = model.to("cuda").eval()
|
| 231 |
|
| 232 |
+
@spaces.GPU(duration=90) # cold load ~50s + generate; shorter = better queue priority
|
| 233 |
def _generate(question: str, facts_text: str) -> str:
|
| 234 |
if _state["model"] is None:
|
| 235 |
_load()
|
|
|
|
| 248 |
prompt_len = inputs["input_ids"].shape[1]
|
| 249 |
with torch.no_grad():
|
| 250 |
out = model.generate(
|
| 251 |
+
**inputs, max_new_tokens=160, do_sample=False,
|
| 252 |
pad_token_id=tok.eos_token_id,
|
| 253 |
)
|
| 254 |
return tok.decode(out[0][prompt_len:], skip_special_tokens=True).strip()
|
static/app.js
CHANGED
|
@@ -74,8 +74,9 @@ const GPUS = {
|
|
| 74 |
};
|
| 75 |
|
| 76 |
const $ = (s) => document.querySelector(s);
|
| 77 |
-
const state = { mode: "have", computer: "Windows laptop", provider: "none", priority: "balanced",
|
| 78 |
let lastAdvice = null; // the most recent /api/advise result — facts the model explains
|
|
|
|
| 79 |
|
| 80 |
// ---- Buy-vs-check mode -----------------------------------------------------
|
| 81 |
function applyMode() {
|
|
@@ -130,11 +131,17 @@ function buildPicker() {
|
|
| 130 |
</div>`).join("");
|
| 131 |
hydrate(wrap);
|
| 132 |
|
|
|
|
| 133 |
wrap.querySelectorAll(".uc-pill").forEach(p => p.addEventListener("click", () => {
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
maybeLiveUpdate();
|
| 139 |
}));
|
| 140 |
}
|
|
@@ -200,7 +207,8 @@ function gather() {
|
|
| 200 |
gpu: sel.style.display === "none" ? "" : sel.value,
|
| 201 |
vram_gb: $("#vram").value ? parseFloat($("#vram").value) : null,
|
| 202 |
paste: $("#paste").value.trim(),
|
| 203 |
-
usecase: state.
|
|
|
|
| 204 |
custom: $("#custom-uc").value.trim(),
|
| 205 |
priority: state.priority,
|
| 206 |
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
|
@@ -221,11 +229,23 @@ async function check() {
|
|
| 221 |
if (state.mode === "buy") {
|
| 222 |
const res = await fetch("/api/minspecs", {
|
| 223 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 224 |
-
body: JSON.stringify({
|
| 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),
|
|
@@ -279,17 +299,53 @@ async function lookupRepo(payload) {
|
|
| 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 |
-
|
| 291 |
</div>` : `
|
| 292 |
-
<div class="tool"><div class="twhat">No tier on this ladder handles it comfortably — this
|
| 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>`;
|
|
@@ -408,6 +464,13 @@ function render(d) {
|
|
| 408 |
</div>
|
| 409 |
</div>`;
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
hydrate($("#results"));
|
| 412 |
$("#results").querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => {
|
| 413 |
navigator.clipboard.writeText(decodeURIComponent(b.dataset.code));
|
|
@@ -433,7 +496,8 @@ async function askQuestion(question) {
|
|
| 433 |
const box = $("#ask-answer");
|
| 434 |
if (!question || !box) return;
|
| 435 |
box.hidden = false;
|
| 436 |
-
box.innerHTML = `<div class="ans-loading"><span class="spinner"></span>Thinking it through…
|
|
|
|
| 437 |
try {
|
| 438 |
const a = await callAsk(question, JSON.stringify(lastAdvice || {}));
|
| 439 |
renderAnswer(box, a);
|
|
|
|
| 74 |
};
|
| 75 |
|
| 76 |
const $ = (s) => document.querySelector(s);
|
| 77 |
+
const state = { mode: "have", computer: "Windows laptop", provider: "none", priority: "balanced", usecases: ["chat"], checked: false };
|
| 78 |
let lastAdvice = null; // the most recent /api/advise result — facts the model explains
|
| 79 |
+
let multiCache = null; // {ucs, results} when several goals are checked at once
|
| 80 |
|
| 81 |
// ---- Buy-vs-check mode -----------------------------------------------------
|
| 82 |
function applyMode() {
|
|
|
|
| 131 |
</div>`).join("");
|
| 132 |
hydrate(wrap);
|
| 133 |
|
| 134 |
+
// Pills toggle: pick one goal or several (several = checked together).
|
| 135 |
wrap.querySelectorAll(".uc-pill").forEach(p => p.addEventListener("click", () => {
|
| 136 |
+
const uc = p.dataset.uc;
|
| 137 |
+
const i = state.usecases.indexOf(uc);
|
| 138 |
+
if (i >= 0) {
|
| 139 |
+
if (state.usecases.length > 1) { state.usecases.splice(i, 1); p.classList.remove("active"); }
|
| 140 |
+
} else {
|
| 141 |
+
state.usecases.push(uc);
|
| 142 |
+
p.classList.add("active");
|
| 143 |
+
}
|
| 144 |
+
$("#custom-uc-field").style.display = state.usecases.includes("custom") ? "block" : "none";
|
| 145 |
maybeLiveUpdate();
|
| 146 |
}));
|
| 147 |
}
|
|
|
|
| 207 |
gpu: sel.style.display === "none" ? "" : sel.value,
|
| 208 |
vram_gb: $("#vram").value ? parseFloat($("#vram").value) : null,
|
| 209 |
paste: $("#paste").value.trim(),
|
| 210 |
+
usecase: state.usecases[0],
|
| 211 |
+
usecases: state.usecases.slice(),
|
| 212 |
custom: $("#custom-uc").value.trim(),
|
| 213 |
priority: state.priority,
|
| 214 |
repo: $("#repo-check") ? $("#repo-check").value.trim() : "",
|
|
|
|
| 229 |
if (state.mode === "buy") {
|
| 230 |
const res = await fetch("/api/minspecs", {
|
| 231 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 232 |
+
body: JSON.stringify({ usecases: state.usecases }),
|
| 233 |
});
|
| 234 |
renderBuy(await res.json());
|
| 235 |
return;
|
| 236 |
}
|
| 237 |
+
if (state.usecases.length > 1) {
|
| 238 |
+
const results = await Promise.all(state.usecases.map(u =>
|
| 239 |
+
fetch("/api/advise", {
|
| 240 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 241 |
+
body: JSON.stringify({ ...payload, usecase: u }),
|
| 242 |
+
}).then(r => r.json())));
|
| 243 |
+
multiCache = { ucs: state.usecases.slice(), results };
|
| 244 |
+
renderMulti(results);
|
| 245 |
+
if (payload.repo) lookupRepo(payload);
|
| 246 |
+
return;
|
| 247 |
+
}
|
| 248 |
+
multiCache = null;
|
| 249 |
const res = await fetch("/api/advise", {
|
| 250 |
method: "POST", headers: { "Content-Type": "application/json" },
|
| 251 |
body: JSON.stringify(payload),
|
|
|
|
| 299 |
}
|
| 300 |
}
|
| 301 |
|
| 302 |
+
// ---- Multi-goal overview (several goals checked at once) -------------------
|
| 303 |
+
function renderMulti(results) {
|
| 304 |
+
const ok = results.filter(d => d.verdict === "great").length;
|
| 305 |
+
const cards = results.map((d, i) => {
|
| 306 |
+
const v = VMAP[d.verdict] || VMAP.tight;
|
| 307 |
+
const need = (d.gauge || {}).need_gb || "";
|
| 308 |
+
return `
|
| 309 |
+
<div class="goal-card" data-i="${i}" style="--status:${v.cls};--status-soft:${v.soft}">
|
| 310 |
+
<div class="goal-top"><span class="badge"><span class="dot"></span>${d.verdict_word || v.word}</span></div>
|
| 311 |
+
<div class="goal-name">${d.use_case || ""}</div>
|
| 312 |
+
<div class="goal-pick">${d.headline_model ? `→ ${d.headline_model}` : "Nothing realistic on this machine"}</div>
|
| 313 |
+
<div class="goal-need">${need}</div>
|
| 314 |
+
<div class="goal-more">See full breakdown</div>
|
| 315 |
+
</div>`;
|
| 316 |
+
}).join("");
|
| 317 |
+
$("#results").innerHTML = `
|
| 318 |
+
<div class="reveal">
|
| 319 |
+
<div id="lookup-result"></div>
|
| 320 |
+
<div class="verdict" style="--status:var(--accent);--status-soft:var(--accent-soft)">
|
| 321 |
+
<span class="badge"><span class="dot"></span>${results.length} goals checked</span>
|
| 322 |
+
<h2>${ok} of ${results.length} run great on this machine.</h2>
|
| 323 |
+
<p>Each goal is checked independently with the same conservative engine. Click any card for the full honest breakdown, links and commands.</p>
|
| 324 |
+
</div>
|
| 325 |
+
<div class="goal-grid">${cards}</div>
|
| 326 |
+
</div>`;
|
| 327 |
+
hydrate($("#results"));
|
| 328 |
+
$("#results").querySelectorAll(".goal-card").forEach(c => c.addEventListener("click", () => {
|
| 329 |
+
render(multiCache.results[parseInt(c.dataset.i, 10)]);
|
| 330 |
+
}));
|
| 331 |
+
$("#cat-version").textContent = (results[0] || {}).catalogue_version || "—";
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
// ---- Buy-advice render ------------------------------------------------------
|
| 335 |
function renderBuy(d) {
|
| 336 |
+
const goalLines = (t) => (t.goals && t.goals.length > 1)
|
| 337 |
+
? `<ul class="goal-lines">${t.goals.map(g =>
|
| 338 |
+
`<li><span>${g.goal}</span><b>${g.model}</b>${g.verdict === "tight" ? " <i>(trade-offs)</i>" : ""}</li>`).join("")}</ul>`
|
| 339 |
+
: `<div class="twhat">Runs: ${t.runs}${t.goals && t.goals[0] && t.goals[0].verdict === "tight" ? " (with trade-offs)" : ""}</div>`;
|
| 340 |
const lane = (title, icon, lanes) => {
|
| 341 |
const tier = (t, kind) => t ? `
|
| 342 |
<div class="tool" style="border-left:3px solid ${kind === "min" ? "var(--warn)" : "var(--ok)"}">
|
| 343 |
<div class="tool-head"><span class="tname">${kind === "min" ? "Minimum" : "Comfortable"}</span>
|
| 344 |
<span class="tag ${kind === "min" ? "mid" : "best"}">${t.price}</span></div>
|
| 345 |
<div class="twhat"><b>${t.label}</b></div>
|
| 346 |
+
${goalLines(t)}
|
| 347 |
</div>` : `
|
| 348 |
+
<div class="tool"><div class="twhat">No tier on this ladder handles ${d.goals && d.goals.length > 1 ? "all of these together" : "it"} comfortably — this combination wants workstation hardware.</div></div>`;
|
| 349 |
return `
|
| 350 |
<div class="section-title"><span class="ic" data-ic="${icon}"></span>${title}</div>
|
| 351 |
<div class="tool-grid">${tier(lanes.minimum, "min")}${tier(lanes.comfortable, "comfy")}</div>`;
|
|
|
|
| 464 |
</div>
|
| 465 |
</div>`;
|
| 466 |
|
| 467 |
+
if (multiCache) {
|
| 468 |
+
const back = document.createElement("button");
|
| 469 |
+
back.className = "back-link";
|
| 470 |
+
back.textContent = "← All goals";
|
| 471 |
+
back.addEventListener("click", () => renderMulti(multiCache.results));
|
| 472 |
+
$("#results").firstElementChild.prepend(back);
|
| 473 |
+
}
|
| 474 |
hydrate($("#results"));
|
| 475 |
$("#results").querySelectorAll(".copy-btn").forEach(b => b.addEventListener("click", () => {
|
| 476 |
navigator.clipboard.writeText(decodeURIComponent(b.dataset.code));
|
|
|
|
| 496 |
const box = $("#ask-answer");
|
| 497 |
if (!question || !box) return;
|
| 498 |
box.hidden = false;
|
| 499 |
+
box.innerHTML = `<div class="ans-loading"><span class="spinner"></span>Thinking it through…
|
| 500 |
+
<span class="ans-cold">first question after a quiet spell wakes the model (up to a minute); after that it's a few seconds</span></div>`;
|
| 501 |
try {
|
| 502 |
const a = await callAsk(question, JSON.stringify(lastAdvice || {}));
|
| 503 |
renderAnswer(box, a);
|
static/index.html
CHANGED
|
@@ -97,7 +97,7 @@
|
|
| 97 |
|
| 98 |
<!-- Step 2: goal -->
|
| 99 |
<div class="step">
|
| 100 |
-
<div class="step-head"><span class="step-num">2</span><h2>What do you want to do?</h2></div>
|
| 101 |
<div id="usecase-picker"><!-- rendered by app.js --></div>
|
| 102 |
<div class="field" id="custom-uc-field" style="display:none; margin-top:var(--s-3)">
|
| 103 |
<span class="label">Describe what you want to build</span>
|
|
|
|
| 97 |
|
| 98 |
<!-- Step 2: goal -->
|
| 99 |
<div class="step">
|
| 100 |
+
<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>
|
| 101 |
<div id="usecase-picker"><!-- rendered by app.js --></div>
|
| 102 |
<div class="field" id="custom-uc-field" style="display:none; margin-top:var(--s-3)">
|
| 103 |
<span class="label">Describe what you want to build</span>
|
static/style.css
CHANGED
|
@@ -377,6 +377,42 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 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));
|
|
@@ -431,7 +467,8 @@ details.disc > summary:hover { color: var(--text-primary); }
|
|
| 431 |
.ans-card.ans-error { border-left-color: var(--no); }
|
| 432 |
.ans-card.ans-error h3 { color: var(--no); }
|
| 433 |
.ans-card.ans-error p { color: var(--text-secondary); font-size: 13.5px; word-break: break-word; }
|
| 434 |
-
.ans-loading { display: flex; align-items: center; gap: var(--s-2); color: var(--text-muted); font-size: 14px; padding: var(--s-2) 0; }
|
|
|
|
| 435 |
.spinner {
|
| 436 |
width: 15px; height: 15px; flex: none; border-radius: 50%;
|
| 437 |
border: 2px solid var(--border-hi); border-top-color: var(--accent);
|
|
|
|
| 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 |
+
/* Multi-goal overview */
|
| 381 |
+
.goal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--s-3); }
|
| 382 |
+
.goal-card {
|
| 383 |
+
background: var(--bg-raised); border: 1px solid var(--border);
|
| 384 |
+
border-left: 4px solid var(--status, var(--border)); border-radius: var(--r-md);
|
| 385 |
+
padding: var(--s-4); cursor: pointer;
|
| 386 |
+
transition: transform .2s, box-shadow .2s;
|
| 387 |
+
}
|
| 388 |
+
.goal-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
|
| 389 |
+
.goal-card .badge {
|
| 390 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 391 |
+
padding: 4px 10px; border-radius: var(--r-pill);
|
| 392 |
+
background: var(--status-soft); color: var(--status);
|
| 393 |
+
font: 700 11.5px/1 var(--font-head); text-transform: uppercase; letter-spacing: .04em;
|
| 394 |
+
}
|
| 395 |
+
.goal-card .badge .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
| 396 |
+
.goal-name { font: 700 15px/1.3 var(--font-head); margin-top: var(--s-3); }
|
| 397 |
+
.goal-pick { font-size: 13.5px; color: var(--text-secondary); margin-top: 4px; }
|
| 398 |
+
.goal-need { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
| 399 |
+
.goal-more { font-size: 12.5px; font-weight: 600; color: var(--accent); margin-top: var(--s-3); }
|
| 400 |
+
.back-link {
|
| 401 |
+
background: var(--bg-inset); border: 1px solid var(--border); color: var(--text-secondary);
|
| 402 |
+
border-radius: var(--r-pill); padding: 6px 14px; font-size: 13px; font-weight: 600;
|
| 403 |
+
margin-bottom: var(--s-4); display: inline-block;
|
| 404 |
+
}
|
| 405 |
+
.back-link:hover { color: var(--text-primary); border-color: var(--border-hi); }
|
| 406 |
+
.goal-lines { list-style: none; padding: 0; margin: var(--s-2) 0 0; }
|
| 407 |
+
.goal-lines li {
|
| 408 |
+
display: flex; justify-content: space-between; gap: var(--s-3);
|
| 409 |
+
font-size: 13px; color: var(--text-secondary); padding: 3px 0;
|
| 410 |
+
border-bottom: 1px dashed var(--border);
|
| 411 |
+
}
|
| 412 |
+
.goal-lines li:last-child { border-bottom: none; }
|
| 413 |
+
.goal-lines li b { color: var(--text-primary); font-weight: 600; text-align: right; }
|
| 414 |
+
.goal-lines li i { color: var(--text-muted); font-style: normal; font-size: 12px; }
|
| 415 |
+
|
| 416 |
/* Live single-model lookup card */
|
| 417 |
.lookup-card {
|
| 418 |
border: 1px solid var(--border); border-left: 4px solid var(--status, var(--accent));
|
|
|
|
| 467 |
.ans-card.ans-error { border-left-color: var(--no); }
|
| 468 |
.ans-card.ans-error h3 { color: var(--no); }
|
| 469 |
.ans-card.ans-error p { color: var(--text-secondary); font-size: 13.5px; word-break: break-word; }
|
| 470 |
+
.ans-loading { display: flex; align-items: center; gap: var(--s-2); color: var(--text-muted); font-size: 14px; padding: var(--s-2) 0; flex-wrap: wrap; }
|
| 471 |
+
.ans-cold { font-size: 12px; color: var(--text-muted); opacity: .75; }
|
| 472 |
.spinner {
|
| 473 |
width: 15px; height: 15px; flex: none; border-radius: 50%;
|
| 474 |
border: 2px solid var(--border-hi); border-top-color: var(--accent);
|