fix(ux): Idiotensicherheit — 8 Audit-Fixes

Behebung der drei kritischsten UX-Schwachstellen aus dem Produkt-Audit:

1. ConnectPanel: Steps 2-4 (Modellauswahl, Tool-Picker, Config-Snippet,
   Vision) werden blockiert wenn noch kein Modell installiert ist.
   Stattdessen klarer Hinweis "Erst im Cookbook ein Modell holen" mit
   direktem CTA. Verhindert dass Anfänger ein Snippet mit Fake-"coder"
   kopieren, bevor ein echtes Modell existiert.

2. OverviewPanel: preload() schluckte Fehler still (catch {}). Zeigt
   jetzt einen verständlichen Toast wenn die Engine nicht erreichbar ist.
   Schnellstart ist kontextsensitiv: ohne Modelle zeigt er Onboarding-
   Schritte (Modell holen → Verbinden → Guide lesen) statt sinnloser
   Aktionen wie "Speicher freigeben".

3. CookbookPanel Profi-Suche: doDownload() nutzte zwei sequenzielle
   API-Calls (download + register) — wenn der zweite scheiterte, blieb
   das System in einem inkonsistenten Halbzustand. Jetzt ein einziger
   atomarer Aufruf an /api/cookbook/install-model (wie Recipe/Discover).
   Backend-Erweiterung: InstallModelReq akzeptiert jetzt 'file' und
   'ctx' um Nutzer-Auswahl direkt zu übergeben.

4. Cookbook Section 1 als "Hier starten" markiert ("1 · Anwendungsfall
   wählen & installieren" mit erklärendem Sub-Text).

5. Post-Download-Toasts: Alle drei Install-Pfade sagen nun einheitlich
   "Modell erscheint danach automatisch im Modelle-Tab" statt nur
   "siehe Aktivität".

6. too_tight-Alternative: Bei Modellen/Setups die den Speicher übersteigen
   wird jetzt eine konkrete Alternative (kleinere Datei / passendes Rezept)
   verlinkt statt nur "Trotzdem installieren".

7. Duplikat extract_params_b aus cookbook.py entfernt — importiert jetzt
   aus hw_math (Single Source of Truth).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-23 08:31:34 +02:00
parent a93751fb01
commit c33fe57433
5 changed files with 213 additions and 152 deletions
+26 -10
View File
@@ -115,8 +115,10 @@
</div>
</div>
<!-- Step 2: Model selector -->
<!-- Steps 24: nur anzeigen wenn Modelle installiert sind -->
{#if allModels.length}
<!-- Step 2: Model selector -->
<div class="card">
<div class="card-h">
<h3>2 · Modelle auswählen</h3>
@@ -140,10 +142,9 @@
</div>
{/if}
</div>
{/if}
<!-- Step 3: Tool picker -->
<div class="card">
<!-- Step 3: Tool picker -->
<div class="card">
<div class="card-h"><h3>3 · Tool wählen</h3></div>
<div class="card-sub">Für welches Werkzeug brauchst du die Konfiguration?</div>
<div class="grid grid-4" style="gap:8px">
@@ -159,10 +160,10 @@
</button>
{/each}
</div>
</div>
</div>
<!-- Step 4: Config snippet -->
<div class="card">
<!-- Step 4: Config snippet -->
<div class="card">
<div class="card-h"><h3>4 · Config-Snippet</h3></div>
{#if selectedTool === 'zed'}
{@const block = zedBlock(url, activeIds)}
@@ -210,10 +211,10 @@
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div>
{/if}
</div>
</div>
<!-- Vision -->
{#if visionModel}
<!-- Vision -->
{#if visionModel}
{@const snippet = `curl ${url}/chat/completions \\\n -H "Content-Type: application/json" \\\n -d '{\n "model": "${visionModel}",\n "messages": [{"role":"user","content":[{"type":"text","text":"Was ist auf dem Bild falsch?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,<DEIN_BILD>"}}]}]\n}'`}
<div class="card">
<div class="card-h">
@@ -226,4 +227,19 @@
<div class="log" style="max-height:none"><code>{snippet}</code></div>
</div>
</div>
{/if}
{:else}
<!-- Kein Modell installiert: Schritt 0 erklären statt leeres Snippet ausgeben -->
<div class="card">
<div class="card-h"><h3>2 · Erst ein Modell installieren</h3></div>
<div class="empty-c" style="padding:8px 0 4px">
<div class="e-t">Noch kein Modell eingerichtet</div>
<div class="e-s">Bevor du ein Tool verbinden kannst, braucht die Engine mindestens ein Modell. Das Config-Snippet wird dann automatisch mit deinen echten Modellnamen befüllt.</div>
<button class="primary" style="margin-top:14px"
onclick={() => document.querySelector(".nav-item[data-view='cookbook']")?.dispatchEvent(new MouseEvent('click'))}>
→ Zum Cookbook (Modell holen)
</button>
</div>
</div>
{/if}
+41 -9
View File
@@ -163,7 +163,7 @@
if (btn) { btn.disabled = true; btn.textContent = 'Starte…' }
try {
const r = await api('/api/cookbook/install-recipe', { method: 'POST', body: JSON.stringify({ recipe_id: id, hf_token: getHfToken() }) })
toast(`${r.count} Downloads gestartet — siehe Aktivität.`)
toast(`${r.count} Download${r.count !== 1 ? 's' : ''} läuft — Modelle erscheinen danach automatisch im Modelle-Tab.`)
recipeModal = null; goView('activity')
} catch (e: any) { toast(e.message, true); if (btn) { btn.disabled = false; btn.textContent = 'Installieren' } }
}
@@ -196,9 +196,14 @@
if (!modelModalRepo || !modelFile) return toast('Bitte eine GGUF-Datei wählen.', true)
modelLoading = true
try {
const res = await api('/api/download', { method: 'POST', body: JSON.stringify({ repo: modelModalRepo, file: modelFile, hf_token: getHfToken() }) })
await api('/api/register', { method: 'POST', body: JSON.stringify({ role: modelRole, model_path: res.expected_path, ctx: modelCtx }) })
toast('Download gestartet — siehe Aktivität.'); modelModal = false; goView('activity')
const params_b = modelAnalysis?.params_b ?? 7.0
const quant = modelFit?.quant ?? 'Q4_K_M'
await api('/api/cookbook/install-model', { method: 'POST', body: JSON.stringify({
repo: modelModalRepo, role: modelRole, params_b, quant,
file: modelFile, ctx: modelCtx, hf_token: getHfToken()
})})
toast('Download läuft — Modell erscheint danach automatisch im Modelle-Tab.')
modelModal = false; goView('activity')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
modelLoading = false
}
@@ -208,7 +213,7 @@
btnEl.disabled = true; btnEl.textContent = 'Starte…'
try {
await api('/api/cookbook/install-model', { method: 'POST', body: JSON.stringify({ repo, role, params_b, quant: 'Q4_K_M', hf_token: getHfToken() }) })
toast('Download gestartet — siehe Aktivität.'); goView('activity')
toast('Download läuft — Modell erscheint danach automatisch im Modelle-Tab.'); goView('activity')
} catch (e: any) { toast(e.message, true); btnEl.disabled = false; btnEl.textContent = 'Installieren' }
}
@@ -263,10 +268,10 @@
<div class="card">
<div class="card-h">
<h3>Use-Case-Setups</h3>
<h3>1 · Anwendungsfall wählen &amp; installieren</h3>
<button class="ghost" style="margin-left:auto" onclick={openNewRecipe}>+ Eigenes Setup</button>
</div>
<div class="card-sub">Fertige Modell-Bundles für deinen Anwendungsfall. Hardware-Ampel zeigt, ob es auf deinen PC passt.</div>
<div class="card-sub">Hier starten: Fertige Modell-Bundles für deinen Anwendungsfall — ein Klick installiert alles. Die Hardware-Ampel zeigt vorab, ob das Setup auf deinen PC passt.</div>
{#if recipeLoading}
<div class="empty" style="text-align:center;padding:30px">Lade Setups…</div>
{:else}
@@ -450,9 +455,21 @@
{@const maxRam = Math.max(...r.models.map((m: any) => m.fit?.req_gb ?? 0))}
<div class="hint" style="margin:12px 0">Größter Spitzenbedarf: <b>~{maxRam.toFixed(1)} GB</b>. Es läuft immer nur <b>ein</b> Modell gleichzeitig — das größte bestimmt, ob das Setup passt.</div>
{/if}
{#if r.fit_level === 'too_tight'}
{@const smallerRec = recipes.find((x: any) => !x.user && x.fit_level !== 'too_tight' && x.id !== r.id)}
<div class="hint" style="color:var(--warn);margin-bottom:10px;border-left:2px solid var(--warn);padding-left:10px">
⚠ Dieses Setup übersteigt deinen verfügbaren Speicher.
{#if smallerRec}
Alternative: <a href="#" style="color:var(--accent)"
onclick={e => { e.preventDefault(); recipeModal = null; setTimeout(() => openRecipe(smallerRec.id), 50) }}>
{smallerRec.title}" ansehen
</a> — passt auf dein System.
{/if}
</div>
{/if}
<button id="cb-r-install-btn" class="primary{r.fit_level === 'too_tight' ? ' warn' : ''}" style="width:100%"
onclick={() => installRecipe(r.id)}>
{r.fit_level === 'too_tight' ? 'Trotzdem installieren (zu groß)' : 'Komplettes Setup installieren'}
{r.fit_level === 'too_tight' ? 'Trotzdem installieren (nicht empfohlen)' : 'Komplettes Setup installieren'}
</button>
</div>
</div>
@@ -501,9 +518,24 @@
<span class="fit-badge {fitCls(modelFit.fit.level)}">{modelFit.fit.text}</span>
</div>
{/if}
{#if modelFit?.fit.level === 'too_tight' && modelAnalysis?.files}
{@const better = modelAnalysis.files.find((f: any) => f.fit.level !== 'too_tight' && f.filename !== modelFile)}
{#if better}
<div class="hint" style="color:var(--warn);margin:-8px 0 14px;border-left:2px solid var(--warn);padding-left:10px">
⚠ Diese Datei ist zu groß für deinen Speicher. Kleinere Alternative:
<a href="#" style="color:var(--accent)" onclick={e => { e.preventDefault(); modelFile = better.filename; updateModelFit() }}>
{better.filename} ({fitWord(better.fit.level)})
</a> — einfach auswählen.
</div>
{:else}
<div class="hint" style="color:var(--warn);margin:-8px 0 14px;border-left:2px solid var(--warn);padding-left:10px">
⚠ Alle Varianten dieses Modells sind zu groß für deinen Speicher. Ein kleineres Modell aus den Empfehlungen wäre die bessere Wahl.
</div>
{/if}
{/if}
<button class="primary{modelFit?.fit.level === 'too_tight' ? ' warn' : ''}" style="width:100%" disabled={modelLoading}
onclick={doDownload}>
{modelLoading ? 'Starte…' : modelFit?.fit.level === 'too_tight' ? 'Trotzdem holen (zu groß)' : 'Herunterladen & Einpflegen'}
{modelLoading ? 'Starte…' : modelFit?.fit.level === 'too_tight' ? 'Trotzdem holen (zu groß — nicht empfohlen)' : 'Herunterladen & Einpflegen'}
</button>
{:else if !modelLoading}
<div class="hint">Keine GGUF-Dateien im Repo gefunden.</div>
+23 -2
View File
@@ -3,7 +3,7 @@
import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.js'
import { confirmModal, icon, fmtBytes } from '@core/ui.js'
import { confirmModal, toast, icon, fmtBytes } from '@core/ui.js'
const s = $derived(statusStore.value)
const sys = $derived(systemStore.value)
@@ -53,7 +53,7 @@
async function preload(modelName: string) {
preloadingModel = modelName
try { await api('/api/preload?model=' + encodeURIComponent(modelName), { method: 'POST' }) }
catch {}
catch (e: any) { toast('Laden fehlgeschlagen: ' + (e.message || 'Engine nicht erreichbar?'), true) }
preloadingModel = null
refresh()
}
@@ -153,6 +153,26 @@
<div class="card">
<div class="card-h"><h3>Schnellstart</h3></div>
{#if !configuredModels.length}
<!-- Onboarding-Pfad: noch keine Modelle -->
<div class="card-sub">Noch kein Modell eingerichtet — starte hier:</div>
<button class="qa" onclick={() => go('cookbook')}>
<span class="qa-ic teal">{@html icon('search')}</span>
<span class="qa-main"><span class="qa-t">Schritt 1 · Modell holen</span><span class="qa-s">Cookbook → passendes Setup installieren</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('connect')} style="opacity:.55;pointer-events:none">
<span class="qa-ic teal">{@html icon('swap')}</span>
<span class="qa-main"><span class="qa-t">Schritt 2 · Tools verbinden</span><span class="qa-s">Erst nach dem Download verfügbar</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('guides')}>
<span class="qa-ic blue">{@html icon('file')}</span>
<span class="qa-main"><span class="qa-t">Einsteiger-Guide lesen</span><span class="qa-s">Alle Begriffe erklärt, ohne Vorwissen</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
{:else}
<!-- Normalbetrieb -->
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div>
<button class="qa" onclick={() => go('cookbook')}>
<span class="qa-ic teal">{@html icon('search')}</span>
@@ -174,6 +194,7 @@
<span class="qa-main"><span class="qa-t">Tools verbinden</span><span class="qa-s">Zed, OpenCode, Cline einrichten</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
{/if}
</div>
</div>
+8 -16
View File
@@ -14,7 +14,7 @@ import psutil
from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth
from hw_math import evaluate_fit, max_ctx_for
from hw_math import evaluate_fit, max_ctx_for, extract_params_b
from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH,
DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin)
from llamaswap import read_config, write_config, model_id_from_path, set_role_alias
@@ -68,6 +68,8 @@ class InstallModelReq(BaseModel):
role: str
params_b: float
quant: str = "Q4_K_M"
file: str | None = None # konkrete GGUF-Datei; None → _pick_gguf wählt automatisch
ctx: int | None = None # None → max_ctx_for (Hardware-Optimum)
hf_token: str | None = None
@@ -85,18 +87,6 @@ class UserRecipeReq(BaseModel):
icon: str = "box"
models: list[UserRecipeModelReq]
def extract_params_b(repo_id: str) -> float:
"""Extrahiert die Parametergröße (in Milliarden) aus dem Repo-Namen."""
# z.B. Qwen2.5-Coder-32B -> 32
# 8x7B -> 56 (MoE)
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", repo_id)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", repo_id)
if m:
return float(m.group(1))
return 7.0 # Fallback
def extract_quant(filename: str) -> str:
m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE)
return m.group(1).upper() if m else "Q4_K_M"
@@ -531,8 +521,10 @@ def upgrades():
@router.post("/install-model")
def install_model(req: InstallModelReq):
"""Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx)."""
file = _pick_gguf(req.repo, req.quant)
"""Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).
Wenn 'file' übergeben wird, wird genau diese GGUF-Datei geladen (Profi-Suche-Auswahl);
andernfalls wählt _pick_gguf automatisch die beste Variante für den gewünschten Quant."""
file = req.file or _pick_gguf(req.repo, req.quant)
if not file:
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
mmproj = _pick_mmproj(req.repo)
@@ -550,7 +542,7 @@ def install_model(req: InstallModelReq):
JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
cfg = read_config()
ctx = max_ctx_for(req.params_b, req.quant, ram_gb)
ctx = req.ctx if req.ctx else max_ctx_for(req.params_b, req.quant, ram_gb)
path = str(target / file)
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
if mmproj:
+26 -26
View File
File diff suppressed because one or more lines are too long