cn0303 commited on
Commit
1bbff15
·
verified ·
1 Parent(s): a87d7a7

Multi-goal checks (union buying advice), fix vision/audio quality bars, honest niche fallback

Browse files
Files changed (6) hide show
  1. app.py +2 -1
  2. engine/real_advisor.py +66 -22
  3. model_brick.py +2 -2
  4. static/app.js +74 -10
  5. static/index.html +1 -1
  6. 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.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
@@ -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, 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"),
@@ -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
- # 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:
@@ -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(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
@@ -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": 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
  }
 
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=120)
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=192, 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()
 
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", 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() {
@@ -130,11 +131,17 @@ function buildPicker() {
130
  </div>`).join("");
131
  hydrate(wrap);
132
 
 
133
  wrap.querySelectorAll(".uc-pill").forEach(p => p.addEventListener("click", () => {
134
- wrap.querySelectorAll(".uc-pill").forEach(x => x.classList.remove("active"));
135
- p.classList.add("active");
136
- state.usecase = p.dataset.uc;
137
- $("#custom-uc-field").style.display = state.usecase === "custom" ? "block" : "none";
 
 
 
 
 
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.usecase,
 
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({ 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),
@@ -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
- <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>`;
@@ -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…</div>`;
 
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);