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
+94 -78
View File
@@ -115,8 +115,10 @@
</div> </div>
</div> </div>
<!-- Step 2: Model selector --> <!-- Steps 24: nur anzeigen wenn Modelle installiert sind -->
{#if allModels.length} {#if allModels.length}
<!-- Step 2: Model selector -->
<div class="card"> <div class="card">
<div class="card-h"> <div class="card-h">
<h3>2 · Modelle auswählen</h3> <h3>2 · Modelle auswählen</h3>
@@ -140,90 +142,104 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<!-- Step 3: Tool picker --> <!-- Step 3: Tool picker -->
<div class="card"> <div class="card">
<div class="card-h"><h3>3 · Tool wählen</h3></div> <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="card-sub">Für welches Werkzeug brauchst du die Konfiguration?</div>
<div class="grid grid-4" style="gap:8px"> <div class="grid grid-4" style="gap:8px">
{#each [ {#each [
['zed', 'Zed', 'Schneller nativer Editor — Empfohlen'], ['zed', 'Zed', 'Schneller nativer Editor — Empfohlen'],
['opencode', 'OpenCode', 'Open-Source Coding-Agent (Terminal/Desktop)'], ['opencode', 'OpenCode', 'Open-Source Coding-Agent (Terminal/Desktop)'],
['cline', 'Cline / Cursor', 'VS Code Extension, Bring-Your-Own-Key'], ['cline', 'Cline / Cursor', 'VS Code Extension, Bring-Your-Own-Key'],
['openwebui', 'OpenWebUI', 'Browser-Chat-Interface'] ['openwebui', 'OpenWebUI', 'Browser-Chat-Interface']
] as [id, label, desc]} ] as [id, label, desc]}
<button class="card-btn{selectedTool === id ? ' cb-best' : ''}" style="text-align:left;padding:12px 16px" onclick={() => selectedTool = id}> <button class="card-btn{selectedTool === id ? ' cb-best' : ''}" style="text-align:left;padding:12px 16px" onclick={() => selectedTool = id}>
<div style="font-weight:500;font-size:13.5px">{label}</div> <div style="font-weight:500;font-size:13.5px">{label}</div>
<div class="card-sub" style="margin:3px 0 0">{desc}</div> <div class="card-sub" style="margin:3px 0 0">{desc}</div>
</button> </button>
{/each} {/each}
</div>
</div> </div>
</div>
<!-- Step 4: Config snippet --> <!-- Step 4: Config snippet -->
<div class="card"> <div class="card">
<div class="card-h"><h3>4 · Config-Snippet</h3></div> <div class="card-h"><h3>4 · Config-Snippet</h3></div>
{#if selectedTool === 'zed'} {#if selectedTool === 'zed'}
{@const block = zedBlock(url, activeIds)} {@const block = zedBlock(url, activeIds)}
<p>Öffne die Zed-Settings mit <code>Cmd/Strg + ,</code> und <b>füge diesen Block</b> in deine bestehende <code>settings.json</code> ein. Falls bereits ein <code>"bosgame"</code>-Eintrag existiert: vorher löschen.</p> <p>Öffne die Zed-Settings mit <code>Cmd/Strg + ,</code> und <b>füge diesen Block</b> in deine bestehende <code>settings.json</code> ein. Falls bereits ein <code>"bosgame"</code>-Eintrag existiert: vorher löschen.</p>
<div class="alert warn" style="margin-bottom:12px;padding:10px 14px"><span class="a-dot"></span><span>Nur diesen Block einfügen, <b>nicht die ganze Datei ersetzen</b> — sonst gehen andere Einstellungen verloren.</span></div> <div class="alert warn" style="margin-bottom:12px;padding:10px 14px"><span class="a-dot"></span><span>Nur diesen Block einfügen, <b>nicht die ganze Datei ersetzen</b> — sonst gehen andere Einstellungen verloren.</span></div>
<div style="position:relative"> <div style="position:relative">
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button> <button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
<div class="log" style="max-height:none"><code>{block}</code></div> <div class="log" style="max-height:none"><code>{block}</code></div>
</div> </div>
<p style="margin-top:12px"><b>API-Key:</b> Öffne den Agent-Panel → Anbieter <b>bosgame</b> → trag irgendeinen Wert ein (z.B. <code>local</code>).</p> <p style="margin-top:12px"><b>API-Key:</b> Öffne den Agent-Panel → Anbieter <b>bosgame</b> → trag irgendeinen Wert ein (z.B. <code>local</code>).</p>
<div class="hint">Danach im Agent-Panel das Modell <b>bosgame / {firstModel}</b> auswählen.</div> <div class="hint">Danach im Agent-Panel das Modell <b>bosgame / {firstModel}</b> auswählen.</div>
{:else if selectedTool === 'opencode'} {:else if selectedTool === 'opencode'}
{@const block = openCodeBlock(url, activeIds)} {@const block = openCodeBlock(url, activeIds)}
<p>Datei anlegen oder öffnen:</p> <p>Datei anlegen oder öffnen:</p>
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\.config\opencode\opencode.json</code></p> <p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\.config\opencode\opencode.json</code></p>
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Nur den <code>"provider"</code>-Block einfügen. Falls bereits ein <code>"llama-swap"</code>-Eintrag vorhanden: <b>vorher löschen</b>, sonst erscheinen Modelle doppelt.</span></div> <div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Nur den <code>"provider"</code>-Block einfügen. Falls bereits ein <code>"llama-swap"</code>-Eintrag vorhanden: <b>vorher löschen</b>, sonst erscheinen Modelle doppelt.</span></div>
<div style="position:relative"> <div style="position:relative">
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button> <button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
<div class="log" style="max-height:none"><code>{block}</code></div> <div class="log" style="max-height:none"><code>{block}</code></div>
</div> </div>
<p style="margin-top:12px">Danach in OpenCode <code>/models</code><b>llama-swap/{firstModel}</b> wählen.</p> <p style="margin-top:12px">Danach in OpenCode <code>/models</code><b>llama-swap/{firstModel}</b> wählen.</p>
{:else if selectedTool === 'cline'} {:else if selectedTool === 'cline'}
<p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen:</p> <p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen:</p>
<label>Basis-URL</label> <label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px"> <div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" readonly value={url} style="flex:1;margin:0"> <input class="mono-sm" readonly value={url} style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button> <button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div> </div>
<label>Modell-ID</label> <label>Modell-ID</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px"> <div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" readonly value={firstModel} style="flex:1;margin:0"> <input class="mono-sm" readonly value={firstModel} style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button> <button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div> </div>
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>).</div> <div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>).</div>
{:else if selectedTool === 'openwebui'} {:else if selectedTool === 'openwebui'}
<p>Settings → Admin Panel → Connections → neue OpenAI-Verbindung:</p> <p>Settings → Admin Panel → Connections → neue OpenAI-Verbindung:</p>
<label>Basis-URL</label> <label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px"> <div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" readonly value={url} style="flex:1;margin:0"> <input class="mono-sm" readonly value={url} style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button> <button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div> </div>
<label>API-Key</label> <label>API-Key</label>
<div class="flex gap-2" style="align-items:stretch"> <div class="flex gap-2" style="align-items:stretch">
<input class="mono-sm" readonly value="dummy-key" style="flex:1;margin:0"> <input class="mono-sm" readonly value="dummy-key" style="flex:1;margin:0">
<button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button> <button class="ghost" onclick={e => copyVal((e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement)}>Kopieren</button>
</div>
{/if}
</div>
<!-- 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">
<h3>Bilder einbinden (Vision)</h3>
<span class="chip" style="background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a">{@html icon('check')}{visionModel}" bereit</span>
</div>
<div class="card-sub">Mit Vision-Modellen kannst du Screenshots oder Fehlerbilder direkt mitschicken.</div>
<div style="position:relative;margin-top:8px">
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
<div class="log" style="max-height:none"><code>{snippet}</code></div>
</div>
</div> </div>
{/if} {/if}
</div>
<!-- Vision --> {:else}
{#if visionModel} <!-- Kein Modell installiert: Schritt 0 erklären statt leeres Snippet ausgeben -->
{@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">
<div class="card-h"> <div class="card-h"><h3>2 · Erst ein Modell installieren</h3></div>
<h3>Bilder einbinden (Vision)</h3> <div class="empty-c" style="padding:8px 0 4px">
<span class="chip" style="background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a">{@html icon('check')}{visionModel}" bereit</span> <div class="e-t">Noch kein Modell eingerichtet</div>
</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>
<div class="card-sub">Mit Vision-Modellen kannst du Screenshots oder Fehlerbilder direkt mitschicken.</div> <button class="primary" style="margin-top:14px"
<div style="position:relative;margin-top:8px"> onclick={() => document.querySelector(".nav-item[data-view='cookbook']")?.dispatchEvent(new MouseEvent('click'))}>
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1" onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button> → Zum Cookbook (Modell holen)
<div class="log" style="max-height:none"><code>{snippet}</code></div> </button>
</div> </div>
</div> </div>
{/if} {/if}
+41 -9
View File
@@ -163,7 +163,7 @@
if (btn) { btn.disabled = true; btn.textContent = 'Starte…' } if (btn) { btn.disabled = true; btn.textContent = 'Starte…' }
try { try {
const r = await api('/api/cookbook/install-recipe', { method: 'POST', body: JSON.stringify({ recipe_id: id, hf_token: getHfToken() }) }) 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') recipeModal = null; goView('activity')
} catch (e: any) { toast(e.message, true); if (btn) { btn.disabled = false; btn.textContent = 'Installieren' } } } 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) if (!modelModalRepo || !modelFile) return toast('Bitte eine GGUF-Datei wählen.', true)
modelLoading = true modelLoading = true
try { try {
const res = await api('/api/download', { method: 'POST', body: JSON.stringify({ repo: modelModalRepo, file: modelFile, hf_token: getHfToken() }) }) const params_b = modelAnalysis?.params_b ?? 7.0
await api('/api/register', { method: 'POST', body: JSON.stringify({ role: modelRole, model_path: res.expected_path, ctx: modelCtx }) }) const quant = modelFit?.quant ?? 'Q4_K_M'
toast('Download gestartet — siehe Aktivität.'); modelModal = false; goView('activity') 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) } } catch (e: any) { toast('Fehler: ' + e.message, true) }
modelLoading = false modelLoading = false
} }
@@ -208,7 +213,7 @@
btnEl.disabled = true; btnEl.textContent = 'Starte…' btnEl.disabled = true; btnEl.textContent = 'Starte…'
try { try {
await api('/api/cookbook/install-model', { method: 'POST', body: JSON.stringify({ repo, role, params_b, quant: 'Q4_K_M', hf_token: getHfToken() }) }) 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' } } catch (e: any) { toast(e.message, true); btnEl.disabled = false; btnEl.textContent = 'Installieren' }
} }
@@ -263,10 +268,10 @@
<div class="card"> <div class="card">
<div class="card-h"> <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> <button class="ghost" style="margin-left:auto" onclick={openNewRecipe}>+ Eigenes Setup</button>
</div> </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} {#if recipeLoading}
<div class="empty" style="text-align:center;padding:30px">Lade Setups…</div> <div class="empty" style="text-align:center;padding:30px">Lade Setups…</div>
{:else} {:else}
@@ -450,9 +455,21 @@
{@const maxRam = Math.max(...r.models.map((m: any) => m.fit?.req_gb ?? 0))} {@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> <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}
{#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%" <button id="cb-r-install-btn" class="primary{r.fit_level === 'too_tight' ? ' warn' : ''}" style="width:100%"
onclick={() => installRecipe(r.id)}> 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> </button>
</div> </div>
</div> </div>
@@ -501,9 +518,24 @@
<span class="fit-badge {fitCls(modelFit.fit.level)}">{modelFit.fit.text}</span> <span class="fit-badge {fitCls(modelFit.fit.level)}">{modelFit.fit.text}</span>
</div> </div>
{/if} {/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} <button class="primary{modelFit?.fit.level === 'too_tight' ? ' warn' : ''}" style="width:100%" disabled={modelLoading}
onclick={doDownload}> 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> </button>
{:else if !modelLoading} {:else if !modelLoading}
<div class="hint">Keine GGUF-Dateien im Repo gefunden.</div> <div class="hint">Keine GGUF-Dateien im Repo gefunden.</div>
+44 -23
View File
@@ -3,7 +3,7 @@
import { statusStore } from '../stores/status.svelte.js' import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js' import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.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 s = $derived(statusStore.value)
const sys = $derived(systemStore.value) const sys = $derived(systemStore.value)
@@ -53,7 +53,7 @@
async function preload(modelName: string) { async function preload(modelName: string) {
preloadingModel = modelName preloadingModel = modelName
try { await api('/api/preload?model=' + encodeURIComponent(modelName), { method: 'POST' }) } 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 preloadingModel = null
refresh() refresh()
} }
@@ -153,27 +153,48 @@
<div class="card"> <div class="card">
<div class="card-h"><h3>Schnellstart</h3></div> <div class="card-h"><h3>Schnellstart</h3></div>
<div class="card-sub">Die häufigsten Aufgaben — ein Klick.</div> {#if !configuredModels.length}
<button class="qa" onclick={() => go('cookbook')}> <!-- Onboarding-Pfad: noch keine Modelle -->
<span class="qa-ic teal">{@html icon('search')}</span> <div class="card-sub">Noch kein Modell eingerichtet — starte hier:</div>
<span class="qa-main"><span class="qa-t">Modell finden</span><span class="qa-s">Passend zu deiner Hardware</span></span> <button class="qa" onclick={() => go('cookbook')}>
<span class="qa-arrow">{@html icon('chevron')}</span> <span class="qa-ic teal">{@html icon('search')}</span>
</button> <span class="qa-main"><span class="qa-t">Schritt 1 · Modell holen</span><span class="qa-s">Cookbook → passendes Setup installieren</span></span>
<button class="qa" onclick={freeMemory}> <span class="qa-arrow">{@html icon('chevron')}</span>
<span class="qa-ic amber">{@html icon('refresh')}</span> </button>
<span class="qa-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Alle Modelle entladen</span></span> <button class="qa" onclick={() => go('connect')} style="opacity:.55;pointer-events:none">
<span class="qa-arrow">{@html icon('chevron')}</span> <span class="qa-ic teal">{@html icon('swap')}</span>
</button> <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>
<button class="qa" onclick={() => go('server')}> <span class="qa-arrow">{@html icon('chevron')}</span>
<span class="qa-ic blue">{@html icon('file')}</span> </button>
<span class="qa-main"><span class="qa-t">Logs ansehen</span><span class="qa-s">Live mitlesen, was läuft</span></span> <button class="qa" onclick={() => go('guides')}>
<span class="qa-arrow">{@html icon('chevron')}</span> <span class="qa-ic blue">{@html icon('file')}</span>
</button> <span class="qa-main"><span class="qa-t">Einsteiger-Guide lesen</span><span class="qa-s">Alle Begriffe erklärt, ohne Vorwissen</span></span>
<button class="qa" onclick={() => go('connect')}> <span class="qa-arrow">{@html icon('chevron')}</span>
<span class="qa-ic teal">{@html icon('swap')}</span> </button>
<span class="qa-main"><span class="qa-t">Tools verbinden</span><span class="qa-s">Zed, OpenCode, Cline einrichten</span></span> {:else}
<span class="qa-arrow">{@html icon('chevron')}</span> <!-- Normalbetrieb -->
</button> <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>
<span class="qa-main"><span class="qa-t">Modell finden</span><span class="qa-s">Passend zu deiner Hardware</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={freeMemory}>
<span class="qa-ic amber">{@html icon('refresh')}</span>
<span class="qa-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Alle Modelle entladen</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('server')}>
<span class="qa-ic blue">{@html icon('file')}</span>
<span class="qa-main"><span class="qa-t">Logs ansehen</span><span class="qa-s">Live mitlesen, was läuft</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('connect')}>
<span class="qa-ic teal">{@html icon('swap')}</span>
<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>
</div> </div>
+8 -16
View File
@@ -14,7 +14,7 @@ import psutil
from ruamel.yaml.scalarstring import LiteralScalarString from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth 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, from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH,
DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin) DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin)
from llamaswap import read_config, write_config, model_id_from_path, set_role_alias from llamaswap import read_config, write_config, model_id_from_path, set_role_alias
@@ -68,6 +68,8 @@ class InstallModelReq(BaseModel):
role: str role: str
params_b: float params_b: float
quant: str = "Q4_K_M" 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 hf_token: str | None = None
@@ -85,18 +87,6 @@ class UserRecipeReq(BaseModel):
icon: str = "box" icon: str = "box"
models: list[UserRecipeModelReq] 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: 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) 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" return m.group(1).upper() if m else "Q4_K_M"
@@ -531,8 +521,10 @@ def upgrades():
@router.post("/install-model") @router.post("/install-model")
def install_model(req: InstallModelReq): def install_model(req: InstallModelReq):
"""Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).""" """Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).
file = _pick_gguf(req.repo, req.quant) 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: if not file:
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.") raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
mmproj = _pick_mmproj(req.repo) mmproj = _pick_mmproj(req.repo)
@@ -550,7 +542,7 @@ def install_model(req: InstallModelReq):
JOBS[jid]["result_path"] = str(target / file) JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(req.repo, file)) attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
cfg = read_config() 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) path = str(target / file)
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx)) cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
if mmproj: if mmproj:
+26 -26
View File
File diff suppressed because one or more lines are too long