Compare commits

...

85 Commits

Author SHA1 Message Date
Hitonabi a91f32c70c CI-Ampel nachgerüstet (Wasserdicht-Runde 22.07.)
Ampel / ampel (push) Failing after 15m12s
2026-07-22 19:32:59 +02:00
Hitonabi 902867be3f fix(v9): Auto-Setup "agent"-Slot via Tool-Capability statt Name-Kategorie
Der agent-Slot zog bisher aus der duennen Name-Keyword-Kategorie und blieb
oft leer. Jetzt poolt build_dynamic_recipes fuer role==agent ueber ALLE
discover-Kategorien und filtert via model_caps.capabilities (HF-Tags) auf
tool-faehige Modelle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:02:40 +02:00
Hitonabi 3dd767f23d docs(v9): Phase 11 (Cookbook 3.0 — Auto-Setups + Tabs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:31:28 +02:00
Hitonabi 084f92c661 feat(v9): Cookbook 3.0 — Segmented-Tabs (Setups | Modelle finden)
Entschlackt die überladene Seite: statt 3 gestapelter Sektionen zwei Tabs.
"Fertige Setups" = (jetzt auto-aktuelle) Bundles; "Modelle finden" bündelt
Discover + Profi-Suche (Facetten) unter einem Tab. Nur eine Ansicht zur Zeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:30:50 +02:00
Hitonabi 5d8babaede feat(v9): Phase 11 — selbst-aktualisierende Setups (Rollen-Slots × discover)
Setups sind nicht mehr hartkodiert: RECIPE_TEMPLATES definieren das KONZEPT
als Rollen-Slots, build_dynamic_recipes füllt sie live aus discover (Hardware-
Fit, MoE bevorzugt, dedupe, leere Slots ausgelassen). all_recipes liefert
Auto-Setups + eigene; statische RECIPES nur als Fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:27:19 +02:00
Hitonabi 30bae06c0c fix(v9): Capability-Chips auch im Rezept-Detail-Modal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:21:30 +02:00
Hitonabi 4dd819488e docs(v9): Phase 10 komplett — Capability-Chips + Facetten in Models & Cookbook
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:17:27 +02:00
Hitonabi 701476179e feat(v9): Phase 10 Schritt 2 — Capability-Chips + Facetten-Filter im Cookbook
- Profi-Suche: HF-Suche mit &full=true (liefert Tags); Multi-Capability-Chips
  (Code/Bild/Logik/MoE·aktiv/Tools(yes|likely)/Chat) aus Name + HF-Tags.
- Facetten-Filter (Mehrfachauswahl: Tools/MoE/Logik/Bild/Code) statt
  Einzel-Kategorie — clientseitig auf den Treffern (Index für Fit/Best bleibt).
- Discover: dieselben Chips; Backend liefert HF-Tags je Modell mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:16:40 +02:00
Hitonabi ada85504f3 docs(v9): Phase 10 Schritt 1 (GGUF-Capabilities) dokumentiert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:10:50 +02:00
Hitonabi 9fa065908d feat(v9): Phase 10 Schritt 1 — Modell-Capabilities aus GGUF-Metadaten
- model_caps.py: dependency-freier GGUF-Header-Reader (architecture,
  expert_count, context_length, parameter_count) — offline/authoritativ,
  bricht vor dem Tokenizer-Array ab. Leitet Tag-Set ab: MoE(+aktive B),
  Tools (yes via --jinja/Template | likely via Familie | no), Vision,
  Coder, Reasoning, Embedding, native ctx/params. Familien-Liste nur Fallback.
- models.py status(): meta.capabilities ergänzt.
- ModelsPanel: "KANN"-Spalte zeigt jetzt Capability-Chips statt Text/Code/Bild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:08:31 +02:00
Hitonabi d66f51ab8b docs(v9): Phase 9 final — deterministischer Kurator (LLM-Cron verworfen)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:51:36 +02:00
Hitonabi dd2938ee53 feat(v9): deterministischer Memory-Kurator (Dedupe) statt LLM-Cron
- routers/memory.py: POST /api/memory/dedupe — findet Dubletten (exakt/
  enthalten/ähnlich via SequenceMatcher) je Kategorie, behält den
  vollständigsten Eintrag. Dry-Run (apply:false) + Anwenden. Kein LLM.
- MemoryPanel: "🧹 Aufräumen"-Button (Vorschau → Bestätigung → löschen).

Grund: LLM-Cron-Kurator hat Edits nur halluziniert/als Text ausgegeben
statt auszuführen (Hermes 4 + Qwen). Deterministisch = verlässlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:49:18 +02:00
Hitonabi 0c7f3104d4 docs(v9): Phase 9 komplett — Memory-Kurator als Hermes-Cron-Job live
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:31:18 +02:00
Hitonabi aa726c01af fix(v9): Cron-Parser fuer echtes Block-Format (Cockpit zeigte Jobs nicht)
`hermes cron list` nutzt bei vorhandenen Jobs kein │-Tabellenformat, sondern
"id [status]" + eingerueckte Label:value-Zeilen. Eigener _parse_cron statt
_parse_rich_table → Memory-Kurator-Job erscheint jetzt im Cockpit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:30:29 +02:00
Hitonabi 3facb6c3ad feat(v9): Phase 7 (Hermes 4 14B) live + Phase 9 proaktives Memory
- Phase 7: model.default = Hermes-4-14B (Q6_K, llama-swap-Alias hermes,
  --jinja), verifiziert (sauberer Tool-Call). A/B vs Qwen3-Coder umschaltbar.
- Phase 9: mcp_memory.py Tool-Beschreibungen auf proaktives Protokoll
  (get→Session-Start, add→proaktiv+dedup, search→vor add) → geteiltes
  MC-SQLite waechst autonom aus allen Tool-Sessions mit.
- Docs: Memory-Architektur (2 komplementaere Schichten) festgeschrieben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:25:52 +02:00
Hitonabi c9433470ab docs(v9): Phase 6 + 8 live, Phase 7 (Hermes 4 14B) in Arbeit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:08:44 +02:00
Hitonabi d4aa7eb8cf feat(v9): Phase 8 — Cockpit zeigt Lernen & Aktivität
- hermes_control.py: learned_profile() liest USER.md (Curator-Nutzerprofil,
  § -getrennt) + MEMORY.md; insights() zieht Sessions/Tokens/Tool-Calls +
  Top-Tools aus `hermes insights` (Regex, best effort).
- routers/hermes.py: GET /api/hermes/{learned,insights}.
- HermesPanel-Cockpit: Kachel "Aktivität" (Kennzahlen + Top-Tool-Balken) und
  "Was Hermes über dich gelernt hat" (USER.md-Fakten) — beantwortet sichtbar,
  dass der Agent automatisch mitlernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:51 +02:00
Hitonabi 7e8ed585a9 feat(v9): Hermes-Chat full-bleed, landet direkt auf /chat
Schlanke Kopfleiste (nur Cockpit/Setup), iframe fuellt das Fenster
(calc(100dvh - 116px)) und oeffnet die Chat-Route des Dashboards direkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:56:42 +02:00
Hitonabi af0e63c9d6 docs(v9): Phase 5 (Dashboard-Adoption) + Phasen 6-8 in ROADMAP
Kurswechsel dokumentiert: eingebettetes Hermes-Web-Dashboard statt Eigenbau-Chat.
CLAUDE.md: hermes_ui-Router, HERMES_DASHBOARD_URL, Dashboard-Gotcha, Projektstatus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:51:52 +02:00
Hitonabi 09aabbb86e feat(v9): Phase 5 — Hermes-Web-Dashboard eingebettet, Eigenbau-Chat raus
Das Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool-Aktivitaet,
Approval-Prompts, Settings, Sessions). Statt sie nachzubauen, betten wir sie ein:

- routers/hermes_ui.py: HTTP+WS-Reverse-Proxy auf das lokale Dashboard (:9119)
  unter /hermes-ui/ mit X-Forwarded-Prefix -> Dashboard rewritet Assets/Base-Path
  selbst, injiziert seinen Session-Token (kein zweiter Login). WS-Bruecke fuer
  pty/ws/pub/events.
- HermesPanel: Chat -> iframe auf /hermes-ui/; Eigenbau-Chat/Voice/WS entfernt.
  Cockpit + Setup bleiben. Loest damit Kontext-/Lern-/Tool-Sichtbarkeits-Themen,
  da die UI direkt mit dem Agent-Loop spricht (kein Proxy-Bug mehr).
- routers/hermes.py: Chat-WS + _proxy_chat entfernt (Cutover).
- config.py: HERMES_DASHBOARD_URL. requirements: websockets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:47:52 +02:00
Hitonabi 40d9efee18 docs(v9): Phase 4 (Agent-Cockpit) dokumentiert + Versions-Chip-Fix
- HermesPanel: version-Chip zeigt PINNED_VERSION ohne doppeltes "v".
- ROADMAP/CLAUDE.md: Phase 4 als live markiert, HERMES_HOME/BIN ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:20:19 +02:00
Hitonabi ecf974d9c0 feat(v9): Phase 4 — Agent-Cockpit (Status/Cron/Skills) im Dashboard
Macht das "geschenkte v9" sichtbar. Read-only Control-Plane-Reads:
- hermes_control.py: gateway_state.json + cron-Heartbeat (Status),
  `hermes cron list` + `hermes skills list` via COLUMNS=400 (kein
  Rich-Truncation) als kanonische Quelle, .usage.json fuer Nutzung.
  TTL-Cache (10s) gegen Polling-Last.
- routers/hermes.py: GET /api/hermes/{agent,cron,skills}.
- HermesPanel: aufklappbares "Cockpit" — Status-Badges, Cron-Jobs,
  Skills (sortiert nach Nutzung, aktiv/inaktiv).
- config.py: HERMES_HOME, HERMES_BIN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:18:00 +02:00
Hitonabi f816c0c879 docs(v9): Memory-Feintuning via SOUL.md dokumentiert
Hermes konsultiert das MCP-Gedaechtnis jetzt proaktiv (SOUL.md-Regel auf
dem Bosgame). Verifiziert: weicher Prompt nennt "Tobi" ohne Tool-Aufforderung.
SOUL.md liegt in ~/.hermes/ (nicht im Repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:33:44 +02:00
Hitonabi 565994977f fix(v9): Hermes-Proxy non-streaming (Tool-Calls wurden im Stream geleakt)
Der Hermes-API-Server fuehrt im Streaming-Modus Tool-Calls nicht aus und
streamt rohe <function=…></tool_call>-Tokens als Text. Non-streaming
durchlaeuft den vollen Agent-Loop (Tools, Gedaechtnis). Proxy holt die
fertige Antwort und streamt sie wortweise selbst ans Frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:27:23 +02:00
Hitonabi 8fd00d1887 feat(v9): Phase 2+3 — harter Cutover auf Hermes-Agent (:8642)
Mission Control proxyt jetzt zum Nous-Hermes-Agent-Server statt zum
Eigenbau-Agenten:

- routers/hermes.py: Chat-WS streamt als Proxy zu :8642 (OpenAI-API),
  WS-Contract (thinking/token/done/error) beibehalten -> HermesPanel
  unveraendert, kein Frontend-Build noetig. Status meldet Hermes-Health.
- config.py: HERMES_API_URL + HERMES_API_KEY (Key aus ~/.hermes/.env);
  HERMES_SIMPLE/COMPLEX_MODEL entfernt (Routing macht Hermes selbst).
- hermes_agent.py geloescht (ReAct-Loop, tote Tools, DuckDuckGo, Fake-Stream).
- CLAUDE/README/ROADMAP aktualisiert; Phasen 2+3 abgehakt.

Lokaler Smoke-Test: App importiert, /api/hermes/status liefert 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:24:09 +02:00
Hitonabi bc02779496 docs(v9): Phase 1 (Memory-Bridge) live
mcp_memory.py als MCP-Server in Hermes Agent registriert
(mission-control-memory, 5/5 Tools). Bidirektional verifiziert:
Hermes liest bestehende Memories und schreibt neue in dieselbe
SQLite via /api/memory. v7/v8.3-Gedaechtnis ohne Migration im Agenten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:12:40 +02:00
Hitonabi 02615f16ae docs(v9): Hermes-Agent-Adoption entschieden + Phase 0 live
- ROADMAP: neue v9 (Nous Hermes Agent als Runtime, Mission Control = Control-Plane),
  5-Phasen-Plan; alte v8.4 (Windows-SSH) + altes v9 (Agentic-OS-Eigenbau) als
  ueberholt markiert; Phase 0 abgehakt mit Details
- CLAUDE: hermes_agent.py als DEPRECATED ab v9 markiert; Stack- und Status-Abschnitt
  auf die Adoption aktualisiert
- Phase 0 (Bosgame, 2026-06-23): Hermes Agent v0.17.0 installiert, coder-Modell um
  --jinja ergaenzt (Backup angelegt), auf llama-swap verdrahtet (provider=custom);
  Tool-Call-Proof erfolgreich (free -h, exakt deckungsgleich mit Live-Wert)
- docs/: alte README + Screenshot entfernt (nach Root-README konsolidiert)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:09:41 +02:00
Hitonabi c6fb8210b3 docs: README/CLAUDE/ROADMAP auf v8.1-v8.3 aktualisiert
- ROADMAP: v8.1 (Bug-Fixes), v8.2 (UX), v8.3 (Memory Import) als done markiert;
  v8.1-Hermes-SSH -> v8.4, v8.2-opencode -> v8.5 umnummeriert
- CLAUDE.md: Projektstatus + 3 neue Gotchas (system_status-Keys,
  OpenCode .jsonc, ConnectPanel NPM-Proxy)
- README.md: Hermes + Gedächtnis-Features aktualisiert, Download-Links erwähnt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:32:19 +02:00
Hitonabi 6f4f25480a feat(v8.3): Memory Import aus Cloud-KIs + Batch-POST
- MemoryPanel: "Import"-Button öffnet Formular
- Jede Zeile wird ein eigener Gedächtnis-Eintrag (Batch-POST)
- Kategorie + Quelle (claude/gemini/chatgpt/import) wählbar
- Zeilen-Vorschau zeigt erkannte Einträge vor dem Speichern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:25:17 +02:00
Hitonabi df1ba748d4 feat(v8.2): UX — Guide-Tabs, Begriffe oben, Mobile, Download-Links
- GuidesPanel: "Begriffe kurz erklärt" jetzt ganz oben (vor Guide-Karten)
- GuidesPanel: Tab-Navigation mit Kategorie-Chips + Active-Highlighting;
  Klick scrollt zur jeweiligen Guide-Gruppe
- ConnectPanel: Download-Links für Zed (zed.dev) und Cline (VS Code +
  JetBrains Marketplace) ergänzt
- base.css: Mobile-Breakpoint ≤520px — Sidebar wird zur Bottom-Nav,
  Content-Padding reduziert, Touch-freundliche Button-Höhen (min 40px),
  Topbar kompaktiert, Guide-Tabs horizontal scrollbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:23:57 +02:00
Hitonabi e8971fbf0c fix(v8.1): Kritische Bugs behoben — system_status, model-routing, UI
P0:
- hermes_agent: system_status Key-Mismatch behoben (cpu_pct→cpu.percent,
  ram_used_gb→ram.used/1024³, gtt analog) — zeigt jetzt echte Werte statt '?'
- guides-data: &amp; in 4 Titel-Strings → & (Svelte text-node rendert keine HTML-Entities)

P1:
- hermes_agent: choose_model() um recherchier/such/vergleich/etc. erweitert;
  kein Downgrade wenn komplexes Modell bereits geladen
- routers/hermes: asyncio.get_event_loop() → get_running_loop() (Whisper+TTS)
- maintenance: llama-swap Update akzeptiert optionales sudo-PW via stdin;
  import os hinzugefügt (fix NameError in updates()); UpdateReq-Model
- ServerPanel: Engine-Update fragt jetzt nach sudo-PW (analog OS-Update)
- HermesPanel: Chat-History in sessionStorage (überlebt Reload);
  Stopp-Button während Hermes antwortet; Verlauf-löschen-Button
- ConnectPanel: opencode.json → opencode.jsonc; provider → providers;
  LAN-IP Override (localStorage) für NPM-Proxy-Setups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:19:01 +02:00
Hitonabi 8539999627 fix+docs: Bug-Fixes, vollstaendige Dokumentation v8
Bug-Fixes:
- hermes_agent.py: asyncio.get_event_loop() → get_running_loop() (Python 3.10+)
- routers/hermes.py: Thread-Lock fuer Whisper-Init, HERMES_WINDOWS_USER Import,
  Piper stderr logging, __import__ Anti-Pattern entfernt
- routers/memory.py: SQLite WAL-Mode, Kategorie-Enum-Validierung (user/instruction/stable/versioned/ephemeral)
- HermesPanel.svelte: findLast() → reverse().find() (Browser-Kompatibilitaet)
- ConnectPanel.svelte: Hardcoded Username durch Platzhalter ersetzt

Docs:
- CLAUDE.md: komplett aktualisiert (v7+v8, Hermes, Memory, alle Env-Vars)
- ROADMAP.md: v7+v8 als erledigt, naechste Features (v8.1-v9.1)
- README.md: komplett neu geschrieben (Agentic OS Konzept, alle Features)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:48:22 +02:00
Hitonabi 5881a21d9a feat(memory): Kategorien 'user' + 'instruction' fuer universelles Agenten-Gedaechtnis
Gilt fuer alle Tools (Hermes, Cline, OpenCode) via MCP-Server.
- user: wer der User ist, Praeferenzen, Arbeitsweise
- instruction: Verhaltensregeln fuer alle KI-Tools
Hermes-Agent speichert aktiv in diese Kategorien wenn er Neues lernt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:37:25 +02:00
Hitonabi ff60d6ff26 fix(hermes): Kerstin (weiblich) statt Thorsten als TTS-Stimme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:32:21 +02:00
Hitonabi 789eea782a fix(hermes): Piper-Installationsbefehle direkt im Setup-Wizard statt Platzhalter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:28:33 +02:00
Hitonabi 3679465956 feat(hermes): v8 — Hermes Agent mit Voice-Interface
Lokaler KI-Assistent auf dem Bosgame: Text + Sprache, Tool Calling,
WebSocket-Streaming. Steuerzentrale des Agentic OS.

- hermes_agent.py: ReAct-Agent mit Tool-Set (read_file, list_dir,
  run_command, system_status, memory r/w, web_search)
- routers/hermes.py: WS /chat, POST /transcribe (Whisper),
  POST /tts (Piper), GET /status, GET /pubkey
- HermesPanel.svelte: Chat-UI mit Token-Streaming, Tool-Anzeige,
  Mikrofon-Button (MediaRecorder), Setup-Wizard (Windows SSH)
- Modell-Routing: scout fuer einfache Tasks, coder fuer komplexe
- HERMES_* Env-Vars in config.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:18:48 +02:00
Hitonabi e02a1889b2 feat(memory): v7 — Gedächtnis-Layer mit MCP-Integration
SQLite-basiertes Memory-System fuer persistentes Gedaechtnis ueber Sessions.
Cline, OpenCode und Claude Code teilen denselben Speicher via MCP-Server.

- routers/memory.py: CRUD + Export-Endpoint (GET/POST/PUT/DELETE /api/memory)
- mcp_memory.py: stdio MCP-Server — Tools: get/add/search/update/delete_memory
- MemoryPanel.svelte: Gedaechtnis-Tab mit Filter, Inline-Edit, Add-Formular
- ConnectPanel.svelte: Gedaechtnis-MCP Setup-Guide (Cline/OpenCode/Bosgame)
- Temporaere Eintraege (ephemeral) nach 7 Tagen automatisch geloescht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:30:47 +02:00
Hitonabi 8b52cd20b4 fix(guides): OpenCode Desktop-App explizit erklärt
Hinweis ergänzt: CLI und Desktop-App nutzen dieselbe config.json,
alle Slash-Commands funktionieren in beiden Versionen gleich.
Unterschied nur beim Start: Terminal (cd + opencode) vs. GUI (Ordner wählen).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 12:50:43 +02:00
Hitonabi b88eba0da8 feat(tools): Jan AI + Continue + Antigravity — 7 Tools, 17 Guide-Tutorials
Verbinden-Tab neu strukturiert:
- Tool-Picker in 2 Sektionen unterteilt: Chat + Coding-Assistenten
- Jan AI: einfachstes Chat-Interface (Desktop-App, kein Docker)
  Config: Basis-URL + Modell-ID Copy-Fields wie Cline
- Continue: VS Code/JetBrains Coding-Hilfe ohne Agent-Overhead
  Config: fertiger config.json-Block mit allen konfigurierten Modellen
- Antigravity: Googles agentic IDE (VS Code-Fork, kostenlos)
  Config: Basis-URL + Modell-ID Copy-Fields
- continueBlock() Funktion für dynamisches config.json-Snippet

Guide neu strukturiert (17 Tutorials, war 14):
- "Welches Tool" Entscheidungsmatrix komplett neu: Chat vs. Coding,
  Jan AI als Einstieg statt OpenWebUI, Antigravity und Continue ergänzt
- Neu: "Jan AI — einfachstes Chat-Interface" (Schritt-für-Schritt)
- Neu: "Continue — Coding-Hilfe ohne Agent-Overhead" (Chat/Autocomplete/Inline-Edit)
- Neu: "Antigravity — Googles agentic IDE" (Multi-Agent, Manager-View)
- OpenWebUI repositioniert: "für Teams und Power-User" mit klarer Begründung
  wann OpenWebUI mehr Sinn macht als Jan AI

Recherche-Grundlage: jan.ai/docs, continue.dev, antigravityide.net,
Google Dev Blog (Antigravity), promptquorum.com (Frontend-Vergleich 2026)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:23:37 +02:00
Hitonabi a0ff9d1b25 feat(guides): Best Practices Update Juni 2026 — recherchierte Korrekturen
OpenCode:
- /compact hat Alias /summarize; /clear = Alias für /new (klar unterschieden)
- Multi-Agenten erklärt: Tab = primary agents wechseln; @name = Subagent aufrufen
- Agenten via Markdown-Dateien definieren (review.md → @review)
- Slash-Commands als Tabelle statt Bullet-Liste

Cline:
- Plattformen aktualisiert: VS Code, JetBrains, Cursor, Windsurf, Zed, Neovim, CLI (v3.81)
- Memory Bank Konzept ergänzt (projectbrief.md, activeContext.md, progress.md)
- .clinerules/ als Verzeichnis möglich für große Projekte
- Spending Limits (seit April 2026 / v3.78) dokumentiert
- Hinweis: .clinerules-Größe kostet Kontext → kürzer ist besser
- "in VS Code" im Titel entfernt (irreführend)

MCP:
- Empfehlungsliste als Tabelle, nach Anwendungsfall gegliedert
- Playwright (Browser-Automation) ergänzt
- Memory MCPs (mem0) als 2026-Standard ergänzt
- GitHub = meistinstalliert ever; filesystem = meistgenutzt ever
- "36.000+ MCPs verfügbar" als Kontext

Skills & Rules:
- skills.sh: Vercel-Launch Jan 2026, npm-Pendant für Skills erklärt
- Sicherheitshinweis: Snyk-Audit 2026 — 13,4% kritische Probleme in Community-Skills
- SkillHub als qualitätsgeprüfte Alternative ergänzt

AGENTS.md:
- Als plattformübergreifender 2026-Standard hervorgehoben (10+ Tools lesen es nativ)
- 200-500 Zeilen Sweet Spot dokumentiert (Kontext-Overhead)

Konzepte:
- Quantisierung: IQ4_XS als Alternative + Q8_0 für Coding/Reasoning
- Memory Bank als neues Konzept ergänzt
- Skills/Rules: plattformübergreifender Standard erwähnt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:09:08 +02:00
Hitonabi 05acad5af8 feat(guides): Quellen überall + Persona-Onboarding
Quellen/Links:
- OpenCode → opencode.ai/docs
- Cline → cline.bot
- OpenWebUI → openwebui.com
- MCP → mcp.run, smithery.ai, modelcontextprotocol.io/servers
- Skills & Rules → skills.sh (neu), cursor.directory
- Persona → skills.sh, cursor.directory
- AGENTS.MD → skills.sh, opencode.ai/docs
Neue .tut-sources CSS-Klasse: dezente Trennlinie, Teal-Links.

Persona-Tutorial grundlegend erweitert:
- Zwei Dimensionen erklärt (WAS der Agent tut vs. WIE er mit dir umgeht)
- Template um Beziehungs-/Interaktionszeile erweitert
- Beispiele zeigen jetzt auch Feedback-Stil, Proaktivität, Fehlerkultur
- Neues Onboarding-Interview-Template: Agent befragt den Nutzer selbst
  (6-8 Fragen, eine nach der anderen) und schreibt daraus eine
  maßgeschneiderte AGENTS.md — der schnellste Weg zu einer echten Persona.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:00:36 +02:00
Hitonabi 18236fc6d6 feat(guides): Persona + AGENTS.MD/SAVEPOINT.MD Tutorials
Zwei neue Tutorials in der Gruppe „Agenten meistern":

1. Die Persona — deinem Agenten eine Rolle geben
   Erklärt was eine Persona ist, warum sie den Unterschied macht,
   + Copy-paste-ready Template + 2 konkrete Beispiele (Coding-Assistent,
   Lernbegleiter) + Tabelle wo man sie einträgt (pro Projekt vs. global).

2. AGENTS.MD & SAVEPOINT.MD — Gedächtnis für den Agenten
   Erklärt den Unterschied (was das Projekt ist vs. wo wir stehen),
   + vollständige Vorlagen für beide Dateien die direkt nutzbar sind,
   + 2-Schritt-Workflow (Session starten / beenden).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:53:22 +02:00
Hitonabi c0f77ba69c feat(guides): Guide-Tab komplett neu — 4 Gruppen, 12 Tutorials
Vollständige Überarbeitung des Guide-Tabs:

Struktur: Statt 8 gleichwertiger Accordions jetzt 4 klar getrennte
Gruppen (EINSTIEG / TOOLS EINRICHTEN / AGENTEN MEISTERN / WENN ETWAS
NICHT KLAPPT) — jede als eigene Card mit farbigem Teal-Label.
Anfänger sehen sofort die logische Reihenfolge.

Neue Tutorials (war 8, jetzt 12):
- "Welches Tool für mich?" — Entscheidungsbaum (OpenWebUI/Cline/OpenCode/Zed)
- "OpenCode verbinden & erste Schritte" — Config + Befehle
- "Cline in VS Code verbinden" — Plan- vs. Act-Modus erklärt
- "OpenWebUI verbinden" — für Nutzer ohne Programmierkenntnisse
- "Einen Agenten richtig briefen" — Schlecht→Gut-Beispiele
- "MCP" — überarbeitet mit konkretem Config-Beispiel und Empfehlungen
- "Skills & Rules" — AGENTS.md / .clinerules mit echten Pfaden und Quellen
- "Häufige Probleme & Lösungen" — Troubleshooting-Tabelle (7 Fälle)

Zusammengeführt: Kontext-Tutorials 2+3 (waren doppelt) → ein Tutorial.

Neue CSS-Komponenten: .tut-table/.tut-row für Vergleichstabellen,
.tut-code für Code-Blöcke, .bad für durchgestrichene Negativbeispiele.
Neues main.css von Vite extrahiert und in index.html eingebunden.

Konzepte erweitert: Token, TTL, System-Prompt, Vision ergänzt (war 9, jetzt 12).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:48:25 +02:00
Hitonabi c33fe57433 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>
2026-06-23 08:31:34 +02:00
Hitonabi a93751fb01 feat(dashboard): Dashboard-Redesign + Bugfixes + Pre-load
Bugs behoben:
- Topbar + Server-Panel: 'Kein Modell im VRAM' obwohl geladen → alle
  States (running/ready/loading/starting) werden jetzt erkannt
- Topbar zeigt bei geladenem Modell: Name + ctx-Größe + State mit Farbe

Dashboard-Redesign (OverviewPanel):
- KPI-Kacheln mit Farbe: .kpi.green/.red/.blue/.muted statt .tile
  (farbige Randlinien + farbige Werte wie im Referenz-Design)
- Icons pro Kachel (server, cpu, thermo, database)
- Stack-Liste: zeigt ctx/quant/GB, 'Laden'-Button wenn Modell bereit
  aber nicht geladen, ∞-Chip für immer-aktive Modelle
- 4. Schnellstart-Button: 'Tools verbinden'

Feature: Modell Vorwärmen (Pre-load):
- POST /api/preload?model=<name> — sendet Mini-Request an llama-swap
  damit es das Modell vorab lädt (max_tokens=1, kein Output)
- 'Laden'-Button je Modell in der Stack-Liste

Topbar CSS:
- Aktives Modell: teal-hinterlegter Pill wenn Modell geladen
- Kontext-Größe direkt neben Modellname angezeigt

Cookbook:
- Discover: genaue Zeitanzeige wann Quellen zuletzt befragt wurden
  ('vor X min / h / Tg.') statt nur 'aktuell'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:04:40 +02:00
Hitonabi f536f454ba fix(connect): OpenCode contextWindowSize nutzt echten Modell-Kontext
openCodeBlock() baut jetzt Modell-Objekte statt nur ID-Strings, damit
m.meta.ctx ausgelesen werden kann. contextWindowSize wird korrekt auf
den konfigurierten Kontext gesetzt (Standard: 131072 = 128k).

Vorher: { "name": "coder" }  (kein Kontexthinweis → AI-SDK-Default)
Nachher: { "name": "coder", "contextWindowSize": 131072 }

Identisches Problem wie bei Zed max_tokens (war 32768 hardcoded).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:25:52 +02:00
Hitonabi d8fe1f8097 fix+feat: offene Punkte abgeschlossen
Kontext in Connect-Snippet:
- ConnectPanel: max_tokens nutzt jetzt den echten konfigurierten Kontext
  des Modells (m.meta.ctx) statt hartkodiertem 32768 -> Zed sieht 128k

Phase D - 'Immer aktiv halten' Toggle:
- models.py: UpdateReq bekommt optionales ttl-Feld
- update_model: setzt TTL wenn angegeben (ctx und ttl unabhaengig)
- ModelsPanel: Checkbox 'Immer aktiv halten' im Konfig-Modal
  (TTL=99999 = nie entladen, TTL=300 = Standard 5min)

MoE tps-Schaetzung:
- hw_math.py: extract_active_params_b() erkennt A3B-Suffix
- estimate_speed(): moe_active_ratio-Parameter, sqrt-Boost fuer MoE
- evaluate_fit(): name-Parameter (optional) fuer automatische MoE-Erkennung
- cookbook.py: alle evaluate_fit-Aufrufe mit name= versehen

Cleanup:
- static/js/panels/*.js + static/js/main.js geloescht (Dead Code)
- wird-Datei (versehentlich committet) geloescht
- CLAUDE.md: KISS-Statement auf Vite+Svelte-Stand aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:21:15 +02:00
Hitonabi 5e9471a778 fix(vision): mmproj-Projektor wird jetzt automatisch mitgeladen
Bisher: install-recipe + install-model luden nur die Haupt-GGUF, aber
nicht den mmproj-Projektor → Vision-Modelle starteten ohne --mmproj und
konnten keine Bilder verarbeiten (obwohl der Hinweis sagte 'wird automatisch ergänzt').

Fix:
- _pick_mmproj(repo): findet die mmproj-F16/BF16-Datei im HF-Repo
- install-recipe: lädt mmproj mit hf download (multi-file) + ergänzt --mmproj/--jinja im cmd
- install-model: gleicher Fix
- Requirement-Text präzisiert (explizit 'mmproj-F16 wird mitgeladen')

hf-cli unterstützt mehrere Dateien pro Download-Aufruf → ein einziger Job
für Haupt-GGUF + Projektor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:13:02 +02:00
Hitonabi 86831a3627 feat(phase-c): Vollstaendige Svelte-5-Migration aller 8 Panels
Alle Panels vollstaendig migriert (kein Legacy-JS mehr):
- OverviewPanel: Hero, Metriken, System-Gesundheit, Stack, Top-News
- JobsPanel: KPI-Kacheln, Sparklines, Job-Liste mit Log/Abbrechen
- ServerPanel: Engine-Status, Service-Aktionen, Live-Konsole (WebSocket)
- ModelsPanel: Schnelltest-Chat, Tabelle, Konfig- + Rollen-Modal
- CookbookPanel: Rezepte, Discover, Profi-Suche, 3 Modals (Rezept/Modell/Neu)
- ConnectPanel: Wizard, Tool-Picker, merge-sichere Snippets
- GuidesPanel + NewsPanel: bereits in Phase C1 migriert

main.ts: importiert nur noch Svelte-Panels (keine Legacy-JS-Imports)
index.html: Placeholder-Divs entfernt, nur noch leere Sections
stores/jobs.svelte.ts: track/toggle-Funktionen fuer ServerPanel
svelte.config.js: a11y-Warnungen als non-fatal konfiguriert
Build: 119 Module -> 137 kB (gzip: 48 kB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:08:08 +02:00
Hitonabi 27b229b3d7 docs/fix: Deploy-Kette fuer Svelte-Build dokumentiert + node_modules excludiert
- CLAUDE.md: npm run build als Schritt vor git push dokumentiert, Vite-Dev-Hinweis
- maintenance.py self-update: frontend/node_modules vom rsync ausgenommen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:50:49 +02:00
Hitonabi e2fc1d24cf feat(phase-c): Vite + Svelte 5 Infrastruktur + erste 2 Panels migriert
- frontend/ eingerichtet: Vite 6, Svelte 5 (Runes), TypeScript
- Stores: status/jobs/system mit $state-Runes (status.svelte.ts etc.)
- main.ts ersetzt main.js: bundelt Legacy-Panels (overview/models/server/
  jobs/cookbook/connect) + mountet neue Svelte-Panels (guides/news)
- GuidesPanel.svelte: statischer Guide mit Akkordeon-Tutorials + Konzept-Grid
- NewsPanel.svelte: reaktives Newsboard mit API-Fetch + Upgrade-Vorschlaegen
- Vite-Output: static/dist/main.js (committet, kein Build auf dem Server)
- index.html: laedt jetzt static/dist/main.js statt static/js/main.js
- Build: 118 Module → 134 kB (gzip: 44 kB)

Naechste Schritte: Overview → Jobs → Server → Models → Cookbook migrieren.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:50:12 +02:00
Hitonabi ee4e3c1dd4 feat(connect): Wizard-Style Verbinden-Tab v5
- 4-Schritt-Wizard: Verbindungstest → Auto-Swap-Erklaerung → Modell-Auswahl → Tool-Picker → Config-Snippet
- Merge-sichere Snippets (nur den einzufuegenden Block, nicht die ganze Datei) fuer Zed, OpenCode, Cline, OpenWebUI
- OpenCode: Warnung "llama-swap-Eintrag vorher loeschen" behebt Duplikat-Problem
- Auto-Swap erklaert (3 Kacheln): kein manuelles Starten, automatisches Wechseln, 2-Modelle-Tipp
- Modell-Chips: interaktive Auswahl welche Modelle im Snippet erscheinen
- Tool-Picker: Radio-Button-Stil, aktive Wahl hervorgehoben

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:36:21 +02:00
Hitonabi 82d15d82db fix(phase-a): Kontext-Cap, Download-State, Recipe-Edit, OS-Badge, Swap-Flash
A1: 32768-Kontext-Cap entfernt (install-recipe, install-model, register) →
    max_ctx_for() liefert nun bis zu 128k auf Strix Halo; behebt "context
    size exceeded" bei externen Tools.
A2: Download-State jetzt im Status-Endpoint sichtbar: Modelle zeigen
    "↓ Download X%" statt "bereit" während Job läuft (Backend + Frontend).
A3: PUT /api/cookbook/user-recipe/{id} + Edit-Button (✎) für eigene Setups.
    Download-Modal setzt Kontext-Input automatisch auf optimal.
A4: /api/updates liefert apt_cache_age_h; Badge zeigt Tooltip + ⚠ wenn >24h.
A5: Swap-Flash: Topbar-Text pulst kurz teal wenn Modell den State wechselt.
A6: LLM-Engine-Update fragt jetzt per confirmModal nach (Konsistenz).
A7: Event-Delegation statt per-render addEventListener in models.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:33:03 +02:00
Hitonabi 022c42dccb feat(cookbook): Elite-Lineup + MoE-Badge/Faehigkeit + Best-of-4
- Elite-Lineup-Rezept (5 Rollen: coder/manager/reviewer/vision/scout), MoE-orientiert -> wird automatisch 'Beste Wahl' (meiste Modelle, passt).

- MoE-Erkennung clientseitig aus Repo-Name (8xNB / -A3B / mixtral/moe); Chip 'MoE - ~XB aktiv' in Discover-/Such-Karten + Rezept-Detail.

- Faehigkeits-Chip (Bilder/Coden/Logik/Agenten/Allrounder) je Modell.

- Discover hebt je Kategorie das beste lauffaehige Modell hervor (recommended pro Kategorie: bester Fit, dann Downloads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:24:56 +02:00
Hitonabi bc15e75e3e feat(server): llama-swap transparent machen
Neue Karte oben im Server-Tab beantwortet "funktioniert es / wie steuern /
wie pruefen": Status (erreichbar/offline), aktuell geladenes Modell, Adresse
fuer die Tools, Anzahl bereiter Modelle + Klartext, wie man ansteuert. Knopf
"Verbindung pruefen" testet die Engine live (/api/integration/test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:00:40 +02:00
Hitonabi 4077d06b2d feat(rollen): echte Modellnamen als API-ID + Rollen als Tags/Alias
Modelle erscheinen jetzt mit ihrem ECHTEN Namen (= API-Name, den man in Zed/
OpenCode angibt) statt unter "coder"/"vision". Die Rolle wird ein zusaetzlicher,
sprechender Name (llama-swap-`aliases`) -> in der API funktionieren BEIDE.

- llamaswap.py: model_id_from_path (Repo-Ordner ohne -GGUF) + set_role_alias
  (Rolle als eindeutiger Alias, beim Setzen bei anderen Modellen entfernt) +
  ROLE_IDS (vision/coder/scout/reviewer/manager).
- models.py: register schreibt Key=Modellname, Rolle als Alias; /status liefert
  role + aliases + api_ids; neuer POST /set_role. Rueckwaertskompatibel: Legacy-
  Eintraege (coder/vision ohne Alias) behalten ihren Key als Rolle.
- cookbook.py: install-recipe/-model nutzen dasselbe Schema (Name + Rollen-Alias).
- models.js: Zeile zeigt echten Namen + Rollen-Tag + API-Name (klick=kopieren);
  neuer "Rolle"-Dialog mit 5 Schnellwahl-Rollen + freier Eingabe.
- cookbook.js: Profi-Download-Feld ist jetzt optionale Rolle (Name kommt vom
  Modell), Pflichtfeld-Pruefung entsprechend gelockert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:58:07 +02:00
Hitonabi 0c16fb28c2 feat(cookbook): autonome Modell-Entdeckung aus vertrauenswuerdigen Quellen
"Aktuell beste Modelle fuer dein System": fragt vertrauenswuerdige HF-Orgs
(unsloth/bartowski/ggml-org/lmstudio-community) LIVE ab, kategorisiert die
Treffer (vision/coder/reasoning/agent/scout), filtert per hw_math auf das,
was auf die Hardware passt, und cached das Ergebnis (TTL 12 h, lazy + Knopf
"Aktualisieren"). Damit bleibt das Cookbook von selbst aktuell, ohne dass
Modelle hartkodiert werden.

- sources.py: TRUSTED_AUTHORS + CATEGORIES + SKIP_TOKENS (reine Daten).
- config.py: DISCOVER_CACHE_PATH (persistent neben den Modellen, uebersteht
  Deploys) + DISCOVER_TTL.
- cookbook.py: /api/cookbook/discover (force-Param), refresh_discover,
  Bestandsabgleich (_model_installed -> "schon installiert als X") und
  ehrliche Voraussetzungen je Modell (Vision->mmproj/jinja, unquantisiert,
  zu gross/knapp).
- cookbook.js: Sektion mit Kategorien, Fit-Ampel, Downloads, Hinweisen,
  1-Klick-Installieren bzw. "installiert"-Markierung; "Aktualisieren"-Knopf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:47:37 +02:00
Hitonabi 0ed4dd84b8 feat(verbinden): verifizierter Zed-Guide + proxy-feste Engine-Adresse
- Zed als empfohlene IDE: fertige settings.json (language_models.
  openai_compatible.bosgame) mit available_models aus dem echten Stack,
  Vision-Modelle bekommen capabilities.images; API-Key-Hinweis (UI/Env,
  nicht in settings.json) — Struktur gegen Zed-Doku verifiziert.
- baseUrl() nutzt jetzt fix http://<host>:8080/v1 (statt location.protocol),
  damit der Aufruf hinter einem Reverse-Proxy (NPM/HTTPS) nicht in eine
  kaputte https-:8080-URL kippt.
- Erkennt Proxy-Zugriff (behindProxy) und warnt: Engine-API laeuft direkt
  am Bosgame auf :8080 ohne HTTPS -> rohe LAN-IP eintragen, nicht den Namen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:39:51 +02:00
Hitonabi 7c7fcf87d1 feat(quick-wins): HF-Link-Suche, Download-ETA, Job-Verlauf loeschbar
- Cookbook-Profisuche: ganze HF-URL oder "owner/repo" direkt einfuegbar
  (parseHfRepo) -> exakter Treffer statt unscharfer Suche; limit 12->40,
  damit auch grosse/seltenere Modelle (z.B. Llama-4-Scout) erscheinen.
- jobengine.attach_download_progress: geglaettete Rate (EMA) + Restzeit
  (eta_s) je Download; Aktivitaet zeigt "noch ~X min · Y MB/s".
- Jobs: erledigte/abgebrochene Eintraege einzeln loeschbar (DELETE
  /api/jobs/{id}) + "Verlauf leeren" (POST /api/jobs/clear); laufende
  bleiben geschuetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:35:40 +02:00
Hitonabi 81f6861df8 feat: eigene Cookbook-Setups erstellen & loeschen
- Nutzer koennen im Cookbook eigene Use-Case-Setups anlegen (Titel, Beschreibung,
  beliebige Modelle: Repo + Rolle + Quant). Groesse wird aus dem Repo-Namen
  abgeleitet; Fit-Ampel + optimaler Kontext kommen wie bei kuratierten Setups
  zur Laufzeit aus hw_math.
- Persistenz: JSON-Datei (MC_USER_RECIPES, Default /srv/models/...) -> ueberlebt
  Deploys (liegt bewusst NICHT im rsync-Ziel).
- /api/cookbook/recipes merged eingebaute + eigene Setups; install-recipe findet
  beide. Neue Endpunkte POST/DELETE /api/cookbook/user-recipe.
- UI: "+ Eigenes Setup", Modal mit dynamischen Modell-Zeilen; eigene Karten mit
  "Dein Setup"-Tag + Loeschen. "Beste Wahl" bleibt auf kuratierte beschraenkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:13:55 +02:00
Hitonabi 5761556d84 feat(guide+verbinden): Agenten/Skills/MCP als Dummy-Leitfaden + Quote-Bug fix
- Guide: tiefe Einsteiger-Abschnitte "Was ist ein Agent (und wann)", "Was sind
  Skills (Quellen + Installation)", "MCP — Werkzeuge geben"; Konzept-Karten
  Agent/MCP/Skills geschaerft + "Rules/Commands"-Karte
- Verbinden: Hinweis verlinkt zum Guide (Agent/Skill/MCP von Grund auf)
- FIX: MCP-Konzeptkarte hatte ein ASCII-`"` in einem doppelt-gequoteten String
  -> String brach vorzeitig ab, guides.js parste nicht -> die GANZE Mount-Kette
  stand (Modelle/Verbinden/Guide leer). Im Text statt Anfuehrungszeichen "Anschluss".

Hinweis: `node --check x.js` parst lax als CommonJS und uebersah den Fehler;
erst ein erzwungener ESM-Parse (.mjs) bzw. der Browser deckte ihn auf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:02:29 +02:00
Hitonabi acc754b93e fix: verstuemmelte MC_CMD_TEMPLATE abfangen (systemd-Quoting-Bug)
Eine unquotierte `Environment=MC_CMD_TEMPLATE=llama-server -m {model} ...`-Zeile
wird von systemd an Leerzeichen gesplittet -> nur "llama-server" kommt an. Dadurch
schrieb register/install nur "llama-server" ohne -m/-c in die config.yaml (coder/
vision/coder-fast blieben leer; nur scout war von provision.sh direkt gesetzt).

- config.py: fehlt {model} in MC_CMD_TEMPLATE -> sicherer Default
- mission-control.service: Environment-Wert gequotet + Warnhinweis

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:42:22 +02:00
Hitonabi 45ab32955a feat: leere Rollen ehrlich anzeigen + Vision-cmd automatisch (mmproj/jinja)
- status(): Flag "incomplete" fuer Rollen ohne Modell (-m), z.B. provision.sh-
  Platzhalter. Wurden bisher irrefuehrend als "bereit / ? . 8K . ?" gezeigt.
- Modelle-Tab: leere Rollen klar als "kein Modell hinterlegt / leer" + Aktion
  "Modell zuweisen" (-> Cookbook); nicht im Schnelltest-Dropdown waehlbar
- register(): erkennt einen mmproj-Projektor im selben Ordner und ergaenzt
  --mmproj + --jinja automatisch (Vision braucht das in llama.cpp)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:38:16 +02:00
Hitonabi f44da4b8e2 feat: Modelle loeschen (Config austragen + Dateien entfernen)
- POST /api/delete_model: entlaedt das Modell, traegt es aus der config.yaml
  aus und loescht optional die GGUF-Dateien, um Speicher freizugeben
- Sicherheit: Loeschen STRENG auf MODELS_DIR begrenzt (Pfad-Resolve-Check);
  loescht den ganzen Repo-Unterordner inkl. .cache nur, wenn er ein direktes
  Kind von MODELS_DIR ist und kein anderes Modell eine Datei darin nutzt,
  sonst nur die einzelne GGUF-Datei. Meldet freigegebene GB zurueck.
- Modelle-Tab: roter "Loeschen"-Button je Zeile mit Klartext-Sicherheitsabfrage
  (unwiderruflich) via confirmModal(danger)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:31:56 +02:00
Hitonabi 3cf36d436b feat: echter Download-Fortschritt via Datei-Polling
`hf` gibt im Nicht-TTY-Modus keinen Fortschritt aus (am Bosgame verifiziert:
0 CR-Frames). Stattdessen pollt jobengine.attach_download_progress die
wachsende <local-dir>/.cache/huggingface/download/*.incomplete-Datei gegen die
Gesamtgroesse aus der HF-Tree-API (cookbook.hf_file_size) -> exaktes %.

- attach_download_progress an /api/download, install-recipe, install-model
- Frontend (Aktivitaet + Server-Karte): nutzt job.progress bevorzugt,
  Log-%-Parsing bleibt Fallback fuer Tools, die selbst Prozente ausgeben

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:26:27 +02:00
Hitonabi 2cde6ba5a5 feat: Live-Download-Fortschritt + Jobs abbrechen
Job-Engine:
- Ausgabe wird binaer + byteweise gelesen und CRLF-bewusst verarbeitet:
  einzelnes \r (tqdm/hf-Fortschritt) ueberschreibt die letzte Logzeile statt
  sie zu fluten; \r\n bzw. \n = echte neue Zeile. So kommt der Download-
  Fortschritt live an. (Text-Modus wuerde \r zu \n uebersetzen -> daher binaer.)
- cancel_job(): laufenden Prozess terminieren, Status "canceled", _PROCS-Registry
- POST /api/jobs/{id}/cancel

Frontend:
- Aktivitaet: Fortschrittsbalken + %-Anzeige aus der letzten Logzeile,
  "Abbrechen"-Button je laufendem Job, Status "abgebrochen"
- Server/Aktueller Vorgang: Fortschrittsbalken + Abbrechen (mit Rueckfrage),
  "abgebrochen"-Behandlung

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:21:21 +02:00
Hitonabi 720b47a6e1 feat: Cookbook-Download-Fix, Live-Update-Konsole, Profi-Highlight, OpenCode-Guide
Backend:
- hf_bin() + HF_DOWNLOAD_ENV zentral in config.py; install-recipe und
  install-model in cookbook.py nutzten weiter das fehlende `hf` + falsches
  XET-Env -> Haupt-Installationsweg war ebenso kaputt, jetzt konsistent gefixt

Frontend:
- Server: laufende Updates (LLM-Engine/OS) live auf der Server-Seite mitlesbar,
  inkl. klarer Fertig-/Fehlgeschlagen-Meldung (onJobs + Aktueller-Vorgang-Karte)
- Cookbook/Profi-Modus: meistgeladenes, passendes Modell wird als
  "★ Beste Wahl für dein System" hervorgehoben (analog zu den Templates)
- Verbinden: OpenCode korrekt per opencode.json (@ai-sdk/openai-compatible)
  statt irrefuehrender Felder; erklaert "Connect to API"-Fehler (LAN-IP statt
  localhost)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:03:12 +02:00
Hitonabi f07a8440b2 fix: 500er bei register/update_model/install-recipe, Download & favicon
- write_config schreibt atomar (tmp + os.replace) -> llama-swap (-watch-config)
  sieht nie eine halb geschriebene config.yaml; PermissionError wird in eine
  klare Klartext-Meldung uebersetzt (Hinweis auf chown)
- generischer Exception-Handler: unerwartete Fehler kommen als lesbare JSON-
  Meldung im UI an statt als nackter 500 (Anfaenger-Diagnose)
- Download: nutzt die `hf`-CLI aus dem venv (Dienst-PATH kennt venv/bin nicht)
  + HF_HUB_DISABLE_XET=1 statt HF_XET_HIGH_PERFORMANCE (XET-Haenger bei ~6 MB)
- huggingface_hub als Dependency ergaenzt (liefert `hf`)
- favicon.ico: themed SVG (Link-Tag + Route) -> kein 404 mehr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:53:49 +02:00
Hitonabi 3a4de152b0 v6: Toolbar zeigt AI-Engine-Update-Status + immer-sichtbare Badges
- /api/updates liefert zusaetzlich "engine" (llama.cpp-ROCm: neueste
  GitHub-Release vs. /opt/llamacpp-mtime, 1h gecacht)
- Toolbar-Badges fuer OS/Engine/Modelle jetzt auch im "aktuell"-Zustand
  sichtbar (gruener .ok-Style) statt nur bei ausstehenden Updates
- Guide: neuer Tutorial-Eintrag "Was passiert, wenn der Kontext volllaeuft?"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:40:56 +02:00
Hitonabi 3ccdee339a docs: v6 als umgesetzt markiert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:48:25 +02:00
Hitonabi 3408d42fed v6 Phase B: News-Magazin-Layout + Guide-Neubau (Tutorials)
- news.js: Magazin-Karten-Layout (Quell-Chip, Featured-Beitrag, 'fuer dich'-Marker) statt Liste.
- guides.js: praktische Tutorials/Workflows (erster Workflow, Kontext leeren, Agent noetig?,
  Auto-Swap, Tools optimal nutzen) + kompakte Konzept-Karten statt klobiger Akkordeons.
- components.css: .news-grid/.news-card + .concept-grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:47:37 +02:00
Hitonabi e8685f16f3 v6 Phase C: Cookbook diversifiziert + 'Beste Wahl' hervorgehoben
- recipes.py: best-in-class je Use-Case, herstelleruebergreifend (Qwen3-Coder, Gemma 3,
  Mistral Small, DeepSeek-R1, Qwen3-VL) + neue Kategorie 'Nachdenken & Logik'.
- recipes-Endpoint: recommended_id = reichstes Setup das komplett passt.
- cookbook.js + CSS: 'Beste Wahl fuer dein System' (Rahmen + Badge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:42:31 +02:00
Hitonabi d7a3253740 v6 Phase A: Dashboard & Toolbar
- 'Dein Stack' zeigt echtes Modell (Dateiname) hinter coder/scout/vision.
- Top-News-Karte auf der Uebersicht (neben Stack).
- Toolbar-Update-Badges: /api/updates (OS via apt-Cache + Modell-Upgrades),
  klickbar -> Server bzw. News. compute_upgrades aus cookbook wiederverwendet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:38:18 +02:00
Hitonabi b50323b521 docs: v5 als umgesetzt markiert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:19:34 +02:00
Hitonabi b75335dafd v5 Phase 3: News-Quellen aufgeraeumt + Upgrade-Vorschlaege
- news.py: Git-Release-Feeds raus (b9745-Rauschen), Qualitaetsquellen rein
  (HuggingFace, r/LocalLLaMA, Simon Willison, Latent Space, Ahead of AI, The Batch).
- cookbook.py: /upgrades (matcht installierte alte Modelle gegen UPGRADES-Map) +
  /install-model (einzelnes Modell tauschen, GGUF dynamisch, optimaler ctx).
- news.js: 'Fuer dich: Upgrades'-Sektion oben (Modell X ersetzt dein Y, weil ... [Installieren]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:18:37 +02:00
Hitonabi 970e04af30 v5 Phase 2: aktuelle Modelle, GGUF-Klartext, Tools, HF-Token
- recipes.py: Juni-2026-Modelle (Qwen3-Coder-30B-A3B, Qwen3-8B/30B, Qwen2.5-VL-7B, Qwen3-4B);
  nur Repo gespeichert, GGUF-Datei wird beim Installieren dynamisch aufgeloest (_pick_gguf) +
  UPGRADES-Map fuer Phase 3.
- cookbook: 'kein GGUF' neutral statt rot + GGUF-Erklaerung (infoDot); ctx-infoDot.
- connect.js: 'Empfohlene Tools (Juni 2026)' (OpenCode/Cline/Continue) + MCP-Hinweis.
- HF-Token in Einstellungen -> als HF_TOKEN an Downloads/Recipe-Install durchgereicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:13:30 +02:00
Hitonabi 8e7a5425d4 v5 Phase 1: Erklaeren (Swapping/RAM, Kontext, Guides-Neubau)
- Swapping-Klartext + Spitzenbedarf (peak_ram) pro Modell: Modelle-Konfig + Cookbook-Setup-Modal
  zeigen 'nur ein Modell gleichzeitig, groesstes zaehlt'. models.py: peak_ram_gb/_optimal in Meta.
- Kontextfenster: infoDot(ⓘ)-Helfer in ui.js + Tooltips an ctx-Feldern.
- Guides-Tab komplett neu = echter Anfaenger-Guide (LLM/GGUF/Quant/Kontext/Swapping/MCP/Skills/Agenten).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:58:57 +02:00
Hitonabi dbb9e8d72e docs: v4 als umgesetzt markiert (alle 4 Schritte live)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:46:08 +02:00
Hitonabi 26dac1a10b v4 Schritt 4: News-Board
- routers/news.py: aggregiert kuratierte RSS/Atom-Feeds (HuggingFace, llama.cpp, Ollama,
  r/LocalLLaMA, Simon Willison) via httpx + stdlib xml.etree, In-memory-Cache 30 min,
  Hardware-relevante Beitraege (rocm/gfx1151/...) markiert. Degradiert pro Feed sauber.
- news.js: Beitragsliste (Quelle, Titel-Link, relatives Datum) + Aktualisieren.
- Nav 'News' + View + main.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:44:46 +02:00
Hitonabi 2a60def469 v4 Schritt 3: 'Verbinden'-Tab (Orchestrierung)
- routers/integration.py: GET /api/integration/test ruft Engine /v1/models -> ok + Modell-Liste.
- connect.js: Verbindungstest-Button, Bild/Vision-Flow (erkennt Vision-Modell, sonst Cookbook-Hinweis,
  OpenAI-Vision-Snippet), Tool-Configs (OpenCode/Cline/Cursor/OpenWebUI) mit Copy.
- Nav-Eintrag 'Verbinden' + View + main.js Registrierung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:40:43 +02:00
Hitonabi 29f37bd916 v4 Schritt 2: Cookbook 2.0 — Use-Case-Setups (Stacks)
- recipes.py: 6 kuratierte Use-Case-Stacks (Coding, Vision, Chat, LongDoc, Agents, Fast).
- cookbook.py: /recipes (Stack-Fit pro Modell + Gesamturteil) + /install-recipe
  (download + register aller Modelle mit optimalem, gedeckeltem Kontext).
- cookbook.js: Use-Case-Karten als Haupteinstieg + Setup-Modal + 'Komplettes Setup
  installieren'; Roh-Suche in 'Profi-Modus' (collapsible).
- models.py /register: exists()-Check entfernt (fixt frischen Download-Install).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:36:36 +02:00
Hitonabi 654720ed05 v4 Schritt 1: optimale Kontextfenster automatisch ermitteln
- hw_math: max_ctx_for() (Umkehrung der ctx-Heuristik) + extract_params_b + recommend_ctx.
- cookbook.py: optimal_ctx pro Datei in analyze + evaluate.
- models.py status: params_b + optimal_ctx pro Modell (psutil-RAM).
- models.js Konfig-Modal: Empfehlung + 'Optimal uebernehmen'.
- cookbook.js Modal: 'Empfohlener Kontext ... uebernehmen'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:13:30 +02:00
Hitonabi f3da24a712 docs: v4-Plan (Der Lotse) — ctx-Auto, Cookbook 2.0, Verbinden, News
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:07:52 +02:00
Hitonabi aafc41f4b2 docs: CLAUDE.md + ROADMAP auf v3 (Design-System, Beginner-UX, Security, Self-Update)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:01:33 +02:00
Hitonabi 70c7cba613 v3: Mission Control Self-Update + Guides-Copy + Button-Audit
- Neuer Self-Update-Button ("Mission Control aktualisieren"): POST /api/self-update
  macht git pull (Quelle) -> rsync nach Prod -> systemctl restart (NOPASSWD).
  Pfade aus config.py (SOURCE_DIR/PROD_DIR), nicht user-hartkodiert.
- "LLM-Engine aktualisieren" ehrlich (war MC, ist llama.cpp via MC_UPDATE_CMD).
- Guides: Kopier-Buttons fuer alle Configs + Codeblock (Copy-Paste-tauglich).
- Button-Audit: Funktionen/Labels in allen Panels geprueft, stimmig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 12:57:13 +02:00
62 changed files with 9251 additions and 1304 deletions
+76
View File
@@ -0,0 +1,76 @@
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
# Gitea-Actions-Runner (arcane-VM).
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
shell: bash
run: |
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
set -u +e
rot=0
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
exit 0
fi
if [ -n "$py_datei" ]; then
echo "== Python erkannt =="
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
ruff check . || rot=1
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
while IFS= read -r req; do
[ -n "$req" ] || continue
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
python -m compileall -q . || rot=1
echo "-- Pytest (keine Tests gefunden = ROT)"
ec=0; pytest -q || ec=$?
if [ $ec -eq 5 ]; then
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
rot=1
elif [ $ec -ne 0 ]; then
rot=1
fi
fi
if [ -n "$pkg_dateien" ]; then
echo "== Node/TypeScript erkannt =="
while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
d=$(dirname "$pkg")
echo "-- $d"
if [ ! -f "$d/package-lock.json" ]; then
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
rot=1; continue
fi
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
done <<< "$pkg_dateien"
fi
if [ $rot -ne 0 ]; then
echo "❌ AMPEL ROT — nichts heißt fertig', solange das rot ist."
fi
exit $rot
+1
View File
@@ -2,3 +2,4 @@
__pycache__/
*.pyc
.claude/launch.json
frontend/node_modules/
+107 -50
View File
@@ -1,85 +1,142 @@
# Mission Control
Web-Dashboard zur Verwaltung eines **lokalen LLM-Stacks (`llama-swap`)** auf dem Bosgame M5.
FastAPI-Backend + Vanilla-JS-Dashboard. **Leitprinzip: KISS — kein Build-Schritt, kein Frontend-Framework, keine Datenbank.** Concerns sind getrennt (SoC) — aber *ohne* Build: native ES-Module im Frontend, FastAPI-`APIRouter` im Backend.
FastAPI-Backend + Svelte 5-Frontend (Vite-Build). **Leitprinzip: KISS — SoC ohne Overhead.**
## Architektur
**Backend** (Top-Level-Helfer + ein Router je Bereich):
- **`app.py`** — dünner Einstieg: baut `FastAPI`, hängt die Router ein, liefert das statische UI aus, registriert den Exception-Handler. Sonst nichts.
- **`config.py`** — alle Env-Vars + die gemeinsame `ruamel.yaml`-Instanz.
- **`auth.py`** — optionale Token-Auth (`X-MC-Token`).
- **`jobengine.py`** — In-Memory-Job-System (Threads + Subprocess) mit Live-Log; fährt Downloads/Updates.
- **`llamaswap.py`** — spricht `llama-swap` an (`/running`, `/v1/models`, unload) und liest/schreibt dessen `config.yaml` per `ruamel.yaml` (Kommentare bleiben erhalten).
- **`routers/*.py`** — ein Router je Bereich. Aktuell: `models.py` (`status`, `download`, `register`, `unload`, `chat`), `jobs.py` (`jobs`), `maintenance.py` (`update`, `logs`-WebSocket), `system.py` (`status`, `stream`-WebSocket), `cookbook.py` (`analyze`). Alle Endpoints unter `/api/*`.
- **`app.py`** — dünner Einstieg: `FastAPI`, hängt die Router ein, liefert das statische UI aus, Exception-Handler + `Cache-Control: no-cache`-Middleware.
- **`config.py`** — alle Env-Vars + die gemeinsame `ruamel.yaml`-Instanz. Auch `SOURCE_DIR`/`PROD_DIR` fürs Self-Update.
- **`auth.py`** — optionale Token-Auth: Header `X-MC-Token` **oder** Query `?token=` (Query ist Pflicht für WebSockets).
- **`jobengine.py`** — In-Memory-Job-System (Threads + Subprocess) mit Live-Log. Secrets gehen über **stdin** (kein Leak in Log/`ps`).
- **`llamaswap.py`** — spricht `llama-swap` an und liest/schreibt dessen `config.yaml` per `ruamel.yaml`.
- **`hw_math.py`** — Odysseus-Fit-Mathe: VRAM/RAM-Bedarf, tps-Schätzung, `max_ctx_for`, `extract_params_b`.
- **`recipes.py`** — kuratierte Use-Case-Stacks fürs Cookbook 2.0.
- **`mcp_memory.py`** — stdio-MCP-Server: Gedächtnis-Tools für Cline/OpenCode/Claude Code.
- **`routers/*.py`** — ein Router je Bereich:
- `models.py` — status/download/register/update_model/unload/chat
- `jobs.py` — Jobs-Liste
- `maintenance.py` — update/self-update/os-update/service-restart/reboot/logs-WebSocket
- `system.py` — status + stream-WebSocket (Live-Metriken psutil/sysfs)
- `cookbook.py` — analyze/evaluate/recipes/install-recipe/upgrades
- `integration.py` — test (Engine-Verbindungstest)
- `news.py` — RSS-Aggregation via stdlib
- `memory.py` — Gedächtnis-CRUD (SQLite, WAL-Mode), 5 Kategorien
- `hermes.py` — Chat-WS **proxyt zum Hermes-Agent-Server (:8642)** + transcribe(Whisper)/tts(Piper)/status/pubkey + **Cockpit-Reads** agent/cron/skills (Phase 4)
- `hermes_ui.py`**Reverse-Proxy** auf das eingebettete Hermes-Web-Dashboard (`/hermes-ui/`, HTTP + WS-Bridge `pty/ws/pub/events`, `X-Forwarded-Prefix`). Der Chat lebt jetzt hier (iframe), nicht mehr im Eigenbau.
- **`hermes_control.py`** — read-only Control-Plane: liest Agent-Status (`gateway_state.json` + Cron-Heartbeat), Cron-Jobs & Skills via `hermes`-CLI (`COLUMNS=400`, Rich-Tabelle geparst). TTL-Cache.
- **`model_caps.py`** — Modell-Capability-Tags (Phase 10): dependency-freier GGUF-Header-Reader (architecture/expert_count/context_length/parameter_count, offline) + cmd-Flags (`--jinja`/`--mmproj`) + Familien-Fallback → MoE/Tools(yes|likely|no)/Vision/Coder/Reasoning/Embedding. Genutzt von `models.py` (`meta.capabilities`).
**Frontend** (`static/`, dünne Hülle + ES-Module, kein Build):
- **`index.html`** — nur Gerüst: Sidebar-Nav, Topbar, Alert-Banner, ein `.view`-Container je Bereich (Hash-Routing). Lädt `css/*` und `js/main.js` als Modul.
- **`css/base.css`** — Design-Tokens (`:root`), Reset, App-Layout (Sidebar/Topbar/Content). **`css/components.css`** — Karten, KPI-Kacheln, Listen, Forms, Log, Toast.
- **`js/core/*`** — `api.js` (Fetch + Token), `ui.js` (DOM-Helfer, Toast, Icons), `nav.js` (View-Switch).
- **`js/panels/*`** — ein Panel je Bereich (`overview`, `models`, `maintenance`, `jobs`). Panel-Vertrag: `{ id, mount?(), onStatus?(s), onJobs?(jobs) }`.
- **`js/main.js`** — bootet Panels, pflegt Topbar/Alert, baut die WebSocket-Verbindung für Live-System-Metriken (`/api/system/stream`) auf, fährt das reguläre Polling (`/api/status` 3 s, `/api/jobs` 1.5 s) und verteilt an die Panels.
**Frontend** (`frontend/src/`, Svelte 5 + Vite, Build → `static/dist/`):
- **`index.html`** — Gerüst: Sidebar-Nav (10 Tabs), Topbar, Alert-Banner, View-Container je Bereich.
- **`frontend/src/main.ts`** — bootet alle Svelte-Panels, Topbar/Alert, WebSocket Live-Metriken, Polling.
- **`frontend/src/panels/`** — je ein Panel: Overview, Models, Jobs(Aktivität), Server, Cookbook, Connect(Verbinden), News, Guides, Memory(Gedächtnis), Hermes.
- **`frontend/src/stores/`** — Svelte 5 Runes: `status.svelte.ts`, `jobs.svelte.ts`, `system.svelte.ts`.
- **Design-System (v3):** EINE Akzentfarbe Teal `#2dd4bf`. Grün/Gelb/Rot nur für Status. Tokens in `css/base.css`.
- **`mission-control.service`** — systemd-Unit (uvicorn auf Port 9000).
- **Konfiguration** rein über Env-Vars: `MC_LLAMA_SWAP_URL`, `MC_CONFIG_PATH`, `MC_MODELS_DIR`, `MC_CMD_TEMPLATE`, `MC_UPDATE_CMD`, `MC_DEFAULT_TTL`, `MC_TOKEN`.
## Der Stack drumherum
## Der Stack drumherum (Kontext)
- **Bosgame M5**: AMD Strix Halo (gfx1151), Ubuntu 26.04, Kernel 7.0, ~124 GB GTT-Speicher. LAN-IP `192.168.178.151`.
- **Inferenz**: vorgebaute llama.cpp-ROCm-Binaries → `llama-swap` auf `:8080` (läuft mit `-watch-config`).
- **3 Modelle / 5 Rollen**: `coder`, `scout`, `vision` (Qwen3-Familie). Modelle werden geswappt, nicht parallel geladen.
- **Mission Control**: Produktiv unter `/opt/mission-control` (als Dienst), Source-Repo unter `~/mission-control`.
- **Bosgame M5**: AMD Strix Halo (gfx1151), Ubuntu 26.04, Kernel 7.0, ~124 GB GTT. LAN-IP `192.168.178.151`.
- **Inferenz**: llama.cpp-ROCm-Binaries → `llama-swap` auf `*:8080` (LAN-offen, `-watch-config`).
- **3 Modelle / 5 Rollen**: `coder` (Qwen3-30B-A3B), `scout` (Qwen3-8B), `vision` (Qwen3-VL).
- **Mission Control**: Produktiv unter `/opt/mission-control`, Source unter `~/mission-control`.
- **Gedächtnis**: SQLite unter `/srv/models/mission-control-memory.db` (5 Kategorien: user/instruction/stable/versioned/ephemeral).
- **Hermes Agent**: aktuell Eigenbau als Teil von Mission Control; **ab v9 das Nous Hermes-Agent-Framework** als eigener Dienst (`hermes-agent.service`, OpenAI-API auf `:8642`, base_url → llama-swap, MCP-Client bindet `mcp_memory.py` ein). Voice (Whisper STT + Piper TTS) bleibt in Mission Control und wrappt :8642.
## Entwickeln & Deployen
**Wo entwickelt wird:** primär auf einem **Windows-PC** (Repo z. B. `F:\Coding Stuff\mission-control`),
Zielsystem ist der **Linux-Bosgame**. Lokal nur Smoke-Test (rendert/bootet sauber?), echter
Funktionstest nur auf dem Bosgame (dort läuft llama-swap).
**Wo entwickelt wird:** Windows-PC (`F:\Coding Stuff\mission-control`), Ziel: Linux-Bosgame.
Lokal nur Smoke-Test (rendert/bootet?), echter Funktionstest auf dem Bosgame.
**Lokaler Smoke-Test (Windows):**
```bash
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt
.venv/Scripts/python -m uvicorn app:app --port 9001
```
Ohne llama-swap ist alles im Offline-Zustand (rote Pill, Warn-Banner) — das ist erwartet.
**Bosgame-Zugang (SSH, key-basiert, passwortlos):**
**Bosgame-Zugang:**
```bash
ssh -i ~/.ssh/id_ed25519_hermes -o IdentitiesOnly=yes hitonabi@192.168.178.151
```
User ist **`hitonabi`** (klein!). `sudo` braucht ein Passwort (kein passwortloses sudo).
User: `hitonabi`. sudo NOPASSWD-Whitelist für `systemctl restart mission-control|llama-swap` und `journalctl`.
**Deploy-Kette (Windows → Gitea → Bosgame):**
1. Lokal bauen + Smoke-Test → `git push` (origin = Gitea `http://192.168.178.153:3000/Hitonabi/mission-control.git`).
2. Bosgame: `cd ~/mission-control && git pull --ff-only` (Source-Repo).
3. Nach `/opt` ausrollen — **ohne sudo** (`hitonabi` besitzt `/opt/mission-control`), `.venv` ausnehmen:
**Frontend-Build:**
```bash
rsync -a --exclude='.git' --exclude='.venv' --exclude='__pycache__' --exclude='*.pyc' ~/mission-control/ /opt/mission-control/
cd frontend && npm run build # vor jedem git push
```
4. `sudo systemctl restart mission-control` (Passwort nötig). Prod = `:9000`, Dev-Spielwiese = `:9001`.
5. **Niemals direkt in `/opt` arbeiten.** Logs: `journalctl -u mission-control -f`.
Output: `static/dist/main.js` + `static/dist/main.css` — wird committet (kein Build-Schritt auf dem Server).
Statische Dateien werden je Request frisch von Platte gelesen → UI-Änderungen wirken schon nach
rsync; **Python-Code-Änderungen brauchen den Restart**.
**Deploy-Kette:**
```
npm run build → git push (Gitea :3000) → Bosgame: git pull → rsync → systemctl restart
```
Oder über den Self-Update-Button in Mission Control (empfohlen).
**Self-Update-Button** (`POST /api/self-update`): git pull → rsync → restart — alles in einem Klick.
## Konfiguration (Env-Vars)
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | llama-swap URL |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_MODELS_DIR` | `/srv/models` | GGUF-Ablageort |
| `MC_TOKEN` | leer | Auth-Token (optional, LAN-only) |
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | SQLite Gedächtnis |
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
| `HERMES_HOME` | `~/.hermes` | Hermes-Agent State-Dir (Cockpit-Reads) |
| `HERMES_BIN` | `~/.local/bin/hermes` | Hermes-CLI (Cron/Skills-Reads) |
| `HERMES_DASHBOARD_URL` | `http://127.0.0.1:9119` | Hermes-Web-Dashboard (eingebettet via `/hermes-ui/`) |
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH |
| `HERMES_WINDOWS_USER` | `TobisPC` | Windows SSH-Username |
| `HERMES_SSH_KEY` | `~/.ssh/id_ed25519_hermes_agent` | SSH-Key für Hermes |
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimm-Modell |
| `WHISPER_MODEL` | `medium` | Whisper Modell-Größe |
## Konventionen
- KISS über alles: kein schweres Framework, kein Build-Schritt, solange es ohne geht.
- **SoC ohne Build**: neuer Bereich = ein `routers/<bereich>.py` (FastAPI-Router) + ein `static/js/panels/<bereich>.js` (ES-Modul nach Panel-Vertrag) + ein Nav-Eintrag in `index.html`. Gemeinsame Backend-Logik in `config/auth/jobengine/llamaswap`, gemeinsame Frontend-Logik in `js/core/*`. Keine schweren Libs, kein CDN — nur relative `import`s.
- Endpoint-URLs bleiben unter `/api/*`; neue Bereiche degradieren sauber, wenn ihre Quelle (sysfs, `amd-smi`, `systemctl`) fehlt (z. B. beim Entwickeln auf Windows).
- Funktion darf **nicht** von `localStorage` abhängen (nur das Token-Feld nutzt es, das ist ok).
- **Sicherheit**: Das Backend führt Shell-Befehle aus → ausschließlich im vertrauenswürdigen LAN betreiben, niemals offen ins Internet.
- **Neuer Bereich**: `routers/<bereich>.py` + `frontend/src/panels/<Bereich>Panel.svelte` + Nav/View in `index.html` + Import in `main.ts`. Danach `npm run build`.
- **Backend SoC**: gemeinsame Logik in `config/auth/jobengine/llamaswap/hw_math`. Alles unter `/api/*`.
- **Sicherheit**: Shell-Befehle → nur LAN. Secrets via stdin. Token optional.
- **SQLite**: WAL-Mode aktiv, `check_same_thread=False` — ausreichend für Single-User.
- **`asyncio.get_running_loop()`** statt `get_event_loop()` (Python 3.10+ kompatibel).
- **Memory-Kategorien**: `user` | `instruction` | `stable` | `versioned` | `ephemeral` — Enum in `routers/memory.py`.
## Gotchas (wichtig!)
## Gotchas
- **`${PORT}`** in der generierten `llama-swap`-Config muss **literal** stehen bleiben → beim Bauen des cmd-Strings `str.replace` benutzen, NICHT `.format` (sonst KeyError auf `PORT`).
- `llama-swap` muss mit **`-watch-config`** laufen, sonst greift das Auto-Einpflegen neuer Modelle nicht.
- HuggingFace-Downloads mit **`HF_HUB_DISABLE_XET=1`** (sonst reproduzierbarer Hänger bei ~6 MB).
- Vision-Modelle in llama.cpp brauchen zusätzlich **`--mmproj <projektor>` und `--jinja`**.
- **`${PORT}`** in llama-swap-Config `str.replace` statt `.format` (KeyError sonst).
- **`-watch-config`** bei llama-swap nötig für Auto-Einpflegen.
- **`HF_HUB_DISABLE_XET=1`** bei HuggingFace-Downloads (Hänger bei ~6 MB).
- **Vision-Modelle**: brauchen `--mmproj <projektor>` und `--jinja`.
- **Gitea-Push** kann transient mit „Failed to authenticate" fehlschlagen → einfach nochmal.
- **Piper TTS**: Binary + Stimm-Modell müssen separat installiert werden (nicht via pip).
- **Whisper**: lädt beim ersten Aufruf ~1.4 GB Modell herunter (einmalig, dann gecacht).
- **Hermes SSH-Key**: muss auf dem Bosgame generiert werden (`~/.ssh/id_ed25519_hermes_agent`).
- **system_status-Keys**: `/api/system/status` liefert verschachtelt (`cpu.percent`, `ram.used` in Bytes) — nicht flach. Konsumenten müssen entsprechend lesen.
- **Hermes-Server-Key**: `routers/hermes.py` proxyt zu `:8642` und liest den Key bei Bedarf aus `~/.hermes/.env` (`API_SERVER_KEY`) — MC muss als User `hitonabi` laufen, sonst kein Lesezugriff.
- **Hermes-Dashboard (Phase 5)**: braucht Extras `uv pip install --python ~/.hermes/hermes-agent/venv/bin/python -e ".[web,pty]"` und einen einmaligen UI-Build (erster `hermes dashboard`-Start ohne `--skip-build`; Output → `hermes_cli/web_dist`). Dienst: `~/.config/systemd/user/hermes-dashboard.service` (an `127.0.0.1:9119` gebunden, `--skip-build`). Wird unter `/hermes-ui/` per `X-Forwarded-Prefix` geproxyt — nie ohne diesen Header proxen, sonst absolute Asset-Pfade kaputt. `/hermes-ui/*` ist bewusst **ohne** MC-Token (LAN-only; Dashboard self-auth auf Loopback).
- **OpenCode Config**: Datei heißt `opencode.jsonc` (nicht `.json`), Key ist `"providers"` (Plural).
- **ConnectPanel hinter NPM-Proxy**: `location.hostname` zeigt Proxy-Domain → LAN-IP Override im Verbinden-Tab setzen (localStorage).
## Projektstatus & Roadmap
## Projektstatus
Die v2-Roadmap ist **vollständig umgesetzt** (siehe `ROADMAP.md`).
Wir befinden uns nun im **Feinschliff- und Wartungsmodus**.
**v7 (Memory Layer)**: SQLite-Gedächtnis mit 5 Kategorien + MCP-Server für alle Tools live.
**v8 (Hermes Agent)**: Chat-Agent mit Voice (Whisper STT + Piper TTS), Tool Calling, WebSocket-Streaming live.
**v8.1 (Bug-Fix)**: system_status-Keys, Model-Routing, asyncio, sudo-PW für Engine-Update, Chat-History in sessionStorage.
**v8.2 (UX)**: Guide-Tabs + Begriffe oben, Mobile Bottom-Nav (≤520px), Download-Links, ConnectPanel-Fixes.
**v8.3 (Memory Import)**: Import aus Cloud-KIs (Claude/Gemini/ChatGPT) — zeilenweiser Batch-POST.
**v9 (Hermes-Agent-Adoption, Entscheidung 2026-06-23)**: Eigenbau-Agent → Nous Hermes-Agent-Framework (MIT). MC wird Control-Plane. Eigenes Memory bleibt via MCP erhalten. Phasen 03 live (harter Cutover: `hermes_agent.py` entfernt) **+ Phase 4**: Agent-**Cockpit** (Status/Cron/Skills) **+ Phase 5 (Kurswechsel)**: Eigenbau-Chat ersetzt durch das **eingebettete Hermes-Web-Dashboard** (`hermes-dashboard`-Dienst :9119, MC-Reverse-Proxy `/hermes-ui/`, iframe) **+ Phase 6**: volle lokale Rechte (`approvals.mode:auto`, `hooks_auto_accept`, `subagent_auto_approve`) **+ Phase 7**: Agent-Modell **Hermes 4 14B Q6_K** (llama-swap-Alias `hermes`, `--jinja`) **+ Phase 8**: Cockpit zeigt Lernen (`USER.md`) + Aktivität (`hermes insights`) **+ Phase 9**: `mcp_memory.py`-Tool-Beschreibungen proaktiv → geteiltes Memory wächst autonom mit. Offen: Kurator/Dedup fürs MC-SQLite, Self-Update für `hermes-gateway`+`hermes-dashboard`. Siehe ROADMAP v9.
**Nordstern:** den Server nie wieder via SSH/Putty anfassen müssen — 100 % Automatisierung / Klicki-Bunti.
**Memory-Architektur (entschieden 2026-06-24):** Zwei komplementäre Schichten, keine Doppelung-Panik. **MC-SQLite (via MCP) = geteilte „Verfassung"** für ALLE Tools (Hermes, Cline, OpenCode, Zed) — kuratierbare, deterministische Fakten/Regeln; wächst autonom durch proaktives `add_memory`. **Hermes-nativ (`~/.hermes/memories/USER.md` + `state.db` FTS5) = Hermes' privates Profil-/Gesprächsgedächtnis** (nur Hermes). Linie: „soll das jedes Tool wissen?" → MC-SQLite; „Hermes' Eindruck/Gesprächsfaden" → nativ. Dokumenten-„Second Brain" (Obsidian/RAG) ist davon getrennt und nur bei Bedarf zu bauen.
Offene v8-Einrichtungsschritte (kein Code, nur Setup):
- Piper Binary + Kerstin-Stimme installieren (Befehle im Hermes-Tab → ⚙ Setup)
- Windows OpenSSH Server aktivieren + SSH-Key einrichten
**Nordstern:** Den Bosgame nie wieder via SSH/Putty anfassen — 100 % Automatisierung / Klicki-Bunti.
+86 -57
View File
@@ -1,85 +1,114 @@
# Mission Control
Eine schlanke Steuerzentrale für deinen lokalen `llama-swap`-Stack auf dem Bosgame M5.
Ein FastAPI-Backend + ein HTML-Dashboard. Kein Build-Schritt, keine Datenbank.
Persönliche Steuerzentrale für einen lokalen LLM-Stack auf dem Bosgame M5.
FastAPI-Backend + Svelte 5-Frontend. Kein Cloud-Zwang, keine Datenbank-Abhängigkeit.
## Was sie kann
## Was es kann
- **Modelle & Ports** sehen — liest `llama-swap` (`/running`, `/v1/models`) + deine `config.yaml`
- **Modell holen** — lädt eine GGUF-Datei von HuggingFace (`hf download`) als Hintergrund-Job mit Live-Log
- **Einpflegen** — schreibt das Modell automatisch in deine `config.yaml`; `llama-swap` lädt mit `-watch-config` neu
- **Wartung** — Container/Toolbox aktualisieren, Modelle aus dem Speicher werfen
- **Schnelltest** — Chat-Box, um ein Modell zu wecken und zu prüfen
**LLM-Stack verwalten**
- Modelle & Status sehen (llama-swap `/running`, `/v1/models`, `config.yaml`)
- Modelle von HuggingFace laden — als Hintergrund-Job mit Live-Log
- Automatisch in llama-swap einpflegen (`-watch-config`)
- Optimale Kontextfenster berechnen (hardware-aware, `hw_math`)
Was sie **bewusst nicht** macht: Chat-Logs, Inferenz-Monitoring im Detail — dafür hat `llama-swap`
schon `/ui` und `/log`. Mission Control ergänzt nur, was fehlt.
**Cookbook**
- Use-Case-getrieben: Coding · Chat · Vision · Agenten · Schnell & sparsam
- Hardware-Ampel pro Setup, „Beste Wahl für dein System"
- 1-Klick „Komplettes Setup installieren"
**Wartung & Server**
- OS-Updates, Dienste steuern (llama-swap, Mission Control), Reboot
- Live-Logs (journalctl via WebSocket)
- Self-Update-Button (git pull → rsync → restart)
**Verbinden**
- Copy-Paste-Configs für Cline, OpenCode, Zed, Continue, Jan AI, OpenWebUI — inkl. Download-Links
- LAN-IP Override für NPM-Proxy-Setups (localStorage)
- Gedächtnis-MCP: alle Tools teilen dasselbe persistente Gedächtnis
**Gedächtnis** *(v7/v8.3)*
- SQLite-Datenbank mit 5 Kategorien: `user` · `instruction` · `stable` · `versioned` · `ephemeral`
- MCP-Server (`mcp_memory.py`): Cline, OpenCode, Claude Code teilen dasselbe Gedächtnis
- Import aus Cloud-KIs (Claude/Gemini/ChatGPT): zeilenweise einfügen → Batch-POST
- Temporäre Einträge (ephemeral) nach 7 Tagen automatisch gelöscht
**Hermes Agent** *(v8)*
- Lokaler KI-Assistent direkt im Browser — Text und Sprache
- Whisper STT (lokal, CPU) + Piper TTS (weibliche Stimme Kerstin)
- Tool Calling: Dateien lesen, Befehle ausführen, System abfragen, Web suchen
- Gedächtnis-Integration: Hermes kennt dich und deinen Stack von Anfang an
- Chat-History überlebt Browser-Reload · Stopp-Button · Verlauf löschen
- Setup-Wizard für Piper + Windows SSH-Zugriff
## Voraussetzungen
- Python 3.11+
- `hf` CLI installiert (`pip install -U "huggingface_hub[cli]"`)
- ein laufendes `llama-swap` — gestartet **mit `-watch-config`**, sonst greift das Auto-Einpflegen nicht
- `llama-swap` laufend mit `-watch-config`
- `hf` CLI (`pip install -U "huggingface_hub[cli]"`)
- Für Voice: `faster-whisper` + Piper Binary (Anleitung im Hermes-Tab)
## Installation
## Schnellstart (Bosgame)
```bash
sudo mkdir -p /opt/mission-control && sudo chown $USER /opt/mission-control
cp -r *.py routers static /opt/mission-control/
git clone http://192.168.178.153:3000/Hitonabi/mission-control.git ~/mission-control
rsync -a --exclude='.git' ~/mission-control/ /opt/mission-control/
cd /opt/mission-control
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 9000
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
```
Dann im Browser im LAN: `http://<bosgame-ip>:9000`
### Als Dienst (Autostart)
Pfade in `mission-control.service` anpassen, dann:
Als Dienst:
```bash
sudo cp mission-control.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now mission-control
sudo systemctl daemon-reload && sudo systemctl enable --now mission-control
```
## Konfiguration (Umgebungsvariablen)
Browser im LAN: `http://192.168.178.151:9000`
## Konfiguration
Alle Einstellungen über Umgebungsvariablen in der systemd-Unit:
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | wo `llama-swap` lauscht |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | die `llama-swap`-Config |
| `MC_MODELS_DIR` | `/srv/models` | wohin GGUFs geladen werden |
| `MC_CMD_TEMPLATE` | siehe unten | Startbefehl pro Modell |
| `MC_UPDATE_CMD` | _(leer)_ | Befehl für „Container aktualisieren" |
| `MC_DEFAULT_TTL` | `300` | Sekunden bis Auto-Unload |
| `MC_TOKEN` | _(leer)_ | optionales Zugriffs-Token |
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | llama-swap URL |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_MODELS_DIR` | `/srv/models` | GGUF-Ablageort |
| `MC_TOKEN` | leer | Auth-Token (optional) |
| `MC_UPDATE_CMD` | leer | Engine-Update-Befehl |
| `MC_SOURCE_DIR` | `~/mission-control` | Self-Update Quelle |
| `MC_MEMORY_DB` | `{MODELS_DIR}/mission-control-memory.db` | Gedächtnis-Datenbank |
| `HERMES_API_URL` | `http://127.0.0.1:8642/v1` | Hermes-Agent-Server (OpenAI-API) |
| `HERMES_API_KEY` | aus `~/.hermes/.env` | API-Key des Hermes-Servers (sonst Env) |
| `HERMES_WINDOWS_HOST` | leer | Windows-PC IP für SSH-Zugriff |
| `PIPER_BIN` | `/opt/mission-control/piper/piper` | Piper TTS Binary |
| `PIPER_VOICE` | `.../de_DE-kerstin-low.onnx` | Piper Stimme |
| `WHISPER_MODEL` | `medium` | Whisper Modell-Größe |
### Wichtig: `MC_CMD_TEMPLATE` an deinen Start anpassen
## Sicherheit
Das ist der Befehl, der pro Modell in die `config.yaml` geschrieben wird. `{model}` und `{ctx}`
werden von Mission Control ersetzt, **`${PORT}` bleibt stehen** (das ersetzt `llama-swap` selbst).
- Ausschließlich im vertrauenswürdigen LAN betreiben
- `MC_TOKEN` setzen für minimalen Schutz
- Für Remote-Zugriff: SSH-Tunnel oder Tailscale, niemals direktes Port-Forwarding
- Hermes-SSH-Key hat nur Zugriff auf explizit freigegebene Windows-Pfade
## Architektur
Direkt auf dem Host (llama-server im PATH):
```
llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap
Browser (Windows PC)
├── Svelte 5 UI (static/dist/main.js)
└── WebSocket (Live-Metriken, Hermes-Chat, Logs)
FastAPI (Bosgame :9000)
├── routers/models.py LLM-Verwaltung
├── routers/system.py Live-Metriken (psutil/sysfs)
├── routers/maintenance.py Updates, Logs, Neustart
├── routers/cookbook.py Modell-Empfehlungen
├── routers/memory.py Gedächtnis (SQLite)
├── routers/hermes.py Agent-Chat, Voice (Whisper/Piper)
└── ...
llama-swap (:8080) Inference (Qwen3-Familie)
SQLite Gedächtnis (/srv/models/mission-control-memory.db)
mcp_memory.py MCP-Server für Cline/OpenCode/Claude Code
```
Über den kyuz0-Container (distrobox) — Beispiel, an deinen Toolbox-Namen anpassen:
```
distrobox enter llama-vulkan-radv -- llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap
```
> `-fa 1` (Flash Attention) und `--no-mmap` sind auf Strix Halo Pflicht, sonst drohen Crashes/Slowdowns.
## ⚠️ Sicherheit
Mission Control führt Shell-Befehle aus (Downloads, Updates) und schreibt deine Config.
**Niemals offen ins Internet hängen.** Betrieb nur im vertrauenswürdigen LAN. Wenn du
trotzdem etwas Schutz willst, setz `MC_TOKEN` und trag es oben rechts im Dashboard ein.
Für echten Remote-Zugriff: per SSH-Tunnel oder Tailscale, nicht per Portfreigabe.
## API (falls du es skripten willst)
`GET /api/status` · `POST /api/download` · `POST /api/register` · `POST /api/unload[?model=]`
· `POST /api/update` · `POST /api/chat` · `GET /api/jobs` · `GET /api/jobs/{id}`
+126 -68
View File
@@ -1,86 +1,144 @@
# Mission Control - Roadmap v2
# Mission Control Roadmap
**Nordstern:** Den Bosgame nie wieder via SSH/Putty bedienen müssen. **100 % Automatisierung oder Klicki-Bunti.**
**Nordstern:** Den Bosgame nie wieder via SSH/Putty bedienen. **100 % Automatisierung oder Klicki-Bunti.**
**Größeres Ziel:** Mission Control als persönliches **Agentic OS** — lokaler, privater KI-Assistent der das gesamte Setup kennt und steuert.
---
## Projektstand (Stand 2026-06-20)
## v9 — Hermes-Agent-Adoption (🔜 IN ARBEIT, Entscheidung 2026-06-23)
**✅ Schritt 1 erledigt & live auf `:9000`** — *SoC-Refactor + Design 2.0 als Fundament* (Commit `3649394`).
Backend in Helfer + `routers/*` zerlegt, Frontend in ES-Module (`js/core` + `js/panels`) + ausgelagertes
CSS, neues Dashboard-Layout (Sidebar-Nav, Topbar, Alert-Banner, Hero, KPI-Kacheln, Health-Signale,
Modell-Listen, Aktivitäts-Stream) angelehnt an `docs/mission-control-overview.png`. Bestehende Funktionen
1:1 migriert, Endpoint-URLs unverändert. Architektur + Deploy-Weg stehen in **CLAUDE.md**.
**Strategiewechsel:** Der hand-gebaute Agent (`hermes_agent.py`) wird durch das **Nous Research Hermes Agent**-Framework (MIT, [github.com/nousresearch/hermes-agent](https://github.com/nousresearch/hermes-agent)) ersetzt. Mission Control wird zur **Control-Plane** darum herum.
**✅ Schritt 2 erledigt & live** — *Feature 3: Live-Auslastung*. `system.py` Router mit `psutil` und sysfs für CPU/RAM/Disk/GPU/Temp.
**Warum:** Der Eigenbau ist schwach wegen Scaffolding (blinde DuckDuckGo-Suche, Memory-Voll-Dump in den Prompt, Fake-Streaming) — *nicht* wegen des Modells (Qwen3 ist SOTA bei Tool-Calling). Hermes Agent liefert die gesamte alte v9-Wunschliste fertig & gepflegt: Cron-Scheduler, Cross-Session-Memory (FTS5), Skills (procedural memory), 40+ Tools.
**Reihenfolge (abgestimmt):** Design/Architektur zuerst (✅), dann Quick Wins, Security-Brocken zuletzt:
`1 (✅) Fundament → 2 (✅) Feature 3 Live-Auslastung → 3 (✅) Feature 6 Mehr LLM-Metriken → 4 (✅) Feature 1 Server-Management → 5 (✅) Feature 4 Cookbook → 6 (✅) Feature 7 Integrations-Anleitungen → 7 (✅) Feature 2 Live-Terminal`.
**Verifizierte Kompatibilität (Primärquelle):**
- 100 % lokal: spricht jeden OpenAI-kompatiblen Endpoint → llama-swap/llama.cpp (Agent-Modell braucht `--jinja`).
- MCP-Client (≥v0.2.0) **und** MCP-Server (≥v0.6.0) → eigenes `mcp_memory.py` plugt als MCP-Server ein, **v7/v8.3-Memory + MemoryPanel bleiben erhalten**.
- OpenAI-API-Server auf **:8642** (`API_SERVER_ENABLED=true`, `API_SERVER_KEY`) → HermesPanel-Chat + Voice (Whisper/Piper) zeigen dorthin.
- Config: `~/.hermes/config.yaml`. Braucht Python 3.11, Node.js, ripgrep, ffmpeg.
**Arbeitsweise je Schritt:** neuer `routers/<x>.py` + `js/panels/<x>.js` + Nav-Eintrag, sauber degradierend.
Bauen + Smoke-Test auf Windows, dann push→pull→rsync→restart auf den Bosgame (CLAUDE.md „Entwickeln & Deployen").
Ein Commit je Schritt. **Ich (KI) habe key-basierten SSH-Zugang zum Bosgame und kann selbst deployen+restarten.**
**Phasen:**
- [x] **Phase 0 — Proof auf echter HW (✅ 2026-06-23):** Hermes Agent **v0.17.0** installiert (`~/.hermes/`, Code in `~/.hermes/hermes-agent`). `coder` (Qwen3-Coder-30B-A3B) in llama-swap um `--jinja` ergänzt (Backup: `/etc/llama-swap/config.yaml.bak-pre-jinja`). `hermes config`: provider=custom, base_url=`http://127.0.0.1:8080/v1`, ctx 65536. Proof via `hermes -z … --yolo`: echter Tool-Call (`free -h` → „21 GiB frei", exakt deckungsgleich mit Live-Wert). Befunde: ripgrep fehlt (grep-Fallback, später `apt install ripgrep`); Hermes hat **eigenes lokales STT** (faster-whisper); terminal.backend=local; Version nicht sauber pinbar → v0.17.0 in `~/.hermes/PINNED_VERSION`, **kein** `hermes update`.
- [x] **Phase 1 — Memory-Bridge (✅ 2026-06-23):** `mcp_memory.py` via `hermes mcp add mission-control-memory --command /opt/mission-control/.venv/bin/python --env MC_URL=http://127.0.0.1:9000 --args …/mcp_memory.py` registriert (5/5 Tools, Test ✓). Erkenntnis: `mcp_memory.py` ist stdio-MCP→HTTP-Wrapper auf MC `/api/memory`**MC :9000 muss laufen**. Proof: Hermes liest beide Bestands-Memories *und* schrieb neuen Eintrag (verifiziert in SQLite, 2→3). v7/v8.3-Gedächtnis ohne Migration im Agenten. Hinweis: Hermes' **eigenes** Session-Memory läuft zusätzlich → zwei Schichten im Blick behalten.
- [x] **Phase 2 — Control-Plane (✅ 2026-06-23):** Hermes als **systemd-User-Service** `hermes-gateway` (`hermes gateway install`, Linger aktiv → boot-/logout-fest, kein sudo). API-Server `:8642` aktiviert (`~/.hermes/.env`: `API_SERVER_ENABLED/KEY/PORT/HOST`). Produktionspfad bewiesen: Tool-Call via `curl :8642/v1/chat/completions`. mcp_memory.py läuft als Kindprozess im Dienst. **UI-Hälfte:** `routers/hermes.py`-Chat-WS proxyt jetzt streamend zu `:8642` (Key aus `~/.hermes/.env`); WS-Contract (`thinking/token/done/error`) beibehalten → **HermesPanel unverändert, kein Frontend-Build nötig**. Status-Endpoint meldet zusätzlich Hermes-Erreichbarkeit.
- [x] **Phase 3 — Aufräumen (✅ 2026-06-23, harter Cutover):** `hermes_agent.py` gelöscht (ReAct-Loop, tote Tools, Modell-Routing weg); `config.py` um `HERMES_SIMPLE/COMPLEX_MODEL` bereinigt, `HERMES_API_URL/KEY` ergänzt; `routers/hermes.py` zu reinem Proxy verschlankt. **Rest-offen:** Self-Update auch für `hermes-gateway` (aktuell nur `mission-control`); HermesPanel-Altlast (Windows-SSH-Setup-Sektion) ist nur noch kosmetisch.
- [x] **Phase 4 — Auszahlung (✅ 2026-06-24):** Agent-**Cockpit** in der HermesPanel (aufklappbar, „📡 Cockpit"). Read-only Control-Plane-Reads in `hermes_control.py` (TTL-Cache 10 s): **Status** aus `~/.hermes/gateway_state.json` + Cron-Heartbeat (Liveness), **Cron-Jobs** via `hermes cron list --all`, **Skills** via `hermes skills list` + `.usage.json` (Nutzungszähler). Rich-Tabellen werden mit `COLUMNS=400` ohne Truncation erzeugt und über `│`/`┃` geparst. Endpunkte `GET /api/hermes/{agent,cron,skills}`. Auf der Box verifiziert: Status `running`/Scheduler aktiv, 71 Skills geparst, 0 Cron-Jobs. **Offen-Rest:** Schreib-Aktionen (Job anlegen/pausieren, Skill an/aus) aus der UI — bewusst später, erst Sichtbarkeit.
- [x] **Memory-Feintuning (✅ 2026-06-23):** `~/.hermes/SOUL.md` (Persona, frisch je Nachricht geladen) instruiert Hermes, das MCP-Gedächtnis bei Nutzer-/Projektfragen **proaktiv** via `get_memories` zu konsultieren, bevor er nachfragt — Name/Profil bleiben im Gedächtnis (nicht hart in SOUL.md). Verifiziert: weicher Prompt „nenne meinen Namen" → „Hallo Tobi!". **Hinweis:** SOUL.md liegt auf dem Bosgame (`~/.hermes/`), nicht im Repo — bei Neuaufsetzen mitschreiben.
- [x] **Phase 5 — UI-Adoption (✅ 2026-06-24, Kurswechsel):** Statt Eigenbau-Chat das **mitgelieferte Hermes-Web-Dashboard** einbetten (Chat mit Live-Tool-Aktivität, Approval-Prompts, Settings, Sessions). Dashboard läuft als systemd-User-Dienst `hermes-dashboard` (`hermes dashboard --host 127.0.0.1 --port 9119 --skip-build`, Extras `[web,pty]`); MC reverse-proxyt es unter `/hermes-ui/` (`routers/hermes_ui.py`, HTTP + WS-Bridge für `pty/ws`) mit `X-Forwarded-Prefix` → Dashboard rewritet Assets/Base-Path selbst und injiziert seinen Session-Token (kein zweiter Login). HermesPanel: Chat → iframe; Eigenbau-Chat/Voice/`_proxy_chat` entfernt. **Damit gelöst:** Kontextverlust + „lernt nicht" (UI spricht direkt mit dem Agent-Loop, kein Proxy-Bug) und Tool-Sichtbarkeit. Verifiziert: HTTP-Kette (Asset-Rewrite, 1,9-MB-Bundle) + Chat-WS `/api/ws` durch MC identisch zu direkt.
- [x] **Phase 6 — Rechte & Multi-Agent (✅ 2026-06-24):** „lokal = volle Rechte" via `hermes config set`: `approvals.mode: auto`, `approvals.cron_mode: allow`, `hooks_auto_accept: true`, `delegation.subagent_auto_approve: true`. Tirith/`allow_private_urls:false` bleiben an (Injection-Schutz). Verifiziert: Agent legt über die API autonom eine Datei an (Schreibrechte ohne manuelle Freigabe). Multi-Agent damit entsperrt.
- [x] **Phase 7 — Agent-Modell Hermes 4 14B (✅ 2026-06-24):** GGUF `bartowski/NousResearch_Hermes-4-14B-GGUF` **Q6_K** (~12 GB) → `/srv/models/Hermes-4-14B-GGUF/`. llama-swap-Eintrag `Hermes-4-14B` (Alias `hermes`, `--jinja`, `-c 65536`, Backup `config.yaml.bak-pre-hermes4`). `hermes config set model.default Hermes-4-14B` + Gateway-Restart. **Verifiziert:** llama-swap lädt das Modell (`state: ready`), Agent macht sauberen Tool-Call (kein `<function>`-Leak, RAM via Terminal abgefragt). Kaltstart-Load ~3,5 min, dann ttl 1800 s. A/B gegen Qwen3-Coder jederzeit per `model.default` umschaltbar.
- [x] **Phase 9 — Gedächtnis wächst autonom + deterministischer Kurator (✅ 2026-06-24):** Tool-Beschreibungen in `mcp_memory.py` auf ein **proaktives Protokoll** (get→Session-Start, add→proaktiv+dedup, search→vor add) → das geteilte MC-SQLite wächst aus allen Tool-Sessions mit. **Kurator: deterministisch in MC**`POST /api/memory/dedupe` (exakt/enthalten/`SequenceMatcher`≥0.85 je Kategorie, behält den vollständigsten Eintrag; Dry-Run + Anwenden) + „🧹 Aufräumen"-Button im Memory-Tab. **Warum nicht LLM-Cron:** Test zeigte, dass sowohl Hermes 4 14B (Tool-Call als Text, nicht ausgeführt) als auch Qwen3-Coder (Erfolg nur halluziniert) die Edits NICHT real ausführten → LLM-Cron-Job wieder entfernt. Verifiziert: 18→16 (2 echte Dubletten), konservativ. Nebenbei Bugfix `_parse_cron` (cron list = Block-Format). **Offen:** optional deterministischer Schedule (`cron --no-agent --script` → ruft den Endpoint).
- [x] **Phase 8 — Cockpit-Ausbau (✅ 2026-06-24):** Cockpit zeigt jetzt **Aktivität** (`hermes insights`: Sessions/Nachrichten/Tool-Calls/Tokens/aktiv + Top-Tool-Balken) und **„Was Hermes über dich gelernt hat"** (`USER.md`-Profil, `§`-getrennt + `MEMORY.md`). Reads in `hermes_control.py` (`learned_profile`, `insights`), Endpunkte `/api/hermes/{learned,insights}`. Beantwortet sichtbar „lernt er mit?" — ja (7 Profil-Fakten, 262 Tool-Calls/30 T verifiziert).
- [x] **Phase 10 — Modell-Metadaten & Capabilities (✅ 2026-06-24):**
- **Schritt 1 (installierte Modelle):** `model_caps.py` — dependency-freier **GGUF-Header-Reader** (offline) liest `architecture`/`expert_count`/`context_length`/`parameter_count`; + cmd-Flags (`--jinja`=Tools bestätigt, `--mmproj`=Vision) + dünner Familien-Fallback. Tags: MoE(+aktive B), Tools (yes|likely|no), Vision, Coder, Reasoning, Embedding, native ctx/params. Models-Tab „KANN" = Capability-Chips. Verifiziert (qwen3moe→MoE, qwen3vl→Vision, native ctx 262k).
- **Schritt 2 (Discovery):** Profi-Suche mit `&full=true` (HF-Tags); Multi-Capability-Chips + **Facetten-Filter** (Tools/MoE/Logik/Bild/Code, Mehrfachauswahl, clientseitig). Discover liefert HF-Tags je Modell mit. HF/GGUF = Quelle der Wahrheit, kein Scraping.
- **Offen-Rest:** Tool-Erkennung in der Profi-Suche ist heuristisch (Name+Tags+Familie); authoritatives `gguf.chat_template` nur fürs Detail-Modal denkbar (per-Repo-Call). Reasoning bleibt das schwächste Signal.
- [~] **Phase 11 — Cookbook 3.0 (Schritt 1+2 ✅ 2026-06-24):**
- **Selbst-aktualisierende Setups:** `RECIPE_TEMPLATES` (recipes.py) definieren das Konzept als **Rollen-Slots**; `build_dynamic_recipes` (cookbook.py) füllt sie live aus `discover` (Hardware-Fit, MoE bevorzugt, dedupe, leere Slots ausgelassen). `all_recipes` = Auto-Setups + eigene; statische RECIPES nur Fallback. Verifiziert: Elite = Qwen3-Coder-30B + Nemotron-Reasoning-30B-A3B + Qwen3-VL + gemma-4-26B-A4B, alle „perfect".
- **Entschlackt:** Segmented-Tabs `[📦 Fertige Setups] [🔍 Modelle finden]` — nur eine Ansicht zur Zeit; Discover + Profi-Suche unter einem Tab.
- **Offen-Rest:** „agent"-Slot fällt oft weg, weil die discover-Agent-Kategorie (Name-Keywords) dünn ist → besser über die **Tool-Capability** (caps `tools`) statt Name-Kategorie befüllen. Volle Verschmelzung (Discover als Leerzustand der Suche) + schlankere Karten optional.
**→ Letzte Errungenschaften:**
- **WebSockets:** `/api/system/stream` für super-fluide 2Hz System-Metriken (CPU/RAM/GPU) ohne HTTP-Overhead.
- **Cookbook 2.0:** Suchergebnisse laden Metriken asynchron ("Lazy Loading"), inklusive Hardware-Fit-Berechnung. UI ist auf Premium-Niveau angehoben (Hover-Effekte, Badge-Colors).
- **Log-Streaming:** `GET /running`-Polls werden herausgefiltert, Live-Konsolen sind sauber.
**Entscheidungen (gesetzt):** Memory via MCP einbinden (nicht migrieren) · **Kurswechsel 2026-06-24: mitgeliefertes Hermes-Web-Dashboard per iframe einbetten** (ersetzt Eigenbau-Chat; der alte „nicht hermes-webui"-Entscheid ist überholt — die UI ist offiziell, mit dem Install versioniert und auf genau dieses Ziel gebaut) · Agent-Modell **Hermes 4 14B** · stabiler OpenAI-API-Server bleibt für programmatischen Zugriff.
**→ Aktueller Modus = Wartung & Feinschliff.**
- Die v2 Roadmap ist damit zu 100% umgesetzt.
- Fokus liegt ab sofort auf Stabilität, Bug-Hunting und dem finalen "Polishing".
**Risiken:** sehr junges Projekt (hohes Release-Tempo) → Version pinnen · zwei Memory-Schichten (eigene via MCP + Hermes-Session-Recall) im Blick behalten.
---
## Features
## v8.3 — Memory Import (✅ UMGESETZT & live, Stand 2026-06-23)
### 1. Server-Management ("Update-Panel 2.0") (✅ Erledigt)
Aktuell gibt es nur "Container aktualisieren" + "Alles aus dem Speicher". Ziel: den kompletten Server aus der UI verwalten.
- [x] OS-/Core-Updates (`apt update/upgrade`) per Knopf, mit Live-Output
- [x] Dienste steuern (`llama-swap`, `mission-control`: Status, Restart)
- [x] Reboot / Health-Übersicht
- [x] Referenz: altes `ai-control`-Skript als Funktionsvorlage
- ⚠️ **Scope-/Security-Sprung**: macht MC zum Server-Admin-Panel. Rechte minimal halten (sudoers-Whitelist für genau die erlaubten Befehle, statt Vollzugriff).
### 2. Live-Terminal / Log via SSH (✅ Erledigt)
- [x] Unter "Server" -> "Console" kann man das Journal verfolgen.
- [x] Simpler "Log" Endpunkt in `maintenance.py` (`journalctl -u llama-swap -n 100 -f` über Websockets).
- [x] UI: Schwarze Konsole, umschaltbar zwischen `llama-swap` und `mission-control`.
- ⚠️ **Security-kritisch**: Authentifizierung via Token als Query-Parameter, da Browser keine Custom Websocket-Header senden können.
### 3. Live-Auslastung im Dashboard (✅ Erledigt)
- [x] CPU / RAM / GPU-VRAM+GTT / Temperatur live anzeigen
- [x] Quellen: **sysfs + psutil**`amd-smi`/`rocm-smi` sind auf dem Bosgame NICHT installiert!
GPU-Mem: `/sys/class/drm/card1/device/mem_info_*`; Temp: hwmon-`name` `amdgpu`/`k10temp`.
### 4. Cookbook + "Modell holen" verschmelzen (✅ Erledigt)
- Bisher: Textfelder für HuggingFace-Repo + Pfad unter "Modelle". Das ist super für Custom-Zeug.
- [x] Neu: Ein Klick-Cookbook (Sidebar-Tab "Cookbook") mit kuratierter Liste (z.B. Qwen2.5-Coder 32B, Llama3 Vision, etc.) inkl. Hardware-Aware "What Fits" Logik (wie bei Odysseus).
- [x] Klick auf Modellkarte im Cookbook triggert den Download via `/api/download`.
- [x] UI-Aufräumen: "Modell holen" Panel wandert ins Cookbook, unter "Modelle" bleibt nur die Tabelle & Chat.
### 5. Design 2.0
- [x] **Grundgerüst + Design-Sprache in Schritt 1 umgesetzt** (Sidebar-Nav, Topbar, Hero, getönte
KPI-Kacheln, Health-Signale, Listen, Aktivitäts-Stream, Alert-Banner) — Tokens in `css/base.css`.
- [x] Feinschliff pro Feature durchgeführt.
### 6. Mehr LLM-Metriken (✅ Erledigt)
- [x] Fähigkeiten pro Modell anzeigen (Text / Bild / Code)
- [x] Tokens/Sek, Kontextgröße, Quant, Dateigröße auf Platte
- [x] Status pro Modell: geladen / idle / Ladezeit
### 7. Integrations-Anleitungen (Copy-Paste) (✅ Erledigt)
- [x] Unter "Guides" (neuer Tab links) gibt es Copy-Paste Templates.
- [x] Templates für die Integration von llama-swap/mission-control in:
- Cline / Cursor
- N8N / Zapier
- OpenWebUI
- LangChain / Python Code
- **MemoryPanel**: Import-Button öffnet Formular — Text einfügen, jede Zeile wird ein eigener Eintrag (Batch-POST).
- Quelle wählbar: `claude` · `gemini` · `chatgpt` · `import` · `manual` — Kategorie frei wählbar.
---
## Tech-Leitplanken
## v8.2 — UX & Mobile (✅ UMGESETZT & live, Stand 2026-06-23)
- **KISS beibehalten** — kein schweres Framework, solange es ohne geht.
- **Sicherheit zuerst** bei allem mit Shell-/SSH-Zugriff: LAN-only, Auth, minimale Rechte.
- Backend-Logik in `app.py`, UI in `static/index.html` — Trennung sauber halten.
- **GuidesPanel**: "Begriffe kurz erklärt" jetzt ganz oben; Tab-Navigation mit Active-Highlighting + Scroll-to-Group.
- **ConnectPanel**: Download-Links für Zed (`zed.dev`) und Cline (VS Code + JetBrains Marketplace) ergänzt; OpenCode `.jsonc`-Pfad und `"providers"`-Key korrigiert; LAN-IP Override für NPM-Proxy-Setups.
- **Mobile (≤520px)**: Sidebar → Bottom-Navigation; kompaktere Buttons/Padding; Touch-freundliche Mindesthöhen.
---
## v8.1 — Bug-Fix Release (✅ UMGESETZT & live, Stand 2026-06-23)
Alle kritischen Bugs aus dem ersten Bosgame-Praxistest behoben:
- **Hermes system_status**: Key-Mismatch behoben — zeigt jetzt echte CPU/RAM/GPU-Werte statt `?`
- **Model-Routing**: Keyword-Liste erweitert (recherchiere/suche/vergleiche/…); kein Downgrade wenn komplexes Modell bereits geladen
- **HermesPanel**: Chat-History überlebt Browser-Reload (sessionStorage); ⏹ Stopp-Button während Generierung; Verlauf-löschen
- **Engine-Update**: fragt jetzt nach sudo-Passwort (analog OS-Update)
- **asyncio**: `get_event_loop()``get_running_loop()` in Whisper + TTS
- **Guide-Titel**: `&amp;``&` (HTML-Entity in Textknoten war ein Bug)
- `import os` in `routers/maintenance.py` ergänzt (war NameError in `updates()`)
---
## v8 — Hermes Agent / Agentic OS Kern (✅ UMGESETZT & live, Stand 2026-06-23)
**Hermes**: dauerhafter lokaler KI-Assistent auf dem Bosgame. Text + Voice, Tool Calling, Gedächtnis.
- **`hermes_agent.py`**: ReAct-Agent mit Tool-Set (read_file, list_dir, run_command, system_status, memory r/w, web_search). Modell-Routing: scout für einfache Tasks, coder für komplexe.
- **`routers/hermes.py`**: WS `/chat` (streaming), POST `/transcribe` (Whisper STT, CPU), POST `/tts` (Piper TTS, weibliche Stimme Kerstin), GET `/status`, GET `/pubkey`.
- **HermesPanel.svelte**: Chat-UI mit Token-Streaming, Tool-Anzeige, Mikrofon-Button (MediaRecorder), TTS-Wiedergabe, Setup-Wizard.
**Offene Schritte (nicht Code, sondern Einrichtung):**
- [ ] Piper Binary + Kerstin-Stimme auf dem Bosgame installieren (Befehle im Setup-Wizard)
- [ ] Windows OpenSSH Server aktivieren (5 PowerShell-Befehle im Setup-Wizard)
- [ ] SSH-Key auf Bosgame generieren: `ssh-keygen -t ed25519 -C "hermes-agent@bosgame" -f ~/.ssh/id_ed25519_hermes_agent -N ""`
- [ ] `HERMES_WINDOWS_HOST` + `HERMES_WINDOWS_USER` in `mission-control.service` setzen
---
## v7 — Memory Layer / Gedächtnis (✅ UMGESETZT & live, Stand 2026-06-23)
Persistentes, tool-übergreifendes Gedächtnis für alle KI-Tools via MCP.
- **`routers/memory.py`**: SQLite CRUD + Export, WAL-Mode, 5 Kategorien mit Enum-Validierung.
- **`mcp_memory.py`**: stdio-MCP-Server — Tools: get/add/search/update/delete_memory. Einmalig in Cline/OpenCode/Claude Code einbinden.
- **MemoryPanel.svelte**: Gedächtnis-Tab mit Kategorie-Filter, Inline-Edit, Add-Formular.
- **ConnectPanel.svelte**: Gedächtnis-MCP Setup-Guide mit Config-Snippets.
- **5 Kategorien**: `user` (wer du bist) · `instruction` (Verhaltensregeln für alle Tools) · `stable` (Projektfakten) · `versioned` (Tech-Versionen) · `ephemeral` (7 Tage, dann weg).
---
## v6 — Workflow & Politur (✅ UMGESETZT & live, Stand 2026-06-21)
- Dashboard zeigt echtes Modell hinter dem Alias; Toolbar-Update-Badges (OS + Modell-Upgrades).
- News als Magazin-Layout; Guide neu mit Tutorials/Workflows + Konzept-Karten.
- Cookbook: Modelle diversifiziert (Qwen3/Gemma/Mistral/DeepSeek), „Beste Wahl für dein System".
---
## v5 — „Anfänger-Lotse" (✅ UMGESETZT & live)
## v4 — „Der Lotse" (✅ UMGESETZT & live)
## v3 — Redesign + Beginner-UX + Security (✅ UMGESETZT & live)
Details in Git-History.
---
## Nächste Features (Vorschläge)
### ~~v8.4 — Hermes Windows-SSH~~ (❌ überholt durch v9-Adoption)
Hermes Agent bringt SSH-Terminal-Backend von Haus aus mit — Eigenbau-SSH-Tools unnötig.
~~read_file_windows / write_file_windows / run_command_windows via paramiko~~
### ~~v9 (alt) — Agentic OS Orchestrierung Eigenbau~~ (❌ ersetzt durch Hermes-Agent-Adoption)
Cron, Task-Queue, Push-Notifications, Multi-Step-Workflows — alles im Hermes-Agent-Framework bereits enthalten, siehe v9-Adoption oben.
### v8.5 — `.well-known/opencode` Remote Config (bleibt relevant)
- [ ] `GET /.well-known/opencode` — vollständige, dynamisch generierte OpenCode-Config (providers + models + MCP)
- [ ] OpenCode Desktop lädt beim Start automatisch alle Configs von Mission Control
- [ ] Einmalig: `OPENCODE_REMOTE_CONFIG=http://192.168.178.151:9000/.well-known/opencode` setzen
### v9.1 — Guides & Connect weiter verbessern
- [ ] Zed als primäre Editor-Empfehlung (Inline-Completion + Agent, lokal)
- [ ] Antigravity entfernen oder als "Cloud-first, lokal wackelig" markieren
- [ ] Continue als "unsichere Zukunft (Cursor-Akquisition)" markieren
- [ ] `.well-known/opencode`-Snippet in Connect-Tab ergänzen (nach v8.5)
### Langfristig
- [ ] Hermes-Persönlichkeit konfigurierbar (Name, Tonalität) über Gedächtnis `instruction`-Einträge
- [ ] Voice-to-Voice latency optimieren (Whisper ROCm wenn stable)
- [ ] Mehrsprachige TTS-Stimmen (EN + DE umschaltbar)
- [ ] Hermes kann Mission Control selbst weiterentwickeln (Agentic self-improvement)
+27 -2
View File
@@ -17,10 +17,10 @@ Neue Bereiche kommen als routers/<bereich>.py + static/js/panels/<bereich>.js da
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from routers import jobs, maintenance, models, system, cookbook
from routers import jobs, hermes, hermes_ui, maintenance, memory, models, system, cookbook, integration, news
app = FastAPI(title="Mission Control")
@@ -41,6 +41,11 @@ app.include_router(jobs.router)
app.include_router(maintenance.router)
app.include_router(system.router)
app.include_router(cookbook.router)
app.include_router(integration.router)
app.include_router(news.router)
app.include_router(memory.router)
app.include_router(hermes.router)
app.include_router(hermes_ui.router)
_STATIC = Path(__file__).parent / "static"
@@ -50,9 +55,29 @@ def index():
return FileResponse(_STATIC / "index.html")
_FAVICON = (
b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>"
b"<rect width='32' height='32' rx='7' fill='#0f1720'/>"
b"<circle cx='16' cy='16' r='8' fill='none' stroke='#2dd4bf' stroke-width='3'/>"
b"<circle cx='16' cy='16' r='2.5' fill='#2dd4bf'/></svg>"
)
@app.get("/favicon.ico")
def favicon():
return Response(content=_FAVICON, media_type="image/svg+xml")
app.mount("/static", StaticFiles(directory=_STATIC), name="static")
@app.exception_handler(HTTPException)
def _http_exc(_req, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
@app.exception_handler(Exception)
def _any_exc(_req, exc: Exception):
"""Unerwartete Fehler als lesbare Meldung ans (vertrauenswuerdige LAN-)UI geben,
statt nur einen generischen 500 ohne Hinweis. Erleichtert Anfaengern die Diagnose."""
return JSONResponse(status_code=500, content={"error": str(exc) or exc.__class__.__name__})
+76 -3
View File
@@ -6,6 +6,7 @@ damit es genau eine Quelle der Wahrheit fuer Pfade, URLs und Defaults gibt.
"""
import os
import sys
from pathlib import Path
from ruamel.yaml import YAML
@@ -16,19 +17,91 @@ from ruamel.yaml import YAML
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Eigene Cookbook-Setups (vom Nutzer angelegt). Bewusst NICHT im App-Verzeichnis, sonst
# wuerde ein Deploy (rsync) sie ueberschreiben -> persistent neben den Modellen ablegen.
USER_RECIPES_PATH = Path(os.environ.get("MC_USER_RECIPES", str(MODELS_DIR / "mission-control-recipes.json")))
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mission-control-memory.db")))
# Cache der automatischen Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Ebenfalls persistent neben den Modellen (uebersteht Deploys). TTL = wie lange der Cache
# als frisch gilt, bevor lazy neu von den Quellen geladen wird (Default 12 h).
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mission-control-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200"))
# Befehl, der zum Starten eines Modells in die config.yaml geschrieben wird.
# {model} = Pfad zur GGUF-Datei, {ctx} = Kontextlaenge, ${PORT} bleibt fuer llama-swap stehen.
# WICHTIG: an deinen Container-/llama-server-Aufruf anpassen (siehe README).
CMD_TEMPLATE = os.environ.get(
"MC_CMD_TEMPLATE",
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa 1 --no-mmap",
"-c {ctx} -ngl 999 -fa 1 --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
# Schutz: Eine unquotierte systemd `Environment=MC_CMD_TEMPLATE=...`-Zeile wird an
# Leerzeichen gesplittet und kommt als blosses "llama-server" an. Fehlt der {model}-
# Platzhalter, ist die Vorlage unbrauchbar -> sicheren Default verwenden.
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
# Befehl fuer "Container/Toolbox aktualisieren". Standard: kyuz0 refresh-Skript.
UPDATE_CMD = os.environ.get("MC_UPDATE_CMD", "")
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
TOKEN = os.environ.get("MC_TOKEN", "") # leer = keine Auth (nur LAN!)
# Hermes Agent (Nous-Framework, eigener Dienst auf :8642 — siehe ROADMAP v9).
# Mission Control proxyt nur noch dorthin; Modell-Routing macht Hermes selbst.
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642/v1").rstrip("/")
def _read_hermes_api_key() -> str:
"""API-Key des Hermes-Agent-Servers: bevorzugt Env HERMES_API_KEY,
sonst aus ~/.hermes/.env (Schluessel API_SERVER_KEY) — so muss das
Secret nicht dupliziert werden."""
k = os.environ.get("HERMES_API_KEY", "")
if k:
return k
envf = Path(os.path.expanduser("~/.hermes/.env"))
if envf.exists():
try:
for line in envf.read_text(errors="replace").splitlines():
line = line.strip()
if line.startswith("API_SERVER_KEY="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return ""
HERMES_API_KEY = _read_hermes_api_key()
# Control-Plane-Reads (Phase 4): Hermes legt Status/Cron/Skills lokal unter
# ~/.hermes ab; die `hermes`-CLI ist die kanonische Quelle fuer Cron/Skills.
# MC laeuft als hitonabi auf der Box -> direkter Lese-/Exec-Zugriff.
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
HERMES_BIN = os.path.expanduser(os.environ.get("HERMES_BIN", "~/.local/bin/hermes"))
# Hermes-Web-Dashboard (eingebaute UI mit Chat/Tool-Aktivitaet, lokal gebunden).
# MC reverse-proxyt es unter /hermes-ui/ (X-Forwarded-Prefix) und bettet es per
# iframe ein -> eine Oberflaeche, kein zweiter Login (Dashboard self-auth auf Loopback).
HERMES_DASHBOARD_URL = os.environ.get("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119").rstrip("/")
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
PIPER_BIN = Path(os.environ.get("PIPER_BIN", "/opt/mission-control/piper/piper"))
PIPER_VOICE = Path(os.environ.get("PIPER_VOICE", "/opt/mission-control/piper/voices/de_DE-kerstin-low.onnx"))
WHISPER_MODEL_SIZE = os.environ.get("WHISPER_MODEL", "medium")
# Self-Update ("Mission Control aktualisieren"): Quelle = git-Repo, Prod = laufende Installation.
SOURCE_DIR = os.path.expanduser(os.environ.get("MC_SOURCE_DIR", "~/mission-control"))
PROD_DIR = str(Path(__file__).resolve().parent)
# Env fuer alle HuggingFace-Downloads: XET deaktivieren (mit aktivem XET haengt der
# Download reproduzierbar bei ~6 MB, siehe CLAUDE.md). Token wird je Job ergaenzt.
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
def hf_bin() -> str:
"""Pfad zur `hf`-CLI fuer Modell-Downloads. Bevorzugt die im venv installierte
(neben dem laufenden Python), da der Dienst-PATH das venv/bin meist nicht
enthaelt. Faellt sonst auf ein global installiertes `hf` zurueck."""
cand = os.path.join(os.path.dirname(sys.executable), "hf")
return cand if os.path.exists(cand) else "hf"
# Gemeinsame YAML-Instanz (preserve_quotes haelt Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
-13
View File
@@ -1,13 +0,0 @@
# docs
Referenz-Material für Mission Control.
## `mission-control-overview.png` (vom User nachzulegen)
Das Design-2.0-Referenzbild („Gateway Control Plane"-Dashboard). Das in Schritt 1 umgesetzte
Layout ist daran angelehnt: linke Icon-Sidebar, Topbar mit Status-Pill, Alert-Banner, Hero-Karte
mit Mini-Stats, Reihe getönter KPI-Kacheln, Multi-Spalten-Panels (Health-Signale als Key-Value
+ Progress-Bar, Listen im „Session-Router"-Stil, Aktivität als „Incident-Stream").
**Einfach die PNG-Datei hier als `mission-control-overview.png` ablegen.** ROADMAP.md und CLAUDE.md
verweisen bereits darauf.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

+1449
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "mission-control-frontend",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"svelte": "^5.28.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"typescript": "^5.7.3",
"vite": "^6.3.5"
}
}
+201
View File
@@ -0,0 +1,201 @@
import { mount } from 'svelte'
import { api, getToken, setToken, getHfToken, setHfToken } from '@core/api.js'
import { initNav } from '@core/nav.js'
import { ICON } from '@core/ui.js'
import { statusStore } from './stores/status.svelte.js'
import { jobsStore } from './stores/jobs.svelte.js'
import { systemStore } from './stores/system.svelte.js'
// Svelte panels — vollständig migriert
import OverviewPanel from './panels/OverviewPanel.svelte'
import ModelsPanel from './panels/ModelsPanel.svelte'
import JobsPanel from './panels/JobsPanel.svelte'
import ServerPanel from './panels/ServerPanel.svelte'
import CookbookPanel from './panels/CookbookPanel.svelte'
import ConnectPanel from './panels/ConnectPanel.svelte'
import NewsPanel from './panels/NewsPanel.svelte'
import GuidesPanel from './panels/GuidesPanel.svelte'
import MemoryPanel from './panels/MemoryPanel.svelte'
import HermesPanel from './panels/HermesPanel.svelte'
let prevModelStates: Record<string, string> = {}
function flashSwap() {
const el = document.getElementById('top-active-text')
if (el) { el.classList.add('swap-flash'); setTimeout(() => el.classList.remove('swap-flash'), 900) }
}
function applyStatus(s: any) {
const dot = document.getElementById('swdot')
const label = document.getElementById('swlabel')
const activeText = document.getElementById('top-active-text')
if (!s) {
dot && (dot.className = 'dot off')
label && (label.textContent = 'Backend nicht erreichbar')
activeText && (activeText.textContent = 'Backend offline')
showAlert('Backend nicht erreichbar läuft uvicorn?', false)
} else {
const host = (s.swap_url || '').replace(/^https?:\/\//, '')
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online ' : 'LLM-Engine: Offline ') + host)
const LOADED = ['running', 'ready', 'loading', 'starting']
const active = (s.models || []).find((m: any) => LOADED.includes(m.state))
if (activeText) {
if (active) {
const ctx = active.meta?.ctx ? ` <span style="color:var(--mut);font-size:11.5px">· ${Math.round(active.meta.ctx / 1024)}k ctx</span>` : ''
const stateLabel = active.state === 'loading' || active.state === 'starting' ? ' <span style="color:var(--warn);font-size:11.5px">lädt…</span>' : ''
activeText.innerHTML = `<b style="color:var(--accent)">${active.name}</b>${ctx}${stateLabel}`
} else {
activeText.innerHTML = '<span style="color:var(--mut)">Kein Modell im VRAM</span>'
}
}
if (s.swap_ok) hideAlert()
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true)
const newStates: Record<string, string> = {}
for (const m of (s.models || [])) newStates[m.name] = m.state
for (const [name, state] of Object.entries(newStates)) {
const prev = prevModelStates[name]
if (prev && prev !== state && (state === 'running' || state === 'loading')) { flashSwap(); break }
}
prevModelStates = newStates
}
setSecChip(s)
statusStore.set(s)
}
function setSecChip(s: any) {
const chip = document.getElementById('sec-chip')
const tx = document.getElementById('sec-chip-tx')
if (!chip || !tx) return
chip.classList.toggle('ok', !!s?.secured)
tx.textContent = s?.secured ? 'Geschützt · Heimnetz' : 'Nur im Heimnetz (offen)'
}
function applyJobs(jobs: any[]) {
jobsStore.set(jobs || [])
}
function applySystem(sys: any) {
systemStore.set(sys)
}
function showAlert(html: string, warn: boolean) {
const a = document.getElementById('alert')!
a.className = 'alert' + (warn ? ' warn' : '')
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`
a.style.display = 'flex'
}
function hideAlert() {
const a = document.getElementById('alert')
if (a) a.style.display = 'none'
}
// Update badges
const goView = (v: string) => (document.querySelector(`.nav-item[data-view="${v}"]`) as HTMLElement)?.click()
function mkBadge(text: string, cls: string, view: string, title?: string) {
const s = document.createElement('span')
s.className = 'upd-badge' + (cls ? ' ' + cls : '')
s.textContent = text
if (title) s.title = title
s.onclick = () => goView(view)
return s
}
async function pollUpdates() {
try {
const u = await api('/api/updates')
const el = document.getElementById('update-badges'); if (!el) return
const age = u.apt_cache_age_h as number | null
const stale = age != null && age > 24
const ageNote = age != null ? ` · apt-Cache: vor ${age}h` : ''
el.innerHTML = ''
el.appendChild(mkBadge(
u.os > 0 ? `OS: ${u.os} Updates` : stale ? 'OS: ?' : 'OS: aktuell',
u.os > 0 ? 'warn' : stale ? 'warn' : 'ok', 'server',
u.os > 0 ? `${u.os} Pakete${ageNote}` : stale ? `apt-Cache veraltet (${age}h)` : `Keine Updates${ageNote}`
))
el.appendChild(mkBadge(u.engine > 0 ? 'Engine: Update' : 'Engine: aktuell', u.engine > 0 ? 'warn' : 'ok', 'server'))
el.appendChild(mkBadge(u.models > 0 ? `Modelle: ${u.models}` : 'Modelle: aktuell', u.models > 0 ? 'warn' : 'ok', 'news'))
} catch { /* noop */ }
}
async function pollStatus() {
try { applyStatus(await api('/api/status')) }
catch { applyStatus(null) }
}
async function pollJobs() {
try { applyJobs(await api('/api/jobs')) }
catch { /* noop */ }
}
function connectSystemStream() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${window.location.host}/api/system/stream`)
ws.onmessage = e => { try { applySystem(JSON.parse(e.data)) } catch {} }
ws.onclose = () => { setTimeout(connectSystemStream, 3000) }
ws.onerror = () => ws.close()
}
// ---- Boot ----
// Icons in Nav/Logo injizieren
document.getElementById('logo')?.innerHTML !== undefined &&
(document.getElementById('logo')!.innerHTML = ICON.logo)
document.querySelectorAll('[data-ic]').forEach(n => {
(n as HTMLElement).innerHTML = ICON[(n as HTMLElement).dataset.ic || ''] || ''
})
// Svelte panels mounten
const panels: [string, any][] = [
['overview', OverviewPanel],
['models', ModelsPanel],
['activity', JobsPanel],
['server', ServerPanel],
['cookbook', CookbookPanel],
['connect', ConnectPanel],
['news', NewsPanel],
['guides', GuidesPanel],
['memory', MemoryPanel],
['hermes', HermesPanel],
]
for (const [view, Panel] of panels) {
const el = document.querySelector(`.view[data-view="${view}"]`)
if (el) mount(Panel, { target: el })
}
initNav('overview')
// Token / Settings
const tokenInput = document.getElementById('token') as HTMLInputElement | null
if (tokenInput) {
tokenInput.value = getToken()
tokenInput.addEventListener('change', e => { setToken((e.target as HTMLInputElement).value); pollStatus() })
}
const hfInput = document.getElementById('hf-token') as HTMLInputElement | null
if (hfInput) {
hfInput.value = getHfToken()
hfInput.addEventListener('change', e => setHfToken((e.target as HTMLInputElement).value))
}
const sbtn = document.getElementById('nav-settings')
const smod = document.getElementById('settings-modal')
const scls = document.getElementById('sm-close')
if (sbtn && smod && scls) {
sbtn.addEventListener('click', () => (smod!.style.display = 'flex'))
scls.addEventListener('click', () => (smod!.style.display = 'none'))
}
function tickClock() {
const c = document.getElementById('clock')
if (c) c.textContent = new Date().toTimeString().slice(0, 5)
}
document.addEventListener('mc:refresh', pollStatus as EventListener)
pollStatus(); pollJobs(); connectSystemStream()
setInterval(tickClock, 1000)
setInterval(pollStatus, 3000)
setInterval(pollJobs, 1500)
pollUpdates()
setInterval(pollUpdates, 60000)
+396
View File
@@ -0,0 +1,396 @@
<script lang="ts">
import { statusStore } from '../stores/status.svelte.js'
import { api } from '@core/api.js'
import { esc, icon } from '@core/ui.js'
const s = $derived(statusStore.value)
const allModels = $derived(((s?.models || []) as any[]).filter((m: any) => !m.incomplete))
let selectedTool = $state('zed')
let selectedModelIds = $state<string[]>([])
let testResult = $state('')
let testOk = $state(false)
let selectedMemTool = $state('cline')
let ipOverride = $state<string>(localStorage.getItem('mc_ip_override') || '')
function isLanIp(h: string) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) }
function behindProxy() { return location.protocol === 'https:' || !isLanIp(location.hostname) }
function baseUrl() {
const host = ipOverride.trim() || location.hostname
return `http://${host}:8080/v1`
}
const url = $derived(baseUrl())
function saveIpOverride() {
const v = ipOverride.trim()
if (v) localStorage.setItem('mc_ip_override', v)
else localStorage.removeItem('mc_ip_override')
}
const memBlock = $derived(memMcpBlock(selectedMemTool))
const activeIds = $derived(
selectedModelIds.length ? selectedModelIds : allModels.map((m: any) => m.name)
)
const firstModel = $derived(activeIds[0] || 'coder')
const visionModel = $derived(allModels.find((m: any) => m.meta?.caps?.includes('Bild'))?.name)
function zedBlock(url: string, ids: string[]) {
const ms = ids.length ? allModels.filter((m: any) => ids.includes(m.name)) : (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
const entries = ms.map((m: any) => {
const img = (m.meta?.caps || []).some((c: string) => /Bild|Vision/i.test(c))
const ctx = m.meta?.ctx || 131072 // nutze konfigurierten Kontext des Modells (Standard: 128k)
return ` { "name": "${m.name}", "display_name": "${m.name}", "max_tokens": ${ctx}${img ? `, "capabilities": { "tools": true, "images": true }` : ''} }`
}).join(',\n')
return `"language_models": {\n "openai_compatible": {\n "bosgame": {\n "api_url": "${url}",\n "available_models": [\n${entries}\n ]\n }\n }\n}`
}
function openCodeBlock(url: string, ids: string[]) {
const ms = ids.length
? allModels.filter((m: any) => ids.includes(m.name))
: (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
const modelMap = ms.map((m: any) => {
const ctx = m.meta?.ctx || 131072
return ` "${m.name}": { "name": "${m.name}", "contextWindowSize": ${ctx} }`
}).join(',\n')
return `"providers": {\n "llama-swap": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Bosgame (llama-swap)",\n "options": {\n "baseURL": "${url}",\n "apiKey": "local"\n },\n "models": {\n${modelMap}\n }\n }\n}`
}
function continueBlock(url: string, ids: string[]) {
const ms = ids.length
? allModels.filter((m: any) => ids.includes(m.name))
: (allModels.length ? allModels : [{ name: 'coder', meta: {} }])
const entries = ms.map((m: any) =>
` {\n "title": "llama-swap / ${m.name}",\n "provider": "openai",\n "model": "${m.name}",\n "apiBase": "${url}",\n "apiKey": "local"\n }`
).join(',\n')
return `{\n "models": [\n${entries}\n ]\n}`
}
async function testConnection() {
testResult = 'Teste…'
try {
const r = await api('/api/integration/test')
testOk = r.ok
testResult = r.ok
? `✓ Verbunden — ${r.models.length} Modell(e): ${r.models.join(', ')}`
: `✗ Keine Verbindung: ${r.error}`
} catch (e: any) { testOk = false; testResult = '✗ ' + e.message }
}
function copyCode(el: HTMLElement) {
const code = el.querySelector('code')
if (code) navigator.clipboard?.writeText(code.textContent || '')
}
function copyVal(inp: HTMLInputElement) {
navigator.clipboard?.writeText(inp.value)
}
function memMcpBlock(tool: string) {
const pyPath = tool === 'bosgame'
? '/opt/mission-control/.venv/bin/python'
: 'python'
const scriptPath = tool === 'bosgame'
? '/opt/mission-control/mcp_memory.py'
: 'C:\\\\Users\\\\<DeinUsername>\\\\mission-control\\\\mcp_memory.py'
return `{\n "mcpServers": {\n "mission-control-memory": {\n "command": "${pyPath}",\n "args": ["${scriptPath}"],\n "env": {\n "MC_URL": "http://192.168.178.151:9000"\n }\n }\n }\n}`
}
</script>
<div class="pagehead">
<div>
<h1>Verbinden</h1>
<div class="sub">Schritt für Schritt: KI-Modelle in deine Tools einbinden — getestet, erklärt, mit Copy-Paste-Config.</div>
</div>
</div>
<!-- Step 1: Connection -->
<div class="card">
<div class="card-h"><h3>1 · Verbindung prüfen</h3></div>
<div class="card-sub">Stelle sicher, dass die LLM-Engine erreichbar ist, bevor du Tools konfigurierst.</div>
<label>Engine-Adresse (für alle Tools)</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:4px">
<input class="mono-sm" readonly value={url} style="flex:1;margin:0">
<button class="ghost" onclick={e => { const inp = (e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement; copyVal(inp) }}>Kopieren</button>
</div>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<input class="mono-sm" placeholder="LAN-IP überschreiben, z.B. 192.168.178.151 (leer = auto)"
bind:value={ipOverride}
oninput={saveIpOverride}
style="flex:1;margin:0;font-size:12px;opacity:.7">
</div>
{#if behindProxy()}
<div class="alert warn" style="margin:0 0 12px">
<span class="a-dot"></span>
<span>Du öffnest Mission Control über einen <b>Proxy/Namen</b>. Die Engine läuft auf <b>Port 8080 ohne HTTPS</b> direkt am Bosgame.
Trag in deinen Tools die <b>LAN-IP</b> ein: <code>http://192.168.178.151:8080/v1</code> — nicht den Proxy-Namen.</span>
</div>
{/if}
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
<button class="primary" onclick={testConnection}>Verbindung testen</button>
{#if testResult}
<span class="mono-sm" style="color:{testOk ? '#7ee29a' : '#ff9b95'}">{testResult}</span>
{/if}
</div>
</div>
<!-- Auto-Swap explanation -->
<div class="card">
<div class="card-h"><h3>Wie funktioniert das Auto-Swap?</h3></div>
<div style="display:flex;flex-direction:column;gap:10px">
{#each [
['⚡', 'Kein manuelles Starten nötig', 'Wenn du in Zed oder OpenCode „coder" auswählst und eine Anfrage sendest, lädt llama-swap das Modell automatisch — in ca. 25 Sekunden.'],
['🔄', 'Modelle wechseln sich automatisch ab', 'Rufst du ein anderes Modell auf, entlädt llama-swap das aktuelle (nach TTL-Ablauf) und lädt das neue. Es läuft immer nur eines gleichzeitig.'],
['🧠', 'Zwei Modelle gleichzeitig aktiv halten', 'Du hast 124 GB RAM — genug für z.B. Scout 8B (~5 GB) dauerhaft geladen + ein größeres Modell das swappt. Dafür beim Scout im Modelle-Tab → Konfigurieren die TTL auf 99999 setzen.']
] as [emoji, title, desc]}
<div class="tile" style="display:flex;gap:12px;align-items:flex-start">
<span style="font-size:20px;line-height:1;flex:0 0 auto">{emoji}</span>
<div>
<div style="font-weight:500;font-size:13.5px">{title}</div>
<div class="card-sub" style="margin:4px 0 0">{desc}</div>
</div>
</div>
{/each}
</div>
</div>
<!-- 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>
<span class="meta">{selectedModelIds.length ? `${selectedModelIds.length} von ${allModels.length} ausgewählt` : 'alle werden eingeschlossen'}</span>
</div>
<div class="card-sub">Wähle, welche Modelle im Config-Snippet erscheinen sollen.</div>
<div class="flex gap-2" style="flex-wrap:wrap">
{#each allModels as m}
{@const active = selectedModelIds.includes(m.name)}
<span class="chip" style="cursor:pointer;{active ? 'background:rgba(45,212,191,.2);border-color:var(--accent);color:var(--accent)' : ''}"
role="button" tabindex="0"
onclick={() => selectedModelIds = active ? selectedModelIds.filter(x => x !== m.name) : [...selectedModelIds, m.name]}
onkeydown={e => e.key === 'Enter' && (selectedModelIds = active ? selectedModelIds.filter(x => x !== m.name) : [...selectedModelIds, m.name])}>
{m.name}
</span>
{/each}
</div>
{#if selectedModelIds.length}
<div style="margin-top:10px">
<a href="#" onclick={e => { e.preventDefault(); selectedModelIds = [] }} style="color:var(--mut);font-size:12.5px">Auswahl aufheben (alle)</a>
</div>
{/if}
</div>
<!-- Step 3: Tool picker -->
<div class="card">
<div class="card-h"><h3>3 · Tool wählen</h3></div>
<div class="card-sub" style="margin-bottom:8px">Für welches Werkzeug brauchst du die Konfiguration?</div>
<div style="font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">Chat</div>
<div class="grid grid-3" style="gap:8px;margin-bottom:14px">
{#each [
['janai', 'Jan AI', 'Einfachstes Chat-Interface — Desktop-App, kein Setup'],
['openwebui', 'OpenWebUI', 'Browser-Chat für Teams — mehr Features, mehr Setup'],
] as [id, label, desc]}
<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 class="card-sub" style="margin:3px 0 0">{desc}</div>
</button>
{/each}
</div>
<div style="font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">Coding-Assistenten</div>
<div class="grid grid-3" style="gap:8px">
{#each [
['continue', 'Continue', 'VS Code/JetBrains — Hilfe ohne Agent-Overhead'],
['cline', 'Cline', 'VS Code/JetBrains/Zed — voller Coding-Agent'],
['antigravity', 'Antigravity', 'Googles agentic IDE — VS Code-basiert'],
['opencode', 'OpenCode', 'Terminal-Agent — autonom, mächtig'],
['zed', 'Zed', 'Schneller nativer Editor mit KI-Panel'],
] as [id, label, desc]}
<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 class="card-sub" style="margin:3px 0 0">{desc}</div>
</button>
{/each}
</div>
</div>
<!-- Step 4: Config snippet -->
<div class="card">
<div class="card-h"><h3>4 · Config-Snippet</h3></div>
{#if selectedTool === 'janai'}
<p><b>Jan AI</b> ist eine Desktop-App — kein Browser, kein Server, kein Admin-Panel. Einfach installieren und loslegen.</p>
<p>Jan AI öffnen → linke Leiste → <b>Remote-Modelle</b><b>+ Modell hinzufügen</b>:</p>
<label>Basis-URL (kopieren und in Jan AI einfügen)</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<label>Modell-ID (in Jan AI als „Model name" eintragen)</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>). Jan AI fragt danach beim Einrichten.</div>
<div class="hint" style="margin-top:8px">Jan AI herunterladen: <a href="https://jan.ai" target="_blank" rel="noopener">jan.ai</a> — kostenlos, open source, Windows/macOS/Linux.</div>
{:else if selectedTool === 'continue'}
{@const block = continueBlock(url, activeIds)}
<p>Datei anlegen oder öffnen: <code>~/.continue/config.json</code></p>
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Falls bereits eine <code>config.json</code> vorhanden: nur den <code>"models"</code>-Eintrag ergänzen, nicht die ganze Datei ersetzen.</span></div>
<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>
<div class="log" style="max-height:none"><code>{block}</code></div>
</div>
<p style="margin-top:12px">Danach Continue in VS Code neu starten → im Chat-Panel das Modell <b>llama-swap / {firstModel}</b> wählen.</p>
<div class="hint">Continue: <a href="https://continue.dev" target="_blank" rel="noopener">continue.dev</a> — kostenlose VS Code / JetBrains Extension. Hinweis: 2026 von Cursor akquiriert — aktuell weiter aktiv und kostenlos.</div>
{:else if selectedTool === 'antigravity'}
<p><b>Antigravity</b> (Googles agentic IDE, VS Code-basiert) unterstützt OpenAI-kompatible Endpoints direkt:</p>
<p>Settings → Models → <b>Custom Endpoint</b><b>+ hinzufügen</b>:</p>
<label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<label>Modell-ID</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>). Danach das Modell <b>{firstModel}</b> in der Model-Liste auswählen.</div>
<div class="hint" style="margin-top:8px">Antigravity herunterladen: <a href="https://antigravityide.net" target="_blank" rel="noopener">antigravityide.net</a> — kostenlose Einzelperson-Tier, VS Code-basiert.</div>
{:else if selectedTool === 'zed'}
{@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>
<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">
<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>
<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" style="margin-top:4px">Zed herunterladen: <a href="https://zed.dev" target="_blank" rel="noopener">zed.dev</a> — kostenlos, native Performance, Windows/macOS/Linux.</div>
{:else if selectedTool === 'opencode'}
{@const block = openCodeBlock(url, activeIds)}
<p>Datei anlegen oder öffnen (OpenCode Desktop nutzt <b>.jsonc</b> — JSON mit Kommentaren):</p>
<p class="mono-sm" style="line-height:1.6">Windows: <code>%USERPROFILE%\.config\opencode\opencode.jsonc</code></p>
<div class="alert warn" style="margin:8px 0 12px;padding:10px 14px"><span class="a-dot"></span><span>Den <code>"providers"</code>-Block in die bestehende <code>opencode.jsonc</code> einfügen (nicht die ganze Datei ersetzen). Falls bereits ein <code>"llama-swap"</code>-Eintrag existiert: <b>vorher löschen</b>.</span></div>
<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>
<div class="log" style="max-height:none"><code>{block}</code></div>
</div>
<p style="margin-top:12px">Danach in OpenCode <code>/models</code><b>llama-swap/{firstModel}</b> wählen.</p>
<div class="hint">OpenCode Desktop: <a href="https://opencode.ai" target="_blank" rel="noopener">opencode.ai</a></div>
{:else if selectedTool === 'cline'}
<p>In Cline/Cursor den Provider <b>„OpenAI Compatible"</b> wählen:</p>
<label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<label>Modell-ID</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<div class="hint">API-Key: ein beliebiger Wert (z.B. <code>local</code>).</div>
<div class="hint" style="margin-top:4px">Cline für VS Code: <a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank" rel="noopener">VS Code Marketplace</a> · für JetBrains: <a href="https://plugins.jetbrains.com/plugin/24604-cline" target="_blank" rel="noopener">JetBrains Marketplace</a></div>
{:else if selectedTool === 'openwebui'}
<p>Settings → Admin Panel → Connections → neue OpenAI-Verbindung:</p>
<label>Basis-URL</label>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:12px">
<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>
</div>
<label>API-Key</label>
<div class="flex gap-2" style="align-items:stretch">
<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>
</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>
{/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}
<!-- Memory MCP — unabhaengig von Modellen, immer sichtbar -->
<div class="card">
<div class="card-h">
<h3>Gedächtnis-MCP</h3>
<span class="chip" style="background:rgba(45,212,191,.1);border-color:rgba(45,212,191,.3);color:var(--accent)">Neu in v7</span>
</div>
<div class="card-sub" style="margin-bottom:14px">
Damit Cline, OpenCode und Claude Code sich Projektfakten, Entscheidungen und Präferenzen
über Sessions hinweg merken — geteilt über alle Tools, verwaltet im
<a href="#"
onclick={e => { e.preventDefault(); (document.querySelector(".nav-item[data-view='memory']") as HTMLElement)?.click() }}
style="color:var(--accent)">Gedächtnis-Tab</a>.
</div>
<!-- Tool selector -->
<div class="flex gap-2" style="margin-bottom:14px;flex-wrap:wrap">
{#each [
['cline', 'Cline / Claude Code', 'Windows — Tools auf dem Entwicklungs-PC'],
['opencode', 'OpenCode', 'Windows — Terminal-Agent'],
['bosgame', 'Direkt am Bosgame', 'Wenn das Tool am Server läuft'],
] as [id, label, desc]}
<button class="card-btn{selectedMemTool === id ? ' cb-best' : ''}"
style="text-align:left;padding:10px 14px"
onclick={() => selectedMemTool = id}>
<div style="font-weight:500;font-size:13px">{label}</div>
<div class="card-sub" style="margin:2px 0 0;font-size:11.5px">{desc}</div>
</button>
{/each}
</div>
<!-- Config block -->
<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>
<div class="log" style="max-height:none"><code>{memBlock}</code></div>
</div>
{#if selectedMemTool === 'cline'}
<p style="margin-top:12px">In <b>Cline</b>: MCP-Einstellungen öffnen (Stecker-Icon in der Cline-Sidebar) → „Add MCP Server" → diesen Block einfügen.</p>
<p>In <b>Claude Code</b>: Block in <code>~/.claude/settings.json</code> unter <code>"mcpServers"</code> einfügen.</p>
<div class="alert warn" style="margin-top:8px;padding:10px 14px"><span class="a-dot"></span>
<span>Einmalig auf dem Windows-PC: <code>pip install mcp httpx</code> ausführen — danach <code>mcp_memory.py</code> aus dem Repo-Ordner erreichbar machen (Pfad im Snippet anpassen).</span>
</div>
{:else if selectedMemTool === 'opencode'}
<p style="margin-top:12px">Block in <code>~/.config/opencode/opencode.json</code> unter <code>"mcpServers"</code> einfügen.</p>
<div class="alert warn" style="margin-top:8px;padding:10px 14px"><span class="a-dot"></span>
<span>Einmalig: <code>pip install mcp httpx</code> — und Pfad zu <code>mcp_memory.py</code> im Snippet anpassen.</span>
</div>
{:else}
<p style="margin-top:12px">Block in die MCP-Config des jeweiligen Tools einfügen. Der Bosgame-Pfad <code>/opt/mission-control/mcp_memory.py</code> ist bereits korrekt — kein weiteres Setup nötig.</p>
{/if}
</div>
+644
View File
@@ -0,0 +1,644 @@
<script lang="ts">
import { onMount } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api, getHfToken } from '@core/api.js'
import { esc, toast, confirmModal, infoDot } from '@core/ui.js'
const GGUF_HELP = 'GGUF ist das lokale Dateiformat für KI-Modelle. Jede Datei ist eine quantisierte Variante desselben Modells — Q4_K_M ist meist der beste Kompromiss aus Qualität und Geschwindigkeit.'
const CTX_HELP = 'Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr, braucht aber mehr Speicher und wird etwas langsamer.'
const MOE_HELP = 'MoE (Mixture of Experts): viele Parameter fürs Wissen, aber pro Wort ist nur ein kleiner Teil aktiv → fast große-Modell-Qualität bei hohem Tempo. Ideal für deine APU.'
const FIT_RANK: Record<string, number> = { perfect: 0, marginal: 1, too_tight: 2 }
const ROLE_LABEL: Record<string, string> = { vision: 'Bilder', coder: 'Coden', reasoning: 'Logik', agent: 'Agenten & Tools', scout: 'Allrounder', reviewer: 'Review', manager: 'Manager' }
const CAP_KW = [
{ label: 'Bilder', kw: ['-vl-', '-vl', 'vision', 'llava', 'multimodal', '-mm-', 'pixtral'] },
{ label: 'Coden', kw: ['coder', '-code-', 'code-', 'codestral', 'starcoder'] },
{ label: 'Logik', kw: ['-r1', 'deepseek-r1', 'reasoning', 'qwq', 'magistral', '-think', 'thinking', '-o1'] },
{ label: 'Agenten & Tools', kw: ['hermes', '-tool', 'command-r', 'watt', '-fc-', 'function'] },
]
function capLabel(name: string) {
const l = (name || '').toLowerCase()
for (const c of CAP_KW) if (c.kw.some((k: string) => l.includes(k))) return c.label
return 'Allrounder'
}
// ---- Capability-Tags (Phase 10, Schritt 2): aus Name + HF-Tags ----
const TOOL_FAM = ['qwen2.5', 'qwen3', 'qwen2', 'hermes', 'mistral', 'mixtral', 'devstral',
'command-r', 'command_r', 'llama-3.1', 'llama3.1', 'llama-3.3', 'llama-4', 'llama4',
'functionary', 'watt', 'firefunction', 'granite', 'glm-4', 'ministral', 'gemma-3', 'gemma3']
const REASON_KW = ['-r1', 'deepseek-r1', 'qwq', 'magistral', '-think', 'thinking', '-o1',
'gpt-oss', 'reasoning', 'exaone-deep', 'phi-4-reasoning']
function caps(name: string, tags: any[] = []) {
const l = (name || '').toLowerCase()
const t = (tags || []).map((x: any) => String(x).toLowerCase())
const toolTag = t.some((x: string) => x.includes('function-calling') || x.includes('tool-use') || x.includes('tools'))
const toolFam = TOOL_FAM.some((f: string) => l.includes(f))
return {
moe: isMoe(l),
active_b: moeActive(l),
vision: ['-vl', 'vision', 'llava', 'pixtral', 'multimodal', '-mm-'].some(k => l.includes(k)) || t.includes('image-text-to-text'),
coder: ['coder', '-code', 'code-', 'codestral', 'starcoder'].some(k => l.includes(k)),
reasoning: REASON_KW.some(k => l.includes(k)) || t.includes('reasoning'),
tools: toolTag ? 'yes' : (toolFam ? 'likely' : 'no'),
}
}
// Facetten-Filter (Mehrfachauswahl) für die Profi-Suche
let facets = $state<Record<string, boolean>>({ tools: false, moe: false, reasoning: false, vision: false, coder: false })
const FACET_DEFS: [string, string][] = [['tools', '🛠 Tools'], ['moe', '🧩 MoE'], ['reasoning', '🧠 Logik'], ['vision', '👁 Bild'], ['coder', '💻 Code']]
function matchesFacets(name: string, tags: any[] = []) {
const active = Object.keys(facets).filter(k => facets[k])
if (!active.length) return true
const c: any = caps(name, tags)
return active.every(f => f === 'tools' ? c.tools !== 'no' : c[f])
}
function capsChipsHtml(name: string, tags: any[] = []) {
const c: any = caps(name, tags)
const out: string[] = []
if (c.coder) out.push('<span class="chip">💻 Code</span>')
if (c.vision) out.push('<span class="chip">👁 Bild</span>')
if (c.reasoning) out.push('<span class="chip">🧠 Logik</span>')
if (c.moe) out.push(`<span class="chip" title="${esc(MOE_HELP)}">🧩 MoE${c.active_b ? ` · ~${c.active_b}B` : ''}</span>`)
if (c.tools === 'yes') out.push('<span class="chip" title="Tool-Calling unterstützt">🛠 Tools</span>')
else if (c.tools === 'likely') out.push('<span class="chip" style="opacity:.55" title="laut Modell-Familie wahrscheinlich tool-fähig">🛠 Tools?</span>')
if (!c.coder && !c.vision) out.unshift('<span class="chip">💬 Chat</span>')
return out.join(' ')
}
function isMoe(s = '') {
const l = s.toLowerCase()
return /\d+x\d+(\.\d+)?b/.test(l) || /(?<![a-z])a\d+(\.\d+)?b\b/.test(l) || l.includes('mixtral') || /\bmoe\b/.test(l)
}
function moeActive(s = '') {
const m = s.toLowerCase().match(/(?<![a-z])a(\d+(?:\.\d+)?)b\b/)
return m ? m[1] : null
}
function fitCls(l: string) { return l === 'perfect' ? 'ok' : l === 'marginal' ? 'warn' : 'bad' }
function fitWord(l: string) { return l === 'perfect' ? 'Passt' : l === 'marginal' ? 'Knapp' : 'Zu groß' }
function metricLine(fit: any) { return `~${(fit.req_gb ?? 0).toFixed(1)} GB · ~${Math.round(fit.tps ?? 0)} Tok/s` }
const sys = $derived(systemStore.value)
// ---- Tabs (Cookbook 3.0: entschlackt — eine Ansicht zur Zeit) ----
let cookTab = $state<'setups' | 'find'>('setups')
// ---- Recipes ----
let recipes = $state<any[]>([])
let recommended = $state<string | null>(null)
let recipeLoading = $state(true)
// ---- Discover ----
let discoverData = $state<any | null>(null)
let discoverLoading = $state(false)
let discoverStale = $state(false)
// ---- Search ----
let searchQuery = $state('')
let searchResults = $state<any[]>([])
let searchLoading = $state(false)
let activeFilter = $state('')
let sortBy = $state('downloads')
let cardFits = $state<(string | null)[]>([])
let bestResultIdx = $state(-1)
// ---- Recipe detail modal ----
let recipeModal = $state<any | null>(null)
// ---- Model detail modal ----
let modelModal = $state(false)
let modelModalTitle = $state('')
let modelModalRepo = $state('')
let modelAnalysis = $state<any | null>(null)
let modelFile = $state('')
let modelRole = $state('')
let modelCtx = $state(8192)
let modelLoading = $state(false)
let modelFit = $state<any | null>(null)
// ---- New/Edit recipe modal ----
let newRecipeOpen = $state(false)
let newRecipeTitle = $state('')
let newRecipeDesc = $state('')
let newRecipeModels = $state<{ repo: string; role: string; quant: string }[]>([{ repo: '', role: '', quant: 'Q4_K_M' }])
let editRecipeId = $state<string | null>(null)
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
function goView(v: string) { document.querySelector(`.nav-item[data-view="${v}"]`)?.dispatchEvent(new MouseEvent('click')) }
onMount(() => {
loadRecipes()
loadDiscover()
})
async function loadRecipes() {
recipeLoading = true
try {
const d = await api('/api/cookbook/recipes')
recipes = d.recipes || []
recommended = d.recommended_id || null
} catch (e: any) { toast(e.message, true) }
recipeLoading = false
}
async function loadDiscover(force = false) {
discoverLoading = true
try {
const d = await api('/api/cookbook/discover' + (force ? '?force=true' : ''))
discoverData = d
discoverStale = d.stale
} catch (e: any) { toast('Empfehlungen nicht ladbar: ' + e.message, true) }
discoverLoading = false
}
// ---- Search ----
function parseHfRepo(s: string) {
s = s.trim()
const u = s.match(/huggingface\.co\/([^\s/]+\/[^\s/?#]+)/i)
if (u) return u[1]
if (/^[\w.-]+\/[\w.-]+$/.test(s)) return s
return null
}
async function doSearch() {
const raw = searchQuery.trim()
const repo = parseHfRepo(raw)
const q = raw
if (!q && !repo) { searchResults = []; return }
searchLoading = true; cardFits = []; bestResultIdx = -1
try {
if (repo) {
searchResults = [{ id: repo, author: repo.split('/')[0], downloads: 0 }]
} else {
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&full=true&sort=${encodeURIComponent(sortBy)}&direction=-1&limit=40`
const r = await fetch(url)
searchResults = await r.json()
}
// Fetch fits in background
searchResults.forEach((m: any, i: number) => fetchFitForCard(i, m.id))
} catch (e: any) { toast(e.message, true); searchResults = [] }
searchLoading = false
}
async function fetchFitForCard(i: number, repo_id: string) {
try {
const res = await api('/api/cookbook/analyze', { method: 'POST', body: JSON.stringify({ repo_id, ctx: 8192 }) })
if (!res.files?.length) { cardFits[i] = null; updateBest(); return }
const best = res.files.find((f: any) => f.quant?.includes('Q4_K_M')) || res.files[0]
cardFits[i] = best.fit.level; updateBest()
} catch { cardFits[i] = null; updateBest() }
}
function updateBest() {
let bi = -1, br = 99
cardFits.forEach((lvl, i) => {
if (lvl == null) return
const r = FIT_RANK[lvl] ?? 99
if (r < br) { br = r; bi = i }
})
bestResultIdx = (bi >= 0 && br <= 1) ? bi : -1
}
// ---- Recipe detail modal ----
function openRecipe(id: string) {
recipeModal = recipes.find((r: any) => r.id === id)
}
async function installRecipe(id: string) {
const btn = document.getElementById('cb-r-install-btn') as HTMLButtonElement
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} 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' } }
}
// ---- Model detail modal (from search/discover) ----
async function openModel(repo: string, title: string) {
modelModal = true; modelModalTitle = title; modelModalRepo = repo
modelRole = ''; modelCtx = 8192; modelAnalysis = null; modelFit = null; modelFile = ''
modelLoading = true
try {
modelAnalysis = await api('/api/cookbook/analyze', { method: 'POST', body: JSON.stringify({ repo_id: repo, ctx: 8192 }) })
if (modelAnalysis?.files?.length) {
const best = modelAnalysis.files.find((f: any) => f.quant?.includes('Q4_K_M')) || modelAnalysis.files[0]
modelFile = best.filename
// Auto-set optimal ctx
if (best.optimal_ctx && modelCtx === 8192) modelCtx = best.optimal_ctx
updateModelFit()
}
} catch (e: any) { toast(e.message, true) }
modelLoading = false
}
function updateModelFit() {
if (!modelAnalysis?.files?.length || !modelFile) return
modelFit = modelAnalysis.files.find((f: any) => f.filename === modelFile) || null
if (modelFit?.optimal_ctx && modelCtx === 8192) modelCtx = modelFit.optimal_ctx
}
async function doDownload() {
if (!modelModalRepo || !modelFile) return toast('Bitte eine GGUF-Datei wählen.', true)
modelLoading = true
try {
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
}
// ---- Install discovered model ----
async function installDiscovered(repo: string, role: string, params_b: number, btnEl: HTMLButtonElement) {
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 läuft — Modell erscheint danach automatisch im Modelle-Tab.'); goView('activity')
} catch (e: any) { toast(e.message, true); btnEl.disabled = false; btnEl.textContent = 'Installieren' }
}
// ---- New/edit recipe ----
function openNewRecipe() {
editRecipeId = null; newRecipeTitle = ''; newRecipeDesc = ''
newRecipeModels = [{ repo: '', role: '', quant: 'Q4_K_M' }]
newRecipeOpen = true
}
function openEditRecipe(id: string) {
const r = recipes.find((x: any) => x.id === id); if (!r) return
editRecipeId = id; newRecipeTitle = r.title; newRecipeDesc = r.desc || ''
newRecipeModels = r.models.map((m: any) => ({ repo: m.repo, role: m.role, quant: m.quant || 'Q4_K_M' }))
newRecipeOpen = true
}
async function saveRecipe() {
if (!newRecipeTitle.trim()) return toast('Bitte einen Titel angeben.', true)
const models = newRecipeModels.filter((m: any) => m.repo.trim())
if (!models.length) return toast('Mindestens ein Modell (Repo) angeben.', true)
try {
if (editRecipeId) {
await api('/api/cookbook/user-recipe/' + encodeURIComponent(editRecipeId), { method: 'PUT', body: JSON.stringify({ title: newRecipeTitle, desc: newRecipeDesc, models }) })
toast('Setup aktualisiert.')
} else {
await api('/api/cookbook/user-recipe', { method: 'POST', body: JSON.stringify({ title: newRecipeTitle, desc: newRecipeDesc, models }) })
toast('Setup gespeichert.')
}
newRecipeOpen = false; loadRecipes()
} catch (e: any) { toast(e.message, true) }
}
async function deleteRecipe(id: string) {
const r = recipes.find((x: any) => x.id === id)
if (!await confirmModal({ title: `„${r?.title || id}" löschen?`, body: 'Dein eigenes Setup wird aus dem Cookbook entfernt. Bereits installierte Modelle bleiben unangetastet.', confirmLabel: 'Löschen', danger: true })) return
try { await api('/api/cookbook/user-recipe/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Setup gelöscht.'); loadRecipes() }
catch (e: any) { toast(e.message, true) }
}
// ---- MoE chips ----
function moeChipHtml(name: string) {
if (!isMoe(name)) return ''
const a = moeActive(name)
return `<span class="chip" title="${esc(MOE_HELP)}">MoE${a ? ` · ~${a}B aktiv` : ''}</span>`
}
function capChipHtml(label: string) { return label ? `<span class="chip">${esc(label)}</span>` : '' }
</script>
<!-- ============================================================ -->
<!-- RECIPES (Use-Case Setups) -->
<div class="pagehead">
<div><h1>Cookbook</h1><div class="sub">Modelle finden, installieren und eigene Setups zusammenstellen.</div></div>
</div>
<!-- Segmented-Tabs -->
<div class="flex gap-2" style="margin-bottom:14px">
<button class="{cookTab === 'setups' ? 'primary' : 'ghost'}" style="padding:7px 16px"
onclick={() => cookTab = 'setups'}>📦 Fertige Setups</button>
<button class="{cookTab === 'find' ? 'primary' : 'ghost'}" style="padding:7px 16px"
onclick={() => cookTab = 'find'}>🔍 Modelle finden</button>
</div>
{#if cookTab === 'setups'}
<div class="card">
<div class="card-h">
<h3>Fertige Setups — automatisch aktuell für deine Hardware</h3>
<button class="ghost" style="margin-left:auto" onclick={openNewRecipe}>+ Eigenes Setup</button>
</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}
<div id="cb-recipes" class="grid grid-3" style="gap:10px;margin-top:6px">
{#each recipes as r}
{@const best = r.id === recommended}
<button class="card-btn{best ? ' cb-best' : ''}" style="text-align:left;position:relative"
onclick={() => openRecipe(r.id)}>
{#if r.user}
<span title="Setup bearbeiten" style="position:absolute;top:10px;right:36px;color:var(--accent);cursor:pointer;font-size:14px;line-height:1" role="button" tabindex="0"
onclick={e => { e.stopPropagation(); openEditRecipe(r.id) }} onkeydown={e => { if(e.key==='Enter'){e.stopPropagation();openEditRecipe(r.id)} }}></span>
<span title="Setup löschen" style="position:absolute;top:9px;right:12px;color:var(--err);cursor:pointer;font-size:18px;line-height:1" role="button" tabindex="0"
onclick={e => { e.stopPropagation(); deleteRecipe(r.id) }} onkeydown={e => { if(e.key==='Enter'){e.stopPropagation();deleteRecipe(r.id)} }}>×</span>
{/if}
{#if best}<div class="cb-best-tag">★ Beste Wahl für dein System</div>
{:else if r.user}<div class="cb-best-tag" style="background:var(--act);color:#fff">Dein Setup</div>{/if}
<div class="flex justify-between items-center">
<span class="flex items-center gap-2">
<h3 style="margin:0;font-size:15px">{r.title}</h3>
</span>
<span class="fit-badge {fitCls(r.fit_level)}">{fitWord(r.fit_level)}</span>
</div>
<p>{r.desc}</p>
<div class="text-xs text-mut">{r.models.length} Modell{r.models.length > 1 ? 'e' : ''} im Setup</div>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{#if cookTab === 'find'}
<!-- ============================================================ -->
<!-- DISCOVER -->
<div class="card">
<div class="card-h">
<h3>Aktuell die besten Modelle für dein System</h3>
<span class="meta" style="margin-left:auto">
{#if discoverData?.updated}
{@const ageMin = Math.round((Date.now() / 1000 - discoverData.updated) / 60)}
{#if ageMin < 60}
Quellen befragt vor {ageMin} min
{:else if ageMin < 1440}
Quellen befragt vor {Math.round(ageMin / 60)} h
{:else}
Quellen befragt vor {Math.round(ageMin / 1440)} Tg.
{/if}
{#if discoverStale}<span style="color:var(--warn)"> · ⚠ Cache veraltet</span>{/if}
{/if}
</span>
<button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
{discoverLoading ? 'Suche…' : 'Aktualisieren'}
</button>
</div>
<div class="card-sub">Top-Modelle je Kategorie von HuggingFace — Hardware-Fit wird live für dein System berechnet.</div>
{#if discoverLoading && !discoverData}
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
{:else if discoverData?.categories}
{#each discoverData.categories as cat}
<div style="margin-bottom:8px">
<div class="flex items-center gap-2" style="margin:6px 0 8px">
<h4 style="margin:0;font-size:13.5px;font-weight:600">{cat.title}</h4>
</div>
<div class="grid grid-3">
{#each cat.models as m}
{@const best = cat.recommended && m.repo === cat.recommended}
<div class="card{best ? ' res-best' : ''}" style="display:flex;flex-direction:column;position:relative">
{#if best}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0">
<h3 style="margin:0;font-size:14px;font-weight:500;word-break:break-word">{m.name}</h3>
<div class="text-xs text-mut" style="margin-top:3px">{m.author} · {m.params_b}B</div>
</div>
<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capsChipsHtml(m.repo, m.tags)}
</div>
<div class="mono-sm text-mut" style="margin-top:8px;font-size:11.5px">{metricLine(m.fit)} · ⬇ {(m.downloads || 0).toLocaleString()}</div>
{#each (m.requirements || []) as req}
<div class="li-sub" style="color:var(--warn);margin-top:3px">{req}</div>
{/each}
<div style="flex:1;min-height:8px"></div>
<div class="flex justify-end" style="margin-top:10px">
{#if m.installed}
<span class="fit-badge ok" title="bereits eingerichtet als {m.installed}">✓ installiert</span>
{:else}
<button class="primary" style="padding:6px 12px;font-size:12.5px"
onclick={e => installDiscovered(m.repo, m.role, m.params_b, e.currentTarget as HTMLButtonElement)}>
Installieren
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>
<!-- ============================================================ -->
<!-- PRO SEARCH -->
<div class="card">
<div class="card-h"><h3>Profi-Suche (HuggingFace)</h3></div>
<div class="card-sub">Direkt HuggingFace durchsuchen — oder eine Modell-URL einfügen für den direkten Sprung.</div>
<div class="flex gap-2" style="align-items:stretch;margin-bottom:10px">
<input bind:value={searchQuery} placeholder="Suchbegriff oder HuggingFace-URL…" style="flex:1;margin:0"
onkeydown={e => e.key === 'Enter' && doSearch()}>
<select bind:value={sortBy} style="width:auto;margin:0">
<option value="downloads">Downloads</option>
<option value="trending">Trending</option>
<option value="created_at">Neu</option>
</select>
<button class="primary" disabled={searchLoading} onclick={doSearch}>{searchLoading ? 'Lade…' : 'Suchen'}</button>
</div>
<!-- Facetten-Filter (Mehrfachauswahl, clientseitig auf den Treffern) -->
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px;align-items:center">
<span class="text-xs text-mut" style="margin-right:2px">Filter:</span>
{#each FACET_DEFS as [key, label]}
<button class="{facets[key] ? 'primary' : 'ghost'}" style="padding:4px 10px;border-radius:999px;font-size:12px"
onclick={() => facets[key] = !facets[key]}>
{label}
</button>
{/each}
{#if Object.values(facets).some(v => v)}
<button class="ghost" style="padding:4px 10px;border-radius:999px;font-size:12px;color:var(--mut)"
onclick={() => facets = { tools: false, moe: false, reasoning: false, vision: false, coder: false }}>
✕ zurücksetzen
</button>
{/if}
</div>
<!-- Results -->
{#if searchResults.length}
<div class="grid grid-3" id="cb-grid">
{#each searchResults as m, i}
{#if matchesFacets(m.id, m.tags)}
<div class="card{i === bestResultIdx ? ' res-best' : ''}" style="display:flex;flex-direction:column;cursor:pointer" onclick={() => openModel(m.id, m.id.split('/').pop())}>
{#if i === bestResultIdx}<div class="cb-best-tag">★ Beste Wahl für dein System</div>{/if}
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0">
<h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">{m.id.split('/').pop()}</h3>
<div class="text-xs text-mut" style="margin-top:3px">{m.author || ''}</div>
</div>
<span class="fit-badge {cardFits[i] ? fitCls(cardFits[i]!) : 'warn'}">{cardFits[i] ? fitWord(cardFits[i]!) : 'prüfe…'}</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-top:7px">
{@html capsChipsHtml(m.id, m.tags)}
</div>
<div style="flex:1;margin-top:12px"></div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
<span class="mono-sm">{cardFits[i] ? metricLine({ req_gb: 0, tps: 0 }) : 'Hardware-Fit…'}</span>
<span class="mono-sm">{(m.downloads || 0).toLocaleString()}</span>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
<!-- ============================================================ -->
<!-- RECIPE DETAIL MODAL -->
{#if recipeModal}
{@const r = recipeModal}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) recipeModal = null }}>
<div class="modal-card" style="max-width:560px">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => recipeModal = null}>Schließen</button>
<h3>{r.title}</h3>
<p class="text-mut text-sm" style="margin:-4px 0 16px">{r.desc}</p>
<div class="list">
{#each r.models as m}
<div class="li" style="align-items:flex-start">
<div class="li-main">
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id">{m.name}</span>
<span class="tag text">{m.role}</span>
{@html capsChipsHtml(m.repo, m.tags)}
</div>
{#if m.why}<div class="li-sub">{m.why}</div>{/if}
{#if m.fit}
<div class="li-sub mono-sm">~{(m.fit.req_gb ?? 0).toFixed(1)} GB · ~{Math.round(m.fit.tps ?? 0)} Tok/s · optimal ~{Math.round((m.optimal_ctx || 8192) / 1024)}k Kontext</div>
{/if}
</div>
{#if m.fit}<span class="fit-badge {fitCls(m.fit.level)}">{m.fit.text}</span>{/if}
</div>
{/each}
</div>
{#if r.models.length && r.models[0].fit}
{@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 (nicht empfohlen)' : 'Komplettes Setup installieren'}
</button>
</div>
</div>
{/if}
<!-- ============================================================ -->
<!-- MODEL DETAIL MODAL -->
{#if modelModal}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) modelModal = false }}>
<div class="modal-card" style="max-width:560px">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => modelModal = false}>Schließen</button>
<h3>{modelModalTitle}</h3>
<p class="mono-sm" style="margin:-4px 0 16px">{modelModalRepo}</p>
{#if modelLoading && !modelAnalysis}
<div class="hint">Lade Dateien von HuggingFace…</div>
{:else if modelAnalysis?.files?.length}
<label>Quantisierung (GGUF-Datei) wählen</label>
<select bind:value={modelFile} onchange={updateModelFit}>
{#each modelAnalysis.files as f}
{@const mark = f.fit.level === 'perfect' ? '●' : f.fit.level === 'marginal' ? '◐' : '○'}
<option value={f.filename}>{mark} {f.filename}</option>
{/each}
</select>
<div class="row" style="margin-top:4px">
<div>
<label>Rolle (optional)</label>
<input bind:value={modelRole} placeholder="z.B. coder, vision, scout">
</div>
<div>
<label>Kontext-Größe {@html infoDot(CTX_HELP)}</label>
<input type="number" bind:value={modelCtx}>
</div>
</div>
{#if modelFit?.optimal_ctx}
<div class="hint" style="margin:-6px 0 14px">
Empfohlener Kontext für deine Hardware: <b>~{Math.round(modelFit.optimal_ctx / 1024)}k</b>
<a href="#" onclick={e => { e.preventDefault(); modelCtx = modelFit.optimal_ctx }}>übernehmen</a>
</div>
{/if}
{#if modelFit}
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
<div>
<div style="font-size:13px">Ressourcen-Check</div>
<div class="hint" style="margin:4px 0 0">~{(modelFit.fit.req_gb ?? 0).toFixed(1)} GB · ~{Math.round(modelFit.fit.tps ?? 0)} Tok/s · {modelFit.quant || 'GGUF'}</div>
</div>
<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ß — nicht empfohlen)' : 'Herunterladen & Einpflegen'}
</button>
{:else if !modelLoading}
<div class="hint">Keine GGUF-Dateien im Repo gefunden.</div>
{/if}
</div>
</div>
{/if}
<!-- ============================================================ -->
<!-- NEW/EDIT RECIPE MODAL -->
{#if newRecipeOpen}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) newRecipeOpen = false }}>
<div class="modal-card" style="max-width:640px">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => newRecipeOpen = false}>Schließen</button>
<h3>{editRecipeId ? 'Setup bearbeiten' : 'Eigenes Setup erstellen'}</h3>
<p class="text-mut text-sm" style="margin:-4px 0 16px">Stell dir aus beliebigen Modellen ein eigenes Setup zusammen.</p>
<label>Titel</label>
<input bind:value={newRecipeTitle} placeholder="z.B. Mein Schreib-Stack">
<label>Kurzbeschreibung (optional)</label>
<input bind:value={newRecipeDesc} placeholder="Wofür ist dieses Setup gut?">
<label style="margin-top:10px">Modelle</label>
{#each newRecipeModels as m, i}
<div class="flex gap-2" style="margin-bottom:8px;align-items:center">
<input bind:value={m.repo} placeholder="Repo, z.B. unsloth/Qwen3-8B-GGUF" style="flex:2;margin:0">
<input bind:value={m.role} placeholder="Rolle (z.B. coder)" style="flex:1;margin:0">
<select bind:value={m.quant} style="width:auto;margin:0">
{#each ['Q4_K_M','Q5_K_M','Q6_K','Q8_0','Q3_K_M'] as q}<option>{q}</option>{/each}
</select>
<button class="ghost del" style="margin:0;padding:6px 10px" onclick={() => newRecipeModels = newRecipeModels.filter((_, j) => j !== i)}>×</button>
</div>
{/each}
<button class="ghost" style="margin-top:8px" onclick={() => newRecipeModels = [...newRecipeModels, { repo: '', role: '', quant: 'Q4_K_M' }]}>+ Modell hinzufügen</button>
<div class="hint" style="margin-top:10px">Die <b>Repo-ID</b> findest du über die <b>Profi-Suche</b> weiter unten (z.B. <code>unsloth/Qwen3-8B-GGUF</code>).</div>
<button class="primary" style="width:100%;margin-top:14px" onclick={saveRecipe}>
{editRecipeId ? 'Änderungen speichern' : 'Setup speichern'}
</button>
</div>
</div>
{/if}
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import { GUIDE_GROUPS, CONCEPTS } from './guides-data.js'
let activeGroup = $state(0)
function scrollTo(gi: number) {
activeGroup = gi
document.getElementById(`guide-group-${gi}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<div class="pagehead">
<div>
<h1>Guide</h1>
<div class="sub">Schritt für Schritt erklärt — von der ersten Einrichtung bis zu echten Agenten-Workflows.</div>
</div>
</div>
<!-- Begriffe oben -->
<div class="card">
<div class="card-h"><h3>Begriffe kurz erklärt</h3></div>
<div class="concept-grid" style="margin-top:8px">
{#each CONCEPTS as [term, text]}
<div class="concept">
<h4>{term}</h4>
<p>{text}</p>
</div>
{/each}
</div>
</div>
<!-- Tab-Navigation -->
<div class="guide-tabs">
{#each GUIDE_GROUPS as group, gi}
<button
class="guide-tab{activeGroup === gi ? ' active' : ''}"
onclick={() => scrollTo(gi)}>
{group.label}
</button>
{/each}
</div>
<!-- Guide-Karten -->
{#each GUIDE_GROUPS as group, gi}
<div class="card guide-card" id="guide-group-{gi}">
<div class="guide-group-hd">{group.label}</div>
{#each group.tutorials as [title, body], i}
<details class="guide-acc" open={gi === 0 && i === 0}>
<summary>{title}</summary>
<div class="acc-body">{@html body}</div>
</details>
{/each}
</div>
{/each}
<style>
.guide-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin: -6px 0 2px;
}
.guide-tab {
font-size: 12px;
padding: 5px 13px;
border-radius: 999px;
background: var(--tile);
border: 1px solid var(--line);
color: var(--mut);
cursor: pointer;
transition: .13s;
font-family: var(--sans);
}
.guide-tab:hover { color: var(--tx); border-color: var(--line2); }
.guide-tab.active {
background: rgba(45,212,191,.12);
border-color: rgba(45,212,191,.30);
color: var(--accent);
}
.guide-card {
padding: 0;
overflow: hidden;
margin-bottom: 0;
}
.guide-group-hd {
padding: 9px 18px 7px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent);
background: rgba(45, 212, 191, 0.06);
border-bottom: 1px solid var(--line);
}
/* Comparison / info tables inside acc-body */
:global(.tut-table) {
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
margin-top: 8px;
font-size: 12.5px;
}
:global(.tut-row) {
display: grid;
grid-template-columns: 1fr 2fr;
border-bottom: 1px solid var(--line);
}
:global(.tut-row:last-child) { border-bottom: none; }
:global(.tut-cell-hd) {
padding: 10px 14px;
font-weight: 500;
border-right: 1px solid var(--line);
background: rgba(255,255,255,.025);
line-height: 1.5;
}
:global(.tut-cell-hd.bad) {
color: var(--mut);
text-decoration: line-through;
font-style: italic;
}
:global(.tut-cell-bd) {
padding: 10px 14px;
line-height: 1.6;
color: var(--fg);
}
/* Source / link lines at bottom of tutorials */
:global(.tut-sources) {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid var(--line);
font-size: 11.5px;
color: var(--mut);
}
:global(.tut-sources a) {
color: var(--accent);
text-decoration: none;
}
:global(.tut-sources a:hover) { text-decoration: underline; }
/* Code blocks inside tutorials */
:global(.tut-code) {
display: block;
background: var(--bg, #0d0d0d);
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px 14px;
font-family: monospace;
font-size: 11.5px;
line-height: 1.65;
white-space: pre;
overflow-x: auto;
margin: 10px 0;
color: var(--fg);
}
</style>
+353
View File
@@ -0,0 +1,353 @@
<script lang="ts">
import { api } from '@core/api.js'
import { toast } from '@core/ui.js'
// ---- State ----
let status = $state<any>(null)
let setupOpen = $state(false)
let cockpitOpen = $state(false)
let cockpitLoading = $state(false)
let agent = $state<any>(null)
let cronJobs = $state<any[]>([])
let skills = $state<any[]>([])
let learned = $state<any>(null)
let insights = $state<any>(null)
let pubkey = $state('')
// ---- Status laden ----
async function loadStatus() {
try { status = await api('/api/hermes/status') }
catch { status = null }
}
async function loadPubkey() {
try {
const r = await api('/api/hermes/pubkey')
pubkey = r.pubkey
} catch { pubkey = '' }
}
// ---- Cockpit (Phase 4): Agent-Status, Cron, Skills ----
async function loadCockpit() {
cockpitLoading = true
try {
const [a, c, s, l, i] = await Promise.all([
api('/api/hermes/agent'),
api('/api/hermes/cron'),
api('/api/hermes/skills'),
api('/api/hermes/learned'),
api('/api/hermes/insights'),
])
agent = a
cronJobs = c.jobs || []
skills = s.skills || []
learned = l
insights = i
} catch { agent = null }
cockpitLoading = false
}
// Skills sortiert: meistgenutzt zuerst, dann alphabetisch
let skillsSorted = $derived(
[...skills].sort((a, b) =>
(b.use_count || 0) - (a.use_count || 0) || (a.name || '').localeCompare(b.name || ''))
)
let skillsEnabled = $derived(skills.filter(s => (s.status || '').includes('enabled')).length)
function copyKey() {
navigator.clipboard?.writeText(pubkey)
toast('SSH-Key kopiert')
}
// ---- Boot ----
$effect(() => {
loadStatus()
})
</script>
<!-- ==================== HEADER (schlank, fuer Full-Bleed-Chat) ==================== -->
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h1 style="margin:0;font-size:20px">Hermes</h1>
<div class="flex gap-2" style="flex-shrink:0;flex-wrap:wrap;align-items:center">
<!-- Hermes-Status -->
<span class="chip" style="font-size:11px;{status?.hermes === 'ready'
? 'color:#7ee29a;border-color:rgba(126,226,154,.4)'
: 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
● Agent: {status?.hermes === 'ready' ? 'online' : 'offline'}
</span>
<!-- Cockpit (Phase 4) -->
<button class="ghost" style="padding:4px 10px;font-size:12px;{cockpitOpen ? 'border-color:var(--accent);color:var(--accent)' : ''}"
onclick={() => { cockpitOpen = !cockpitOpen; if (cockpitOpen) loadCockpit() }}>
📡 Cockpit
</button>
<!-- Setup -->
<button class="ghost" style="padding:4px 10px;font-size:12px"
onclick={() => { setupOpen = !setupOpen; if (setupOpen) { loadStatus(); loadPubkey() } }}>
⚙ Setup
</button>
</div>
</div>
<!-- ==================== COCKPIT (Phase 4) ==================== -->
{#if cockpitOpen}
<div class="card" style="border-color:rgba(45,212,191,.3)">
<div class="card-h" style="display:flex;justify-content:space-between;align-items:center">
<h3>Agent-Cockpit</h3>
<button class="ghost" style="font-size:11px;padding:2px 10px" onclick={loadCockpit}>
{cockpitLoading ? '…' : '↻ Aktualisieren'}
</button>
</div>
<!-- Agent-Status -->
<div class="tile" style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<div>
<b>Status</b>
<div class="card-sub" style="font-size:12px">Hermes-Gateway-Dienst (:8642) auf dem Bosgame</div>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;align-items:center">
<span class="chip" style="font-size:11px;{agent?.gateway_state === 'running'
? 'color:#7ee29a;border-color:rgba(126,226,154,.4)' : 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
{agent?.gateway_state ?? 'offline'}
</span>
<span class="chip" style="font-size:11px;{agent?.cron_running
? 'color:var(--accent);border-color:var(--accent)' : 'color:var(--mut)'}">
⏱ Scheduler {agent?.cron_running ? 'läuft' : 'inaktiv'}
</span>
{#if agent?.heartbeat_age_s != null}
<span class="chip" style="font-size:11px;color:var(--mut)">Ticker vor {agent.heartbeat_age_s}s</span>
{/if}
{#if agent?.version}
<span class="chip" style="font-size:11px;color:var(--mut)" title={agent.version}>{agent.version.split(' · ')[0]}</span>
{/if}
</div>
</div>
</div>
<!-- Aktivität (Phase 8) -->
{#if insights && (insights.sessions || insights.total_tokens)}
<div class="tile" style="margin-bottom:10px">
<div style="margin-bottom:8px"><b>Aktivität</b> <span class="card-sub" style="font-size:11px">(letzte 30 Tage)</span></div>
<div style="display:flex;gap:18px;flex-wrap:wrap;font-size:12.5px;margin-bottom:8px">
{#if insights.sessions}<span><b style="color:var(--accent)">{insights.sessions}</b> <span class="card-sub">Sessions</span></span>{/if}
{#if insights.messages}<span><b style="color:var(--accent)">{insights.messages}</b> <span class="card-sub">Nachrichten</span></span>{/if}
{#if insights.tool_calls}<span><b style="color:var(--accent)">{insights.tool_calls}</b> <span class="card-sub">Tool-Calls</span></span>{/if}
{#if insights.total_tokens}<span><b style="color:var(--accent)">{insights.total_tokens}</b> <span class="card-sub">Tokens</span></span>{/if}
{#if insights.active_time}<span><b style="color:var(--accent)">{insights.active_time}</b> <span class="card-sub">aktiv</span></span>{/if}
</div>
{#if insights.top_tools?.length}
<div class="card-sub" style="font-size:11px;margin-bottom:4px">Meistgenutzte Tools</div>
<div style="display:flex;flex-direction:column;gap:3px">
{#each insights.top_tools.slice(0, 6) as t}
<div style="display:flex;align-items:center;gap:8px;font-size:12px">
<span style="min-width:230px">{t.name}</span>
<span style="flex:1;height:6px;background:rgba(255,255,255,.05);border-radius:3px;overflow:hidden">
<span style="display:block;height:100%;width:{t.pct}%;background:var(--accent)"></span>
</span>
<span class="card-sub" style="font-size:11px;min-width:64px;text-align:right">{t.calls}× · {t.pct}%</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Was Hermes gelernt hat (Phase 8) -->
{#if learned?.user_facts?.length || learned?.memory}
<div class="tile" style="margin-bottom:10px">
<div style="margin-bottom:6px"><b>Was Hermes über dich gelernt hat</b>
<span class="card-sub" style="font-size:11px">(automatisches Profil)</span></div>
{#if learned.user_facts?.length}
<ul style="margin:0 0 6px;padding-left:18px;display:flex;flex-direction:column;gap:3px">
{#each learned.user_facts as f}
<li style="font-size:12.5px;line-height:1.45">{f}</li>
{/each}
</ul>
{/if}
{#if learned.memory}
<div class="card-sub" style="font-size:11.5px;border-top:1px solid rgba(255,255,255,.06);padding-top:6px;margin-top:2px">
{learned.memory}
</div>
{/if}
</div>
{/if}
<!-- Cron-Scheduler -->
<div class="tile" style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<b>Geplante Aufgaben <span class="card-sub" style="font-size:11px">({cronJobs.length})</span></b>
</div>
{#if cronJobs.length === 0}
<div class="card-sub" style="font-size:12px">
Keine geplanten Jobs. Hermes kann selbst welche anlegen — sag ihm z. B.
„Erinnere mich jeden Morgen um 9 Uhr an die offenen Modell-Upgrades".
</div>
{:else}
<div style="display:flex;flex-direction:column;gap:6px">
{#each cronJobs as job}
<div style="display:flex;align-items:center;gap:8px;font-size:12.5px;
padding:6px 8px;background:rgba(255,255,255,.02);border-radius:6px">
{#each Object.entries(job) as [k, v]}
{#if v}<span style="color:var(--mut)"><span style="opacity:.6">{k}:</span> {v}</span>{/if}
{/each}
</div>
{/each}
</div>
{/if}
</div>
<!-- Skills -->
<div class="tile">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<b>Skills <span class="card-sub" style="font-size:11px">({skillsEnabled} aktiv von {skills.length})</span></b>
</div>
{#if skills.length === 0}
<div class="card-sub" style="font-size:12px">Keine Skills geladen (oder Agent offline).</div>
{:else}
<div style="max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:3px">
{#each skillsSorted as s}
<div style="display:flex;align-items:center;gap:8px;font-size:12.5px;padding:4px 8px;
border-radius:6px;{(s.status || '').includes('enabled') ? '' : 'opacity:.45'}">
<span style="width:7px;height:7px;border-radius:50%;flex-shrink:0;
background:{(s.status || '').includes('enabled') ? 'var(--accent)' : 'var(--mut)'}"></span>
<span style="font-weight:500">{s.name}</span>
{#if s.category}<span class="card-sub" style="font-size:11px">{s.category}</span>{/if}
<span style="flex:1"></span>
{#if s.use_count > 0}
<span class="chip" style="font-size:10px;color:var(--accent);border-color:rgba(45,212,191,.3)">
{s.use_count}× genutzt
</span>
{/if}
<span class="card-sub" style="font-size:10px">{s.source}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- ==================== SETUP-WIZARD ==================== -->
{#if setupOpen}
<div class="card" style="border-color:rgba(45,212,191,.3)">
<div class="card-h"><h3>Hermes Setup-Wizard</h3></div>
<!-- Whisper -->
<div class="tile" style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<b>Whisper (Sprache → Text)</b>
<div class="card-sub" style="font-size:12px">faster-whisper muss auf dem Bosgame installiert sein</div>
</div>
<span class="chip" style="font-size:11px;{status?.whisper !== 'not_installed' ? 'color:#7ee29a' : 'color:#ff9b95'}">
{status?.whisper !== 'not_installed' ? '✓ OK' : '✗ fehlt'}
</span>
</div>
{#if status?.whisper === 'not_installed'}
<div class="log" style="margin-top:8px"><code>pip install faster-whisper</code></div>
{/if}
</div>
<!-- Piper -->
<div class="tile" style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<b>Piper TTS (Text → Sprache)</b>
<div class="card-sub" style="font-size:12px">Piper-Binary + deutsche Stimme</div>
</div>
<span class="chip" style="font-size:11px;{status?.piper === 'ready' ? 'color:#7ee29a' : 'color:#ff9b95'}">
{status?.piper === 'ready' ? '✓ OK' : '✗ fehlt'}
</span>
</div>
{#if status?.piper !== 'ready'}
<div class="log" style="margin-top:8px"><code>{[
'# 1. Piper Binary (Linux x86_64)',
'mkdir -p /opt/mission-control/piper/voices',
'cd /tmp && wget -q https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz',
'tar -xzf piper_linux_x86_64.tar.gz -C /opt/mission-control/piper/ --strip-components=1',
'',
'# 2. Deutsche Stimme — Kerstin (weiblich, ~25 MB)',
'wget -q -O /opt/mission-control/piper/voices/de_DE-kerstin-low.onnx \\',
' "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/de/de_DE/kerstin/low/de_DE-kerstin-low.onnx"',
'wget -q -O /opt/mission-control/piper/voices/de_DE-kerstin-low.onnx.json \\',
' "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/de/de_DE/kerstin/low/de_DE-kerstin-low.onnx.json"',
].join('\n')}</code></div>
{/if}
</div>
<!-- Windows SSH -->
<div class="tile">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<b>Windows SSH-Verbindung</b>
<div class="card-sub" style="font-size:12px">Damit Hermes Dateien auf deinem Windows-PC ändern kann</div>
</div>
<span class="chip" style="font-size:11px;{status?.ssh_ok ? 'color:#7ee29a' : 'color:var(--mut)'}">
{status?.ssh_ok ? '✓ verbunden' : 'optional'}
</span>
</div>
{#if !status?.ssh_ok}
<div style="margin-top:12px">
<div style="font-size:12.5px;font-weight:500;margin-bottom:8px">Einrichtung (einmalig, ~3 Minuten):</div>
<div style="margin-bottom:8px">
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">1. PowerShell als Admin — OpenSSH Server installieren:</div>
<div class="log"><code>Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0</code></div>
</div>
<div style="margin-bottom:8px">
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">2. Dienst starten und dauerhaft aktivieren:</div>
<div class="log"><code>{"Start-Service sshd\nSet-Service -Name sshd -StartupType Automatic"}</code></div>
</div>
<div style="margin-bottom:8px">
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">3. SSH-Ordner erstellen:</div>
<div class="log"><code>{"New-Item -ItemType Directory -Force -Path \"$env:USERPROFILE\\.ssh\""}</code></div>
</div>
{#if pubkey}
<div style="margin-bottom:8px">
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">4. Bosgame-Key eintragen (in Windows PowerShell):</div>
<div style="position:relative">
<button class="ghost" style="position:absolute;top:6px;right:6px;font-size:11px;padding:2px 8px;z-index:1"
onclick={copyKey}>Kopieren</button>
<div class="log"><code>{"echo \"" + pubkey + "\" >> \"$env:USERPROFILE\\.ssh\\authorized_keys\""}</code></div>
</div>
</div>
{:else}
<div class="card-sub" style="font-size:11.5px">SSH-Key nicht gefunden — bitte auf dem Bosgame generieren:
<code style="display:block;margin-top:4px">ssh-keygen -t ed25519 -C "hermes-agent@bosgame" -f ~/.ssh/id_ed25519_hermes_agent -N ""</code>
</div>
{/if}
<div style="margin-bottom:8px">
<div class="card-sub" style="font-size:11.5px;margin-bottom:4px">5. Windows-IP in Mission Control Env-Var setzen:</div>
<div class="log"><code>HERMES_WINDOWS_HOST=192.168.178.XXX</code></div>
</div>
<button class="primary" style="margin-top:4px" onclick={() => { loadStatus(); toast('Status aktualisiert') }}>
Verbindung testen
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- ==================== CHAT (eingebettetes Hermes-Web-Dashboard, full-bleed) ==================== -->
<div class="card" style="padding:0;overflow:hidden;border-radius:12px">
{#if status?.hermes === 'ready'}
<iframe
title="Hermes Chat"
src="/hermes-ui/chat"
style="width:100%;height:calc(100dvh - 116px);min-height:520px;border:0;display:block;background:#0f1720"
></iframe>
{:else}
<div class="empty-c" style="padding:48px 24px">
<div class="e-t">Hermes-Agent offline</div>
<div class="e-s">Der Hermes-Gateway (:8642) bzw. das Dashboard (:9119) ist nicht erreichbar.
Status im Cockpit prüfen oder den Dienst neu starten.</div>
</div>
{/if}
</div>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { jobsStore } from '../stores/jobs.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.js'
import { toast, fmtBytes } from '@core/ui.js'
const jobs = $derived(jobsStore.value)
const tracked = $derived(jobsStore.tracked)
const sys = $derived(systemStore.value)
const MAX_HIST = 60
let hist = $state({ cpu: [] as number[], ram: [] as number[], gpu: [] as number[] })
$effect(() => {
if (!sys) return
const g = sys.gpu
const gpuPct = g && (g.vram.total + g.gtt.total) > 0
? ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100 : 0
hist.cpu = [...hist.cpu, sys.cpu?.percent ?? 0].slice(-MAX_HIST)
hist.ram = [...hist.ram, sys.ram.percent].slice(-MAX_HIST)
hist.gpu = [...hist.gpu, gpuPct].slice(-MAX_HIST)
})
const gpuPct = $derived(() => {
const g = sys?.gpu
if (g && (g.vram.total + g.gtt.total) > 0)
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
return 0
})
const finishedCount = $derived(jobs.filter(j => j.state !== 'running' && j.state !== 'queued').length)
const failedCount = $derived(jobs.filter(j => j.state === 'failed').length)
function jobPct(j: any) {
if (j.state !== 'running') return null
if (typeof j.progress === 'number') return Math.min(100, j.progress)
const last = (j.log || []).at(-1) || ''
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
return m ? Math.min(100, parseFloat(m[1])) : null
}
function fmtEta(s: number | null) {
if (s == null || s < 0) return ''
return s >= 90 ? `noch ~${Math.round(s / 60)} min` : `noch ~${Math.max(1, Math.round(s))} s`
}
function statusBadge(s: string) {
if (s === 'done') return '<span class="badge b-run">fertig</span>'
if (s === 'failed') return '<span class="badge b-err">fehler</span>'
if (s === 'canceled') return '<span class="badge">abgebrochen</span>'
return '<span class="badge b-load">läuft…</span>'
}
function dotClass(s: string) {
return s === 'done' ? 'on' : (s === 'failed' || s === 'canceled') ? '' : 'load'
}
async function cancelJob(id: string) {
try { await api(`/api/jobs/${id}/cancel`, { method: 'POST' }); toast('Abbruch angefordert…') }
catch (e: any) { toast(e.message, true) }
}
async function deleteJob(id: string) {
try { await api(`/api/jobs/${id}`, { method: 'DELETE' }) }
catch (e: any) { toast(e.message, true) }
}
async function clearJobs() {
try {
const r = await api('/api/jobs/clear', { method: 'POST' })
toast(`${r.removed} Einträge entfernt.`)
} catch (e: any) { toast(e.message, true) }
}
function sparkBars(arr: number[], cssVar: string) {
return arr.map(v =>
`<div style="width:4px;background:var(${cssVar});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`
).join('')
}
</script>
<div class="pagehead">
<div>
<h1>Aktivität</h1>
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div>
</div>
</div>
<!-- System KPI Tiles -->
{#if sys}
{@const gpu = gpuPct()}
<div class="tiles">
<div class="tile">
<div class="t-l">Prozessor (CPU)</div>
<div class="t-v">{Math.round(sys.cpu?.percent ?? 0)}<small> %</small></div>
<div class="t-s">{sys.cpu?.temp != null ? `${Math.round(sys.cpu.temp)}° CPU-Temp` : 'Auslastung'}</div>
</div>
<div class="tile">
<div class="t-l">Arbeitsspeicher</div>
<div class="t-v">{Math.round(sys.ram.percent)}<small> %</small></div>
<div class="t-s">{fmtBytes(sys.ram.used)} / {fmtBytes(sys.ram.total)}</div>
</div>
<div class="tile">
<div class="t-l">Grafikspeicher</div>
<div class="t-v">{Math.round(gpu)}<small> %</small></div>
<div class="t-s">{sys.gpu_temp != null ? `${Math.round(sys.gpu_temp)}° GPU-Temp` : 'VRAM + GTT'}</div>
</div>
</div>
<!-- System Metrics Card -->
<div class="card">
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">{fmtBytes(sys.ram.used)} / {fmtBytes(sys.ram.total)}</span></div>
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span>
<span class="kv-v">{sys.gpu ? `${fmtBytes(sys.gpu.vram.used + sys.gpu.gtt.used)} / ${fmtBytes(sys.gpu.vram.total + sys.gpu.gtt.total)}` : ''}</span>
</div>
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">{Math.round(sys.disk?.percent ?? 0)} % belegt</span></div>
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span>
<span class="kv-v">{sys.gpu_temp != null ? Math.round(sys.gpu_temp) + '°' : ''} / {sys.cpu?.temp != null ? Math.round(sys.cpu.temp) + '°' : ''}</span>
</div>
</div>
<div class="grid grid-3" style="margin-top:14px">
<div><div class="meta text-xs">CPU-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.cpu, '--accent')}</div></div>
<div><div class="meta text-xs">RAM-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.ram, '--purple')}</div></div>
<div><div class="meta text-xs">VRAM-Verlauf</div><div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">{@html sparkBars(hist.gpu, '--on')}</div></div>
</div>
</div>
{/if}
<!-- Jobs Card -->
<div class="card">
<div class="card-h">
<h3>Hintergrund-Aufgaben</h3>
<span class="meta">{jobs.length ? (failedCount ? failedCount + ' Fehler' : jobs.length + ' gesamt') : ''}</span>
{#if finishedCount}
<button class="ghost" style="margin-left:auto;padding:4px 11px;font-size:12px" onclick={clearJobs}>Verlauf leeren</button>
{/if}
</div>
<div class="card-sub">Downloads &amp; Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
{#if !jobs.length}
<div class="empty-c">
<div class="e-t">Gerade nichts los.</div>
<div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div>
</div>
{:else}
{#each jobs as j}
{@const p = jobPct(j)}
{@const eta = j.state === 'running' ? fmtEta(j.eta_s) : ''}
{@const rate = j.state === 'running' && j.rate_bps ? fmtBytes(j.rate_bps) + '/s' : ''}
{@const extra = [eta, rate].filter(Boolean).join(' · ')}
{@const open = tracked.includes(j.id)}
<div class="job">
<div class="job-h" role="button" tabindex="0"
onclick={() => jobsStore.toggle(j.id)}
onkeydown={e => e.key === 'Enter' && jobsStore.toggle(j.id)}>
<span class="li-dot {dotClass(j.state)}"></span>
<span class="mid">{j.label}</span>
{#if p != null}
<span class="mono-sm" style="margin-left:auto">{Math.round(p)} %{extra ? ` · ${extra}` : ''}</span>
{/if}
{#if j.state === 'running'}
<button class="ghost" style="{p == null ? 'margin-left:auto;' : ''}margin-right:2px;padding:2px 9px;font-size:12px"
onclick={e => { e.stopPropagation(); cancelJob(j.id) }}>Abbrechen</button>
{:else}
<button class="ghost" title="Aus Verlauf entfernen" style="margin-left:auto;margin-right:2px;padding:2px 9px;font-size:14px;line-height:1"
onclick={e => { e.stopPropagation(); deleteJob(j.id) }}>×</button>
{/if}
{@html statusBadge(j.state)}
</div>
{#if p != null && j.state === 'running'}
<div class="bar" style="margin-top:8px"><i style="width:{p}%"></i></div>
{/if}
{#if open}
<div class="log">{(j.log || []).join('\n')}</div>
{/if}
</div>
{/each}
{/if}
</div>
+351
View File
@@ -0,0 +1,351 @@
<script lang="ts">
import { api } from '@core/api.js'
import { toast, confirmModal } from '@core/ui.js'
type Mem = {
id: string
content: string
category: string
source: string
created_at: string
updated_at: string
}
let memories = $state<Mem[]>([])
let loading = $state(true)
let filter = $state('all')
let adding = $state(false)
let newContent = $state('')
let newCategory = $state('stable')
let editId = $state<string | null>(null)
let editContent = $state('')
let editCategory = $state('stable')
let importing = $state(false)
let importText = $state('')
let importCategory = $state('stable')
let importSource = $state('import')
let importBusy = $state(false)
const importLines = $derived(
importText.split('\n').map(l => l.trim()).filter(l => l.length > 0)
)
const filtered = $derived(
filter === 'all' ? memories : memories.filter(m => m.category === filter)
)
const CAT: Record<string, { label: string; color: string }> = {
user: { label: 'Über mich', color: '#a78bfa' },
instruction:{ label: 'Verhalten', color: '#fb923c' },
stable: { label: 'Stabil', color: 'var(--accent)' },
versioned: { label: 'Versioniert', color: '#f59e0b' },
ephemeral: { label: 'Temporär', color: 'var(--mut)' },
}
function relTime(iso: string) {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (s < 60) return 'gerade eben'
if (s < 3600) return `vor ${Math.floor(s / 60)} Min`
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std`
return `vor ${Math.floor(s / 86400)} Tagen`
}
function countCat(cat: string) {
return memories.filter(m => m.category === cat).length
}
async function load() {
try { memories = await api('/api/memory') }
catch (e: any) { toast('Laden fehlgeschlagen: ' + e.message, true) }
finally { loading = false }
}
async function addMem() {
if (!newContent.trim()) return
try {
const m = await api('/api/memory', {
method: 'POST',
body: JSON.stringify({ content: newContent.trim(), category: newCategory, source: 'manual' })
})
memories = [m, ...memories]
newContent = ''; newCategory = 'stable'; adding = false
toast('Gespeichert')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
}
async function saveMem(m: Mem) {
try {
const updated = await api(`/api/memory/${m.id}`, {
method: 'PUT',
body: JSON.stringify({ content: editContent, category: editCategory })
})
memories = memories.map(x => x.id === m.id ? updated : x)
editId = null
toast('Aktualisiert')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
}
async function delMem(m: Mem) {
const ok = await confirmModal({
title: 'Eintrag löschen?',
body: `„${m.content.length > 80 ? m.content.slice(0, 80) + '…' : m.content}" wird dauerhaft entfernt.`,
danger: true
})
if (!ok) return
try {
await api(`/api/memory/${m.id}`, { method: 'DELETE' })
memories = memories.filter(x => x.id !== m.id)
toast('Gelöscht')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
}
function startEdit(m: Mem) {
editId = m.id; editContent = m.content; editCategory = m.category
}
// Deterministischer Kurator: Dubletten (exakt/enthalten/ähnlich) je Kategorie zusammenführen
let deduping = $state(false)
async function dedupe() {
deduping = true
try {
const preview = await api('/api/memory/dedupe', { method: 'POST', body: JSON.stringify({ apply: false }) })
if (preview.duplicate_count === 0) { toast('Keine Dubletten gefunden 🎉'); return }
const ok = await confirmModal({
title: `${preview.duplicate_count} Dubletten entfernen?`,
body: `${preview.duplicate_count} doppelte Einträge werden gelöscht; die ${preview.groups.length} vollständigeren bleiben erhalten. Nur exakte/enthaltene/sehr ähnliche Einträge derselben Kategorie.`,
danger: true,
})
if (!ok) return
const res = await api('/api/memory/dedupe', { method: 'POST', body: JSON.stringify({ apply: true }) })
toast(`${res.removed} Dubletten entfernt`)
await load()
} catch (e: any) { toast('Fehler: ' + e.message, true) }
finally { deduping = false }
}
async function runImport() {
if (importLines.length === 0) return
importBusy = true
let ok = 0
for (const line of importLines) {
try {
const m = await api('/api/memory', {
method: 'POST',
body: JSON.stringify({ content: line, category: importCategory, source: importSource })
})
memories = [m, ...memories]
ok++
} catch {}
}
importBusy = false
importing = false
importText = ''
toast(`${ok} von ${importLines.length} Einträgen importiert`)
}
$effect(() => { load() })
</script>
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h1>Gedächtnis</h1>
<div class="sub">Fakten, Entscheidungen und Kontext — geteilt von allen KI-Tools via MCP.</div>
</div>
<div class="flex gap-2" style="flex-shrink:0;margin-top:4px">
<button class="ghost" onclick={dedupe} disabled={deduping} title="Doppelte Einträge zusammenführen">
{deduping ? '…' : '🧹 Aufräumen'}
</button>
<button class="ghost" onclick={() => { importing = !importing; adding = false; importText = '' }}>
{importing ? 'Abbrechen' : '⇩ Import'}
</button>
<button class="primary" onclick={() => { adding = !adding; importing = false; newContent = '' }}>
{adding ? 'Abbrechen' : '+ Eintrag'}
</button>
</div>
</div>
<!-- Import form -->
{#if importing}
<div class="card" style="border-color:rgba(45,212,191,.25)">
<div class="card-h"><h3>Import aus Claude / Gemini / ChatGPT</h3></div>
<div class="card-sub" style="margin-bottom:10px">
Füge Erkenntnisse aus anderen KI-Sessions ein — jede Zeile wird ein eigener Eintrag.
</div>
<label>Einträge (eine Zeile = ein Eintrag)</label>
<textarea
rows="8"
placeholder={"Tobi arbeitet am liebsten abends.\nProjektsprache: Deutsch.\nBosgame M5 läuft mit Ubuntu 26.04.\n..."}
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:10px;font-family:var(--mono);font-size:12.5px"
bind:value={importText}
></textarea>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:14px">
<div style="flex:1;min-width:160px">
<label>Kategorie für alle Einträge</label>
<select bind:value={importCategory} style="margin-bottom:0">
<option value="user">Über mich — Wer du bist, Präferenzen</option>
<option value="instruction">Verhalten — Regeln für alle KI-Tools</option>
<option value="stable">Stabil — Projektfakten</option>
<option value="versioned">Versioniert — Tech-Versionen</option>
<option value="ephemeral">Temporär — 7 Tage, dann weg</option>
</select>
</div>
<div style="flex:1;min-width:120px">
<label>Quelle</label>
<select bind:value={importSource} style="margin-bottom:0">
<option value="import">import</option>
<option value="claude">claude</option>
<option value="gemini">gemini</option>
<option value="chatgpt">chatgpt</option>
<option value="manual">manual</option>
</select>
</div>
</div>
{#if importLines.length > 0}
<div class="card-sub" style="margin-bottom:10px">{importLines.length} Eintrag/Einträge erkannt</div>
{/if}
<div class="btn-row">
<button class="primary" onclick={runImport} disabled={importLines.length === 0 || importBusy}>
{importBusy ? 'Importiere…' : `${importLines.length} Einträge speichern`}
</button>
<button class="ghost" onclick={() => { importing = false; importText = '' }}>Abbrechen</button>
</div>
</div>
{/if}
<!-- Add form -->
{#if adding}
<div class="card">
<div class="card-h"><h3>Neuer Eintrag</h3></div>
<label>Inhalt</label>
<textarea
rows="3"
placeholder="z.B.: Mission Control läuft auf Port 9000 — oder: Wir nutzen SQLite statt Qdrant"
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:10px"
bind:value={newContent}
onkeydown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) addMem() }}
></textarea>
<label>Kategorie</label>
<select bind:value={newCategory} style="margin-bottom:14px">
<option value="user">Über mich — Wer du bist, Präferenzen, Arbeitsweise</option>
<option value="instruction">Verhalten — Wie alle KI-Tools sich verhalten sollen</option>
<option value="stable">Stabil — Projektfakten, die sich selten ändern</option>
<option value="versioned">Versioniert — Tech-Versionen (bei Updates überschreiben)</option>
<option value="ephemeral">Temporär — Aktuelle Aufgabe (nach 7 Tagen gelöscht)</option>
</select>
<div class="btn-row">
<button class="primary" onclick={addMem} disabled={!newContent.trim()}>Speichern</button>
<button class="ghost" onclick={() => { adding = false; newContent = '' }}>Abbrechen</button>
<span class="card-sub" style="font-size:11.5px;margin-left:4px">Strg+Enter zum Speichern</span>
</div>
</div>
{/if}
<!-- Category filter -->
<div class="card" style="padding:10px 16px">
<div class="flex gap-2" style="flex-wrap:wrap;align-items:center">
<span style="font-size:11.5px;color:var(--mut);margin-right:4px">Filter:</span>
{#each [
['all', 'Alle', '', memories.length],
['user', 'Über mich', '#a78bfa', countCat('user')],
['instruction', 'Verhalten', '#fb923c', countCat('instruction')],
['stable', 'Stabil', 'var(--accent)', countCat('stable')],
['versioned', 'Versioniert', '#f59e0b', countCat('versioned')],
['ephemeral', 'Temporär', 'var(--mut)', countCat('ephemeral')],
] as [val, lbl, clr, cnt]}
<span class="chip"
role="button" tabindex="0"
style="cursor:pointer;{filter === val
? `background:rgba(45,212,191,.12);border-color:${clr || 'var(--accent)'};color:${clr || 'var(--accent)'}`
: ''}"
onclick={() => filter = val}
onkeydown={e => e.key === 'Enter' && (filter = val)}>
{lbl} <span style="color:var(--mut);margin-left:2px">{cnt}</span>
</span>
{/each}
</div>
</div>
<!-- Memory list -->
{#if loading}
<div class="card"><div class="card-sub">Lade…</div></div>
{:else if filtered.length === 0}
<div class="card">
<div class="empty-c" style="padding:8px 0 4px">
<div class="e-t">{memories.length === 0 ? 'Noch keine Einträge' : 'Keine Treffer'}</div>
<div class="e-s">
{memories.length === 0
? 'Klicke „+ Eintrag" um manuell etwas zu speichern, oder verbinde ein KI-Tool via MCP.'
: 'Andere Kategorie wählen oder Eintrag anlegen.'}
</div>
{#if memories.length === 0}
<button class="primary" style="margin-top:14px" onclick={() => adding = true}>
+ Ersten Eintrag anlegen
</button>
{/if}
</div>
</div>
{:else}
<div class="card" style="padding:0;overflow:hidden">
{#each filtered as m, i (m.id)}
<div style="padding:14px 16px;{i < filtered.length - 1 ? 'border-bottom:1px solid var(--brd, rgba(255,255,255,.07))' : ''}">
{#if editId === m.id}
<textarea
rows="3"
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:8px"
bind:value={editContent}
></textarea>
<select bind:value={editCategory} style="margin-bottom:10px">
<option value="user">Über mich</option>
<option value="instruction">Verhalten</option>
<option value="stable">Stabil</option>
<option value="versioned">Versioniert</option>
<option value="ephemeral">Temporär</option>
</select>
<div class="btn-row">
<button class="primary" onclick={() => saveMem(m)}>Speichern</button>
<button class="ghost" onclick={() => editId = null}>Abbrechen</button>
</div>
{:else}
<div style="display:flex;gap:10px;align-items:flex-start">
<div style="flex:1;min-width:0">
<div style="font-size:13.5px;line-height:1.55;word-break:break-word">{m.content}</div>
<div class="flex gap-2" style="margin-top:8px;flex-wrap:wrap;align-items:center">
<span class="chip" style="border-color:{CAT[m.category]?.color || 'var(--mut)'};color:{CAT[m.category]?.color || 'var(--mut)'}">
{CAT[m.category]?.label || m.category}
</span>
<span class="chip">{m.source}</span>
<span style="font-size:11px;color:var(--mut)">{relTime(m.updated_at)}</span>
</div>
</div>
<div class="flex gap-2" style="flex-shrink:0;margin-top:2px">
<button class="ghost" style="padding:4px 10px;font-size:12px"
onclick={() => startEdit(m)}>Bearbeiten</button>
<button class="ghost" style="padding:4px 10px;font-size:12px;color:var(--err)"
onclick={() => delMem(m)}>Löschen</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Info-Box: wie KI-Tools das Gedächtnis nutzen -->
<div class="card" style="background:rgba(45,212,191,.04);border-color:rgba(45,212,191,.2)">
<div class="card-h"><h3 style="font-size:13px;font-weight:500">Wie KI-Tools dieses Gedächtnis nutzen</h3></div>
<div class="card-sub" style="line-height:1.6">
Cline, OpenCode und Claude Code greifen über den <b>Memory MCP-Server</b> auf diese Einträge zu.
Beim Start einer Session lädt der Agent automatisch alle Einträge — er kennt dann deinen Stack,
deine Präferenzen und frühere Entscheidungen. Den Setup-Guide findest du unter
<a href="#"
onclick={e => { e.preventDefault(); (document.querySelector(".nav-item[data-view='connect']") as HTMLElement)?.click() }}
style="color:var(--accent)">Verbinden → Gedächtnis-MCP</a>.
</div>
<div class="card-sub" style="margin-top:8px;font-size:11.5px">
<b style="color:#a78bfa">Über mich</b> = wer du bist, Präferenzen ·
<b style="color:#fb923c">Verhalten</b> = Regeln für alle KI-Tools ·
<b style="color:var(--accent)">Stabil</b> = Projektfakten ·
<b style="color:#f59e0b">Versioniert</b> = Tech-Versionen ·
<b style="color:var(--mut)">Temporär</b> = 7 Tage, dann weg
</div>
</div>
+272
View File
@@ -0,0 +1,272 @@
<script lang="ts">
import { statusStore } from '../stores/status.svelte.js'
import { api } from '@core/api.js'
import { esc, toast, confirmModal, badge, infoDot } from '@core/ui.js'
const CTX_HELP = 'Kontext = das Kurzzeitgedächtnis des Modells. Größer = merkt sich mehr (längere Dateien/Chats), braucht aber mehr Speicher und wird etwas langsamer.'
const ROLE_HELP = 'Die Rolle ist ein zusätzlicher, sprechender Name (z.B. coder). Du kannst in deinen Tools entweder den echten Modellnamen ODER die Rolle angeben — beides führt zum selben Modell.'
const ROLES = [
{ id: 'coder', label: 'Coder', desc: 'Programmieren & Code' },
{ id: 'vision', label: 'Vision', desc: 'Bilder verstehen' },
{ id: 'scout', label: 'Scout', desc: 'Schneller Allrounder' },
{ id: 'reviewer', label: 'Reviewer', desc: 'Code/Texte prüfen' },
{ id: 'manager', label: 'Manager', desc: 'Planen & koordinieren' },
]
const allModels = $derived(((statusStore.value?.models) || []) as any[])
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
// ---- Chat ----
let chatModel = $state('')
let chatMsg = $state('')
let chatReply = $state('')
let chatLoading = $state(false)
const chatModels = $derived(allModels.filter((m: any) => !m.incomplete))
async function sendChat() {
const model = chatModel || chatModels[0]?.name
if (!model || !chatMsg.trim()) return
chatLoading = true; chatReply = '(wecke Modell, kann beim Laden kurz dauern…)'
try {
const r = await api('/api/chat', { method: 'POST', body: JSON.stringify({ model, message: chatMsg }) })
chatReply = r.reply
} catch (e: any) { chatReply = 'Fehler: ' + e.message }
chatLoading = false; refresh()
}
const DEFAULT_TTL = 300
// ---- Config Modal ----
let cfgOpen = $state(false)
let cfgModel = $state<any>(null)
let cfgCtx = $state(8192)
let cfgAlwaysActive = $state(false)
function openConfig(name: string) {
cfgModel = allModels.find((m: any) => m.name === name)
if (!cfgModel) return
cfgCtx = cfgModel.meta?.ctx || 8192
cfgAlwaysActive = (cfgModel.ttl ?? DEFAULT_TTL) >= 99999
cfgOpen = true
}
async function saveConfig() {
const ttl = cfgAlwaysActive ? 99999 : DEFAULT_TTL
try {
await api('/api/update_model', { method: 'POST', body: JSON.stringify({ alias: cfgModel.name, ctx: cfgCtx, ttl }) })
toast('Gespeichert — aktiv beim nächsten Modell-Start.')
cfgOpen = false; refresh()
} catch (e: any) { toast(e.message, true) }
}
// ---- Role Modal ----
let roleOpen = $state(false)
let roleModelName = $state('')
let roleCustom = $state('')
let roleCurrentRole = $state('')
function openRole(name: string) {
const m = allModels.find((m: any) => m.name === name)
roleModelName = name
roleCurrentRole = m?.role || ''
roleCustom = ''
roleOpen = true
}
async function saveRole(role: string) {
try {
await api('/api/set_role', { method: 'POST', body: JSON.stringify({ alias: roleModelName, role }) })
toast(role ? `Rolle gesetzt: ${role}` : 'Rolle entfernt.')
roleOpen = false; refresh()
} catch (e: any) { toast(e.message, true) }
}
// ---- Actions ----
async function unloadOne(name: string) {
if (!await confirmModal({ title: `„${name}" entladen?`, body: 'Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.', confirmLabel: 'Entladen' })) return
try { await api('/api/unload?model=' + encodeURIComponent(name), { method: 'POST' }); toast('Entladen: ' + name); setTimeout(refresh, 600) }
catch (e: any) { toast(e.message, true) }
}
async function deleteModel(name: string) {
if (!await confirmModal({ title: `„${name}" wirklich löschen?`, body: 'Das Modell wird aus der Konfiguration ausgetragen und seine Dateien werden von der Festplatte gelöscht, um Speicher freizugeben. <b>Das lässt sich nicht rückgängig machen</b> — später brauchst du dafür einen neuen Download.', confirmLabel: 'Endgültig löschen', danger: true })) return
try {
const r = await api('/api/delete_model', { method: 'POST', body: JSON.stringify({ alias: name }) })
toast(r.note || 'Gelöscht: ' + name); setTimeout(refresh, 600)
} catch (e: any) { toast(e.message, true) }
}
function capChips(cap: any) {
if (!cap) return ''
const chips: string[] = []
if (cap.coder) chips.push('<span class="tag code">Code</span>')
if (cap.vision) chips.push('<span class="tag img">Bild</span>')
if (cap.reasoning) chips.push('<span class="tag" style="border-color:#a78bfa;color:#a78bfa">🧠 Reason</span>')
if (cap.moe) chips.push(`<span class="tag" style="border-color:#f59e0b;color:#f59e0b">🧩 MoE${cap.active_b ? ' ·' + cap.active_b + 'B' : ''}</span>`)
if (cap.tools === 'yes') chips.push('<span class="tag" style="border-color:var(--accent);color:var(--accent)" title="Tool-Calling vom Template/Flag unterstützt">🛠 Tools</span>')
else if (cap.tools === 'likely') chips.push('<span class="tag" style="opacity:.55" title="laut Modell-Familie wahrscheinlich tool-fähig">🛠 Tools?</span>')
if (cap.embedding) chips.push('<span class="tag" style="opacity:.7">🔢 Embed</span>')
if (!cap.coder && !cap.vision && !cap.embedding) chips.unshift('<span class="tag text">Text</span>')
return chips.length ? chips.join(' ') : ''
}
function details(meta: any) {
if (!meta) return ''
const q = meta.quant || '?'
const c = meta.ctx ? Math.round(meta.ctx / 1024) + 'K' : '?'
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : '?'
return `<span class="mono-sm">${q} · ${c} · ${s}</span>`
}
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text); toast('Kopiert: ' + text)
}
</script>
<div id="m-head">
<div class="pagehead">
<div>
<h1>Modelle</h1>
<div class="sub">Deine konfigurierten Modelle — testen, Kontext anpassen oder aus dem Speicher werfen.</div>
</div>
</div>
</div>
<!-- Quick Chat -->
<div class="card">
<div class="card-h"><h3>Schnelltest</h3></div>
<div class="card-sub">Schreib eine Nachricht — das gewählte Modell wird automatisch geweckt.</div>
<label>Modell</label>
<select bind:value={chatModel}>
{#each chatModels as m}<option value={m.name}>{m.name}</option>{/each}
</select>
<label>Nachricht</label>
<textarea bind:value={chatMsg} placeholder='z.B. Erklär mir kurz, was du kannst."'></textarea>
<button class="primary" disabled={chatLoading} onclick={sendChat}>{chatLoading ? '…' : 'Senden'}</button>
{#if chatReply}
<div class="reply" style="margin-top:12px">{chatReply}</div>
{/if}
</div>
<!-- Models Table -->
<div class="card">
<div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span></div>
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt — du musst nichts manuell starten.</div>
{#if !allModels.length}
<div class="empty-c">
<div class="e-t">Noch keine Modelle konfiguriert</div>
<div class="e-s">Hol dir unter „Cookbook" ein passendes Modell.</div>
</div>
{:else}
<table>
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
<tbody>
{#each allModels as m}
{#if m.incomplete}
<tr style="opacity:.9">
<td class="mid" style="font-weight:500">{m.name}<div class="li-sub mono-sm" style="color:var(--warn)">kein Modell hinterlegt</div></td>
<td></td>
<td><span class="mono-sm text-mut">leere Rolle</span></td>
<td><span class="badge">leer</span></td>
<td class="port"></td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" onclick={() => { toast('Lade im Cookbook ein Modell und setze den Alias auf: ' + m.name); document.querySelector(".nav-item[data-view='cookbook']")?.dispatchEvent(new MouseEvent('click')) }}>Modell zuweisen</button>
<button class="ghost del" onclick={() => deleteModel(m.name)}>Löschen</button>
</td>
</tr>
{:else}
<tr>
<td class="mid">
<div style="font-weight:500;display:flex;align-items:center;gap:7px;flex-wrap:wrap">
{m.name}
{#if m.role}<span class="tag" style="background:rgba(45,212,191,.14);color:var(--accent);border-color:rgba(45,212,191,.3)">{m.role}</span>{/if}
</div>
{#if m.meta?.filename}<div class="li-sub mono-sm">{m.meta.filename}</div>{/if}
<div class="li-sub mono-sm" style="margin-top:3px;display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<span class="text-mut">API-Name:</span>
{#each (m.api_ids?.length ? m.api_ids : [m.name]) as id}
<code style="cursor:pointer" title="Klicken zum Kopieren" onclick={() => copyToClipboard(id)}>{id}</code>
{/each}
</div>
</td>
<td>{@html capChips(m.meta?.capabilities)}</td>
<td>{@html details(m.meta)}</td>
<td>{@html badge(m.state, m.download_progress)}</td>
<td class="port">{m.port ?? 'auto'}</td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" onclick={() => openRole(m.name)}>Rolle</button>
<button class="ghost" onclick={() => openConfig(m.name)}>Konfigurieren</button>
<button class="ghost" onclick={() => unloadOne(m.name)}>Entladen</button>
<button class="ghost del" onclick={() => deleteModel(m.name)}>Löschen</button>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
</div>
<!-- Config Modal -->
{#if cfgOpen && cfgModel}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) cfgOpen = false }}>
<div class="modal-card">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => cfgOpen = false}>Schließen</button>
<h3>Modell konfigurieren</h3>
<p class="mono-sm" style="margin:-4px 0 16px">{cfgModel.name}</p>
<label>Kontext-Größe (Tokens) {@html infoDot(CTX_HELP)}</label>
<input type="number" bind:value={cfgCtx}>
{#if cfgModel.meta?.optimal_ctx}
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 10px">
<div style="flex:1">
<div style="font-size:13px">Empfohlen für deine Hardware</div>
<div class="hint" style="margin:4px 0 0">
Optimal ~{Math.round(cfgModel.meta.optimal_ctx / 1024)}k → ~{cfgModel.meta.peak_ram_optimal_gb} GB Spitzenbedarf.<br>
Aktuell ~{Math.round((cfgModel.meta.ctx || 0) / 1024)}k → ~{cfgModel.meta.peak_ram_gb} GB.
</div>
</div>
<button class="ghost" onclick={() => cfgCtx = cfgModel.meta.optimal_ctx}>Optimal übernehmen</button>
</div>
{/if}
<div class="hint" style="margin-bottom:14px">Es läuft immer nur <b>ein</b> Modell gleichzeitig — ein größerer Kontext hier beeinflusst deine anderen Modelle nicht.</div>
<div class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:0 0 14px">
<div style="flex:1">
<div style="font-size:13px;font-weight:500">Immer aktiv halten</div>
<div class="hint" style="margin:3px 0 0">Modell bleibt dauerhaft im RAM — nie automatisch entladen. Ideal für den kleinen Scout (~5 GB), der immer schnell antworten soll.</div>
</div>
<label style="display:flex;align-items:center;gap:8px;margin:0;cursor:pointer;flex:0 0 auto">
<input type="checkbox" bind:checked={cfgAlwaysActive} style="width:18px;height:18px;accent-color:var(--accent);cursor:pointer">
<span class="mono-sm" style="color:{cfgAlwaysActive ? 'var(--accent)' : 'var(--mut)'}">
{cfgAlwaysActive ? 'An (TTL = ∞)' : 'Aus (TTL = 5 min)'}
</span>
</label>
</div>
<button class="primary" style="width:100%;margin-top:6px" onclick={saveConfig}>Speichern</button>
</div>
</div>
{/if}
<!-- Role Modal -->
{#if roleOpen}
<div class="modal-overlay" role="dialog" onclick={e => { if ((e.target as HTMLElement).classList.contains('modal-overlay')) roleOpen = false }}>
<div class="modal-card">
<button class="ghost" style="position:absolute;top:14px;right:14px" onclick={() => roleOpen = false}>Schließen</button>
<h3>Rolle festlegen {@html infoDot(ROLE_HELP)}</h3>
<p class="mono-sm" style="margin:-4px 0 16px">{roleModelName}</p>
<div class="hint" style="margin-bottom:10px">Wähle eine Rolle als zweiten, sprechenden Namen. In deinen Tools funktionieren danach <b>beide</b>: der echte Modellname und die Rolle.</div>
<div class="flex gap-2" style="flex-wrap:wrap;margin-bottom:12px">
{#each ROLES as r}
<button class="{roleCurrentRole === r.id ? 'primary' : 'ghost'}" title={r.desc} style="border-radius:999px"
onclick={() => saveRole(r.id)}>{r.label}</button>
{/each}
</div>
<label>Eigene Rolle (optional)</label>
<input bind:value={roleCustom} placeholder="z.B. uebersetzer">
<div class="btn-row" style="margin-top:14px;display:flex;gap:8px">
<button class="ghost" style="flex:1" onclick={() => saveRole('')}>Rolle entfernen</button>
<button class="primary" style="flex:2" onclick={() => saveRole(roleCustom.trim().toLowerCase())}>Speichern</button>
</div>
</div>
</div>
{/if}
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, getHfToken } from '@core/api.js'
let items = $state<any[]>([])
let upgrades = $state<any[]>([])
let loadingNews = $state(true)
let newsError = $state('')
const fitCls = (l: string) => l === 'perfect' ? 'ok' : l === 'marginal' ? 'warn' : 'bad'
function ago(ts: number) {
const s = Math.max(0, Date.now() / 1000 - ts)
if (s < 60) return s + 's'
if (s < 3600) return Math.floor(s / 60) + 'm'
if (s < 86400) return Math.floor(s / 3600) + 'h'
return Math.floor(s / 86400) + 'd'
}
async function loadNews() {
loadingNews = true; newsError = ''
try {
items = (await api('/api/news')).items || []
} catch (e: any) {
newsError = e.message || 'Fehler beim Laden'
} finally {
loadingNews = false
}
}
async function loadUpgrades() {
try {
upgrades = (await api('/api/cookbook/upgrades')).upgrades || []
} catch {
upgrades = []
}
}
async function installUpgrade(u: any, btn: HTMLButtonElement) {
btn.disabled = true; btn.textContent = 'Starte…'
try {
await api('/api/cookbook/install-model', {
method: 'POST',
body: JSON.stringify({ repo: u.repo, role: u.role, params_b: u.params_b, quant: u.quant, hf_token: getHfToken() })
})
document.querySelector(".nav-item[data-view='activity']")?.dispatchEvent(new MouseEvent('click'))
} catch (e: any) {
btn.disabled = false; btn.textContent = 'Installieren'
alert('Fehler: ' + e.message)
}
}
function refresh() { loadNews(); loadUpgrades() }
onMount(() => { loadNews(); loadUpgrades() })
</script>
<div class="pagehead">
<div>
<h1>News</h1>
<div class="sub">Aktuelles aus der Local-LLM-Welt — aus kuratierten Quellen.</div>
</div>
<button class="ghost" onclick={refresh}>Aktualisieren</button>
</div>
{#if upgrades.length}
<div class="card" style="margin-bottom:18px;border-color:rgba(45,212,191,.25)">
<div class="card-h">
<h3>Für dich: Upgrades</h3>
<span class="meta">{upgrades.length}</span>
</div>
<div class="card-sub">Neuere Modelle, die ein installiertes ersetzen können.</div>
<div class="list">
{#each upgrades as u}
<div class="li" style="align-items:flex-start">
<div class="li-main">
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id" style="font-family:var(--sans);font-weight:500">{u.name}</span>
<span class="fit-badge {fitCls(u.fit.level)}">{u.fit.text}</span>
</div>
<div class="li-sub">Ersetzt dein „{u.old}". {u.why}</div>
</div>
<button class="primary" onclick={(e) => installUpgrade(u, e.currentTarget as HTMLButtonElement)}>
Installieren
</button>
</div>
{/each}
</div>
</div>
{/if}
{#if loadingNews}
<div class="empty" style="text-align:center;padding:40px">Lade News… (erstes Laden kann kurz dauern)</div>
{:else if newsError}
<div class="alert err" style="margin:0">{newsError}</div>
{:else if !items.length}
<div class="empty-c">
<div class="e-t">Keine News geladen</div>
<div class="e-s">Die Quellen sind gerade nicht erreichbar — später nochmal probieren.</div>
</div>
{:else}
<div class="news-grid">
{#each items as n, i}
<a href={n.link} target="_blank" rel="noopener" class="news-card{i === 0 ? ' featured' : ''}">
<div class="news-meta">
<span class="news-src">{n.source}</span>
{#if n.relevant}<span class="news-rel">für dich</span>{/if}
<span class="news-date">{n.ts ? ago(n.ts) : ''}</span>
</div>
<div class="news-title">{n.title}</div>
</a>
{/each}
</div>
{/if}
+277
View File
@@ -0,0 +1,277 @@
<script lang="ts">
import { onMount } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.js'
import { confirmModal, toast, icon, fmtBytes } from '@core/ui.js'
const s = $derived(statusStore.value)
const sys = $derived(systemStore.value)
const allModels = $derived((s?.models || []) as any[])
const swapOk = $derived(s?.swap_ok ?? false)
const LOADED = ['running', 'ready', 'loading', 'starting']
const activeModel = $derived(allModels.find((m: any) => LOADED.includes(m.state)))
const configuredModels = $derived(allModels.filter((m: any) => !m.incomplete))
const heroTitle = $derived(
!s ? 'Verbinde…'
: !swapOk ? 'LLM-Engine offline'
: sys && sys.ram.percent >= 90 ? 'Achtung: Speicher wird knapp.'
: 'Alles läuft rund.'
)
const heroSub = $derived(
!s ? ''
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
: sys && sys.ram.percent >= 90 ? `Arbeitsspeicher bei ${Math.round(sys.ram.percent)} % — eventuell Speicher freigeben.`
: `${configuredModels.length} ${configuredModels.length === 1 ? 'Modell' : 'Modelle'} konfiguriert · ${activeModel ? `„${activeModel.name}" geladen` : 'keines geladen'} · keine Warnungen.`
)
const gpuPct = $derived(() => {
const g = sys?.gpu
if (g && (g.vram.total + g.gtt.total) > 0)
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
return 0
})
let news = $state<any[]>([])
let preloadingModel = $state<string | null>(null)
onMount(async () => {
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
})
async function freeMemory() {
if (!await confirmModal({
title: 'Speicher freigeben?',
body: 'Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. Sie laden beim nächsten Aufruf automatisch neu.',
confirmLabel: 'Speicher freigeben',
})) return
await api('/api/unload', { method: 'POST' })
refresh()
}
async function preload(modelName: string) {
preloadingModel = modelName
try { await api('/api/preload?model=' + encodeURIComponent(modelName), { method: 'POST' }) }
catch (e: any) { toast('Laden fehlgeschlagen: ' + (e.message || 'Engine nicht erreichbar?'), true) }
preloadingModel = null
refresh()
}
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
function go(view: string) {
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
}
function ramColor(pct: number) { return pct >= 90 ? 'red' : pct >= 75 ? '' : 'green' }
function tempColor(t: number | null) { return t == null ? '' : t >= 75 ? 'red' : t >= 55 ? '' : 'green' }
function gpuColor(pct: number) { return pct >= 90 ? 'red' : pct >= 70 ? '' : 'green' }
</script>
<!-- Hero -->
<div class="pagehead" style="margin-bottom:10px">
<div>
<h1 style="display:flex;align-items:center;gap:10px">
{heroTitle}
{#if swapOk && !activeModel}
<span style="font-size:13px;font-weight:400;color:var(--mut)">— bereit für Anfragen</span>
{/if}
</h1>
<div class="sub">{heroSub}</div>
</div>
</div>
<!-- KPI Tiles -->
<div class="tiles">
<div class="kpi {swapOk ? 'green' : 'red'}">
<div class="k-h">
<span class="k-t">LLM-Engine</span>
<span class="k-ic">{@html icon('server')}</span>
</div>
<div class="k-v">{swapOk ? 'Online' : 'Offline'}</div>
<div class="k-s">{s?.swap_url?.replace(/^https?:\/\//, '') ?? '—'}</div>
</div>
<div class="kpi {activeModel ? 'blue' : 'muted'}">
<div class="k-h">
<span class="k-t">Aktives Modell</span>
<span class="k-ic">{@html icon('cpu')}</span>
</div>
<div class="k-v" style="font-size:16px;word-break:break-word">{activeModel ? activeModel.name : 'Keins'}</div>
<div class="k-s">
{#if activeModel}
{activeModel.meta?.ctx ? Math.round(activeModel.meta.ctx / 1024) + 'k ctx' : ''} · {activeModel.state}
{:else}
lädt bei erster Anfrage
{/if}
</div>
</div>
{#if sys}
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
{@const tCol = tempColor(t)}
<div class="kpi {tCol}">
<div class="k-h"><span class="k-t">Temperatur (GPU)</span><span class="k-ic">{@html icon('thermo')}</span></div>
<div class="k-v">{t != null ? `${Math.round(t)}°C` : ''}</div>
<div class="k-s">{t == null ? 'messe…' : t < 55 ? 'kühl' : t < 75 ? 'normal' : '⚠ heiß'}</div>
</div>
{@const free = sys.ram.total - sys.ram.used}
{@const freePct = 100 - sys.ram.percent}
<div class="kpi {freePct < 15 ? 'red' : freePct < 30 ? '' : 'green'}">
<div class="k-h"><span class="k-t">Freier Speicher</span><span class="k-ic">{@html icon('database')}</span></div>
<div class="k-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
<div class="k-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB · {Math.round(freePct)} % frei</div>
</div>
{:else}
<div class="kpi muted"><div class="k-h"><span class="k-t">Temperatur</span></div><div class="k-v"></div><div class="k-s">messe…</div></div>
<div class="kpi muted"><div class="k-h"><span class="k-t">Freier Speicher</span></div><div class="k-v"></div><div class="k-s">messe…</div></div>
{/if}
</div>
<!-- Split: Health + Quickstart -->
<div class="split">
<div class="card">
<div class="card-h"><h3>System-Gesundheit</h3></div>
<div class="card-sub">Auslastung in Echtzeit.</div>
{#if sys}
{@const gpu = gpuPct()}
{#each [
['Prozessor (CPU)', sys.cpu?.percent ?? 0, ''],
['Arbeitsspeicher (RAM)', sys.ram.percent, ramColor(sys.ram.percent)],
['Grafikspeicher (VRAM)', gpu, gpuColor(gpu)]
] as [label, pct, cls]}
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
<div class="meter">
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
<div class="bar {p >= 90 ? 'bad' : p >= 75 ? 'warn' : ''}"><i style="width:{Math.max(2, p)}%"></i></div>
</div>
{/each}
{:else}
<div class="empty">Warte auf Messwerte…</div>
{/if}
</div>
<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>
<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>
<!-- Stack + News -->
<div class="grid grid-2">
<div class="card">
<div class="card-h">
<h3>Dein Stack</h3>
<span class="meta">{configuredModels.length ? configuredModels.length + ' konfiguriert' : ''}</span>
</div>
{#if configuredModels.length}
<div class="list">
{#each configuredModels as m}
{@const isLoaded = LOADED.includes(m.state)}
{@const downloading = m.state === 'downloading'}
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : isLoaded ? 'on' : ''}
{@const statusText = downloading
? `↓ ${m.download_progress != null ? Math.round(m.download_progress) + '%' : '...'}`
: m.state === 'loading' || m.state === 'starting' ? 'lädt…'
: isLoaded ? 'geladen ✓' : 'bereit'}
<div class="li" style="padding:10px 4px">
<span class="li-dot {dot}"></span>
<div class="li-main">
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id" style="{isLoaded ? 'color:var(--accent)' : ''}">{m.name}</span>
{#if m.meta?.caps?.includes('Bild')}<span class="tag img">Bild</span>
{:else if m.meta?.caps?.includes('Code')}<span class="tag code">Code</span>
{:else}<span class="tag text">Text</span>{/if}
{#if m.ttl >= 99999}<span class="chip" style="padding:2px 7px;font-size:11px">∞ aktiv</span>{/if}
</div>
<div class="li-sub mono-sm">
{m.meta?.ctx ? Math.round(m.meta.ctx / 1024) + 'k ctx' : ''}
{m.meta?.quant ? ' · ' + m.meta.quant : ''}
{m.meta?.size_bytes ? ' · ' + (m.meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : ''}
</div>
</div>
<div class="flex items-center gap-2">
<span class="li-meta" style="white-space:nowrap;color:{isLoaded ? 'var(--on)' : 'var(--mut)'}">{statusText}</span>
{#if !isLoaded && !downloading && swapOk}
<button class="ghost" style="padding:3px 9px;font-size:12px;opacity:{preloadingModel === m.name ? '.5' : '1'}"
disabled={preloadingModel === m.name}
onclick={() => preload(m.name)}>
{preloadingModel === m.name ? '…' : 'Laden'}
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="empty-c">
<div class="e-t">Noch keine Modelle</div>
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div>
<button class="primary" style="margin-top:12px" onclick={() => go('cookbook')}>Zum Cookbook →</button>
</div>
{/if}
</div>
<div class="card">
<div class="card-h">
<h3>Top-News</h3>
<span class="meta" style="cursor:pointer" role="button" tabindex="0"
onclick={() => go('news')} onkeydown={e => e.key === 'Enter' && go('news')}>Alle →</span>
</div>
{#if news.length}
<div class="list">
{#each news as n}
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
<div class="li-main">
<div style="font-size:13px;line-height:1.4">{n.title}</div>
<div class="li-sub">{n.source}{n.relevant ? ' · für dich' : ''}</div>
</div>
</a>
{/each}
</div>
{:else}
<div class="empty">News gerade nicht ladbar.</div>
{/if}
</div>
</div>
+262
View File
@@ -0,0 +1,262 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { jobsStore } from '../stores/jobs.svelte.js'
import { api, getToken } from '@core/api.js'
import { esc, toast, confirmModal, promptModal } from '@core/ui.js'
const status = $derived(statusStore.value)
const jobs = $derived(jobsStore.value)
// Console
let ws: WebSocket | null = null
let consoleLogs = $state('Verbinde…\n')
let selectedService = $state('llama-swap')
let consoleEl: HTMLDivElement | undefined
// Active job tracking
let activeJobId = $state<string | null>(null)
let activeJobLabel = $state('')
const notified = new Set<string>()
const activeJob = $derived(activeJobId ? jobs.find((j: any) => j.id === activeJobId) : null)
// Auto-scroll + job finish notification
$effect(() => {
if (!activeJob) return
const j = activeJob as any
const done = j.state === 'done', failed = j.state === 'failed', canceled = j.state === 'canceled'
if ((done || failed || canceled) && !notified.has(j.id)) {
notified.add(j.id)
toast(done ? `✓ ${activeJobLabel} abgeschlossen.` : canceled ? `${activeJobLabel} abgebrochen.` : `✗ ${activeJobLabel} fehlgeschlagen.`, failed)
if (done) setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 800)
}
})
function watchJob(id: string, label: string) {
activeJobId = id
activeJobLabel = label
jobsStore.track(id)
}
// Expose track for use from other panels (server panel tracks its own jobs)
onMount(() => { connectConsole() })
onDestroy(() => { ws?.close() })
function connectConsole() {
ws?.close()
const svc = selectedService
consoleLogs = `Verbinde mit ${svc}…\n`
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`)
ws.onmessage = e => {
consoleLogs += e.data
if (consoleEl) consoleEl.scrollTop = consoleEl.scrollHeight
}
ws.onclose = () => { consoleLogs += '\n— Verbindung getrennt —' }
ws.onerror = () => ws?.close()
}
async function restartService(name: string, title: string, body: string) {
if (!await confirmModal({ title, body, confirmLabel: 'Neustarten' })) return
try {
await api(`/api/service/${name}/restart`, { method: 'POST' })
toast('Neustart ausgelöst: ' + name)
setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 2000)
} catch (e: any) { toast(e.message, true) }
}
async function selfUpdate() {
if (!await confirmModal({ title: 'Mission Control aktualisieren?', body: 'Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu. Die Seite trennt kurz und verbindet von selbst wieder (~10 Sek).' })) return
try { const r = await api('/api/self-update', { method: 'POST' }); toast('Update läuft — Dashboard startet gleich neu.'); watchJob(r.job_id, 'Mission Control Update') }
catch (e: any) { toast(e.message, true) }
}
async function engineUpdate() {
if (!await confirmModal({ title: 'LLM-Engine aktualisieren?', body: 'Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar.' })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für das Engine-Update wird einmalig dein sudo-Passwort gebraucht (für den Update-Befehl).', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Update starten' })
if (pwd === null) return
try { const r = await api('/api/update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('LLM-Engine-Update läuft.'); watchJob(r.job_id, 'LLM-Engine-Update') }
catch (e: any) { toast(e.message, true) }
}
async function unloadAll() {
if (!await confirmModal({ title: 'Grafikspeicher leeren?', body: 'Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.', confirmLabel: 'Leeren' })) return
try { await api('/api/unload', { method: 'POST' }); toast('Grafikspeicher geleert.'); setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 600) }
catch (e: any) { toast(e.message, true) }
}
async function osUpdate() {
if (!await confirmModal({ title: 'OS-Updates installieren?', body: 'Führt <code>apt update & upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität.' })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Installieren' })
if (!pwd) return
try { const r = await api('/api/os-update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('OS-Update gestartet.'); watchJob(r.job_id, 'OS-Update') }
catch (e: any) { toast(e.message, true) }
}
async function rebootServer() {
if (!await confirmModal({ title: 'Server wirklich neu starten?', body: 'Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.', confirmLabel: 'Reboot', danger: true })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für den Reboot wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Jetzt neustarten', danger: true })
if (!pwd) return
try { await api('/api/reboot', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('Reboot ausgelöst — bis gleich.') }
catch (e: any) { toast(e.message, true) }
}
async function checkSwap() {
swapCheckResult = 'prüfe…'
try {
const r = await api('/api/integration/test')
swapCheckResult = r.ok ? `✓ Engine antwortet — ${r.models.length} Modell(e): ${r.models.join(', ')}` : `✗ keine Verbindung: ${r.error}`
swapCheckOk = r.ok
} catch (e: any) { swapCheckResult = '✗ ' + e.message; swapCheckOk = false }
}
let swapCheckResult = $state('')
let swapCheckOk = $state(false)
const swapModels = $derived((status?.models || []) as any[])
const LOADED = ['running', 'ready', 'loading', 'starting']
const loadedModel = $derived(swapModels.find((m: any) => LOADED.includes(m.state)))
// Job progress helpers
function jobPct(j: any) {
if (!j || j.state !== 'running') return null
if (typeof j.progress === 'number') return Math.min(100, j.progress)
const last = (j.log || []).at(-1) || ''
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
return m ? Math.min(100, parseFloat(m[1])) : null
}
function qa(tone: string, title: string, desc: string, fn: () => void, danger = false) {
return { tone, title, desc, fn, danger }
}
</script>
<div class="pagehead">
<div>
<h1>Server &amp; Wartung</h1>
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ganz ohne SSH oder Terminal.</div>
</div>
</div>
<!-- LLM-Engine Card -->
<div class="card">
<div class="card-h">
<h3>LLM-Engine (llama-swap)</h3>
<span class="chip" style="margin-left:auto;{status?.swap_ok
? 'background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a'
: 'background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3);color:#ff9b95'}">
{status?.swap_ok ? '● erreichbar' : '● offline'}
</span>
</div>
<div class="card-sub">Das Herzstück: lädt bei jeder Anfrage automatisch das passende Modell.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Adresse (für deine Tools)</span><span class="kv-v mono-sm">{(status?.swap_url || '—') + '/v1'}</span></div>
<div class="kv-row"><span class="kv-k">Aktuell geladen</span>
<span class="kv-v">{loadedModel ? loadedModel.name + ` (${loadedModel.state})` : 'nichts geladen — lädt bei der nächsten Anfrage'}</span>
</div>
<div class="kv-row"><span class="kv-k">Konfigurierte Modelle</span>
<span class="kv-v">{swapModels.filter((m: any) => !m.incomplete).length || 'keine'}</span>
</div>
</div>
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
<button class="ghost" onclick={checkSwap}>Verbindung prüfen</button>
{#if swapCheckResult}
<span class="mono-sm" style="color:{swapCheckOk ? '#7ee29a' : '#ff9b95'}">{swapCheckResult}</span>
{/if}
</div>
</div>
<!-- Service Actions -->
<div class="card">
<div class="card-h"><h3>Dienste &amp; Applikation</h3></div>
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos — es geht nichts dabei verloren.</div>
<button class="qa" onclick={() => restartService('llama-swap', 'LLM-Engine neustarten?', 'Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder.')}>
<span class="qa-ic teal"></span>
<span class="qa-main"><span class="qa-t">LLM-Engine neustarten</span><span class="qa-s">Startet llama-swap neu. Geladene Modelle laden danach automatisch wieder.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={() => restartService('mission-control', 'Dashboard neustarten?', 'Diese Oberfläche trennt sich kurz und verbindet automatisch wieder.')}>
<span class="qa-ic blue"></span>
<span class="qa-main"><span class="qa-t">Dashboard neustarten</span><span class="qa-s">Startet diese Oberfläche neu (~5 Sek).</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={selfUpdate}>
<span class="qa-ic teal"></span>
<span class="qa-main"><span class="qa-t">Mission Control aktualisieren</span><span class="qa-s">Holt die neueste Version aus Gitea und startet automatisch neu (~10 Sek).</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={engineUpdate}>
<span class="qa-ic blue"></span>
<span class="qa-main"><span class="qa-t">LLM-Engine aktualisieren</span><span class="qa-s">Lädt die neueste llama.cpp-Version (ROCm) für deine GPU.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={unloadAll}>
<span class="qa-ic amber">🗄</span>
<span class="qa-main"><span class="qa-t">Grafikspeicher leeren</span><span class="qa-s">Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.</span></span>
<span class="qa-arrow"></span>
</button>
</div>
<!-- OS Actions -->
<div class="card">
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
<button class="qa" onclick={osUpdate}>
<span class="qa-ic amber"></span>
<span class="qa-main"><span class="qa-t">OS-Updates installieren</span><span class="qa-s">Führt apt update &amp; upgrade aus. Kann ein paar Minuten dauern.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa qa-danger" onclick={rebootServer}>
<span class="qa-ic red"></span>
<span class="qa-main"><span class="qa-t">Server neustarten (Reboot)</span><span class="qa-s">Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline.</span></span>
<span class="qa-arrow"></span>
</button>
</div>
<!-- Active Job Card -->
{#if activeJob}
{@const j = activeJob as any}
{@const done = j.state === 'done'}
{@const failed = j.state === 'failed'}
{@const canceled = j.state === 'canceled'}
{@const terminal = done || failed || canceled}
{@const p = jobPct(j)}
<div class="card">
<div class="card-h">
<h3>Aktueller Vorgang</h3>
{#if !terminal}
<button class="ghost" style="margin-left:auto;padding:3px 12px"
onclick={async () => {
if (await confirmModal({ title: activeJobLabel + ' abbrechen?', body: 'Der laufende Vorgang wird gestoppt.', confirmLabel: 'Abbrechen', danger: true }))
api(`/api/jobs/${j.id}/cancel`, { method: 'POST' })
}}>Abbrechen</button>
{/if}
<span style="margin-left:10px">
{#if done}<span class="badge b-run">✓ fertig</span>
{:else if failed}<span class="badge b-err">✗ fehlgeschlagen</span>
{:else if canceled}<span class="badge">abgebrochen</span>
{:else}<span class="badge b-load">läuft…</span>{/if}
</span>
</div>
<div class="card-sub">{activeJobLabel}{terminal ? (done ? ' — erfolgreich.' : canceled ? ' — abgebrochen.' : ' — fehlgeschlagen.') : ' läuft…'}</div>
{#if p != null && !terminal}
<div class="bar" style="margin-bottom:10px"><i style="width:{p}%"></i></div>
{/if}
<div class="log" style="max-height:280px">{(j.log || []).join('\n')}</div>
</div>
{/if}
<!-- Live Console -->
<div class="card">
<div class="card-h">
<h3>Live-Konsole</h3>
<select bind:value={selectedService} onchange={connectConsole} style="margin:0 0 0 auto;width:240px">
<option value="llama-swap">LLM-Engine (llama-swap)</option>
<option value="mission-control">Dashboard (mission-control)</option>
<option value="system">System (ganzer Server)</option>
</select>
</div>
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
<div class="console" bind:this={consoleEl}>{consoleLogs}</div>
</div>
+656
View File
@@ -0,0 +1,656 @@
export type GuideGroup = {
label: string
tutorials: [string, string][]
}
export const GUIDE_GROUPS: GuideGroup[] = [
{
label: 'Einstieg',
tutorials: [
["Dein erster Workflow — in 3 Schritten", `
<ol class="tut-steps">
<li><b>Modell holen:</b> Tab <b>Cookbook</b> Anwendungsfall wählen (z.B. Coden & Programmieren")
<b>Komplettes Setup installieren</b>. Der Download läuft im Tab <b>Aktivität</b> einfach warten bis der Job grün wird.</li>
<li><b>Verbinden:</b> Tab <b>Verbinden</b> Tool wählen Config-Snippet kopieren und einfügen.
<b>Verbindung testen</b> bestätigt, dass alles klappt.</li>
<li><b>Arbeiten:</b> Im Tool das gewünschte Modell auswählen und loslegen.
Das Modell wird <b>automatisch geladen</b> du musst nichts manuell starten.</li>
</ol>
<p style="margin-top:10px"><b>Nicht sicher welches Tool du nehmen sollst?</b>
Lies den Abschnitt Welches Tool ist das richtige für mich?" weiter unten.</p>`],
["Kontext — das Kurzzeitgedächtnis des Modells", `
<p>Der Kontext ist wie ein Blatt Papier: alles was du mit dem Modell besprochen hast,
steht darauf. Wird es voll, fallen die ältesten Nachrichten raus.</p>
<p><b>Was du meist nicht tun musst:</b> Tools wie OpenCode, Cline und OpenWebUI
verwalten das Kontextfenster automatisch. Ein neuer Chat = leeres Blatt.</p>
<p><b>Wenn der Kontext volläuft</b> (Fehler context length exceeded" oder das Modell vergisst den Anfang):</p>
<ol class="tut-steps">
<li><b>Neuen Chat starten</b> der einfachste Reset.</li>
<li>Vorher: <em>Fasse kurz zusammen was wir bisher erreicht haben"</em> dann diesen Stand in den neuen Chat einfügen.</li>
<li><b>Kontext vergrößern:</b> Tab <b>Modelle</b> <b>Konfigurieren</b> Empfehlung für deine Hardware übernehmen.</li>
</ol>
<p><b>Wichtig:</b> Grafikspeicher leeren" (Tab <b>Server</b>) ist etwas anderes
das wirft das <em>Modell</em> aus dem Speicher, nicht den Gesprächsverlauf.</p>`],
["Auto-Swap — Modelle laden sich von selbst", `
<p>llama-swap lädt automatisch das Modell, das gerade angefragt wird
und entlädt es nach einer Leerlaufzeit wieder. <b>Es läuft immer nur eines gleichzeitig.</b></p>
<p>Du richtest beliebig viele Modelle ein und wählst im Tool das passende.
Das Laden und Entladen passiert unsichtbar im Hintergrund.</p>
<p><b>Warum dauert die erste Antwort manchmal 515 Sekunden?</b><br>
Das Modell wird gerade geladen danach geht es normal weiter.
Die Topbar zeigt <em>[Modellname] lädt"</em> während das passiert.</p>
<p><b>Scout immer sofort verfügbar halten:</b> Tab <b>Modelle</b> Konfigurieren
<em>Immer aktiv halten</em> aktivieren (TTL = ). Das Modell bleibt dann dauerhaft im RAM
und antwortet ohne Wartezeit ideal für den kleinen, schnellen Allrounder.</p>`],
],
},
{
label: 'Tools einrichten',
tutorials: [
["Welches Tool ist das richtige für mich?", `
<p><b>Chat ich will einfach reden, schreiben, fragen:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">Einfachster Einstieg</div>
<div class="tut-cell-bd"> <b>Jan AI</b> Desktop-App, Doppelklick-Installer, sofort loslegen.
Vertraut wie ChatGPT, läuft komplett lokal. Kein Admin-Panel, kein Docker.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Teams / Power-User</div>
<div class="tut-cell-bd"> <b>OpenWebUI</b> viele Features (RAG, Bilder, Plugins, Mehrbenutzer).
Mehr Setup-Aufwand, aber der mächtigste Chat-Client.</div>
</div>
</div>
<p style="margin-top:12px"><b>Coding ich will KI beim Programmieren:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">Simpler Einstieg in VS Code</div>
<div class="tut-cell-bd"> <b>Continue</b> Chat + Autocomplete direkt im Editor, kein Agent-Overhead.
Macht keinen Schritt ohne dich.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Voller Coding-Agent im Editor</div>
<div class="tut-cell-bd"> <b>Cline</b> liest Dateien, schreibt Code, fragt vor jeder Änderung.
VS Code, JetBrains, Zed u.a.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Agent-IDE mit VS Code-Feeling</div>
<div class="tut-cell-bd"> <b>Antigravity</b> Googles agentic IDE (VS Code-Fork), kostenlos.
Multi-Agent, planen + coden + testen in einem.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Autonomer Terminal-Agent</div>
<div class="tut-cell-bd"> <b>OpenCode</b> mächtigster lokaler Agent, lebt im Terminal.
Ideal für bau mir Feature X komplett fertig".</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Schneller Editor, minimalistisch</div>
<div class="tut-cell-bd"> <b>Zed</b> nativer Editor (Rust), eingebautes KI-Panel.
Für Leute die Performance über Features stellen.</div>
</div>
</div>
<p style="margin-top:10px"><b>Faustregel:</b> Fang mit <b>Jan AI</b> zum Chatten an.
Für Coding: <b>Continue</b> als sanfter Einstieg oder gleich <b>Cline/Antigravity</b>
wenn du bereit bist, dem Agenten mehr Kontrolle zu geben.</p>`],
["OpenCode verbinden und die ersten Schritte", `
<p>OpenCode ist installiert? Dann in vier Schritten verbinden:</p>
<ol class="tut-steps">
<li><b>Config-Snippet holen:</b> Tab <b>Verbinden</b> <em>OpenCode</em> auswählen
Snippet kopieren und in <code>~/.config/opencode/opencode.json</code> einfügen.</li>
<li><b>OpenCode starten:</b> Terminal öffnen, <code>opencode</code> eintippen
immer im Projektordner starten, damit der Agent deine Dateien sieht.</li>
<li><b>Modell wählen:</b> <code>/models</code> eintippen <em>llama-swap/coder</em> auswählen.</li>
<li><b>Erste Aufgabe geben</b> z.B.:
<em>Lies die Datei README.md und erkläre mir kurz was dieses Projekt macht."</em></li>
</ol>
<p><b>Die wichtigsten Slash-Commands:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd"><code>/model</code></div>
<div class="tut-cell-bd">Modell wechseln z.B. mitten im Gespräch auf <em>scout</em> für kurze Fragen</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd"><code>/compact</code> · <code>/summarize</code></div>
<div class="tut-cell-bd">Kontext komprimieren wenn er voll wird fasst die Session zusammen und setzt fort</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd"><code>/clear</code> · <code>/new</code></div>
<div class="tut-cell-bd">Neue Session starten komplett leerer Kontext</div>
</div>
</div>
<p>Bevor OpenCode eine Datei ändert, fragt es nach mit <b>y</b> bestätigen oder <b>n</b> ablehnen.</p>
<p><b>Terminal oder Desktop-App?</b> Die Anleitung gilt für beide
der Config-Snippet landet in derselben Datei (<code>~/.config/opencode/opencode.json</code>),
und alle Slash-Commands funktionieren in beiden Versionen gleich.
Unterschied: Im Terminal startest du mit <code>cd ~/mein-projekt && opencode</code>;
in der Desktop-App öffnest du einfach die App und wählst den Projektordner über die GUI.</p>
<p><b>Multi-Agenten (2026):</b> Du kannst in OpenCode mehrere spezialisierte Agenten definieren
z.B. einen <em>review</em>-Agent und einen <em>coder</em>-Agent.
Mit <b>Tab</b> wechselst du zwischen primären Agenten; mit <b>@review</b> rufst du einen Subagenten direkt im Chat auf.
Agenten werden als Markdown-Dateien angelegt: eine Datei <code>review.md</code> im
<code>~/.config/opencode/</code>-Ordner erstellt automatisch den Agenten <em>review</em>.</p>
<p class="tut-sources">Weitere Infos: <a href="https://opencode.ai/docs" target="_blank" rel="noopener">opencode.ai/docs</a> · <a href="https://opencode.ai/docs/agents/" target="_blank" rel="noopener">Agenten-Docs</a></p>`],
["Cline verbinden und nutzen", `
<p>Cline (v3.81, Stand Juni 2026) läuft in: <b>VS Code, JetBrains, Cursor, Windsurf, Zed, Neovim</b>
und als CLI auf macOS/Linux. Verbinden funktioniert überall gleich:</p>
<ol class="tut-steps">
<li>Das Cline-Panel öffnen (Roboter-Icon in der Seitenleiste).</li>
<li>Provider <b>OpenAI Compatible"</b> wählen.</li>
<li>Tab <b>Verbinden</b> <em>Cline / Cursor</em> Basis-URL und Modell-ID kopieren und eintragen.</li>
<li>API-Key: irgendeinen beliebigen Text eingeben (z.B. <code>local</code>) wird nicht geprüft.</li>
</ol>
<p><b>Plan-Modus vs. Act-Modus:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">Plan</div>
<div class="tut-cell-bd">Cline liest und denkt nach erklärt den Plan bevor es handelt.
Billiger (weniger Tokens) und ideal für Anfänger.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Act</div>
<div class="tut-cell-bd">Cline handelt sofort, fragt aber vor jeder Dateiänderung um Erlaubnis.
Schneller für klare Aufgaben.</div>
</div>
</div>
<p><b>Memory Bank (empfohlen):</b> Lege eine Datei <code>projectbrief.md</code> im Projektordner an
Cline liest sie zu Beginn jeder Aufgabe automatisch. Sie ist das Fundament: Projektziel, Tech-Stack,
wichtigste Regeln. Ergänzend: <code>activeContext.md</code> für den aktuellen Fokus,
<code>progress.md</code> für den Stand.</p>
<p><b>.clinerules:</b> Für größere Projekte kannst du statt einer einzelnen Datei ein ganzes
Verzeichnis <code>.clinerules/</code> anlegen jede Datei darin gilt als separate Regel.
Achtung: Jedes Zeichen in .clinerules verbraucht Kontext kurze, präzise Regeln schlagen lange Listen.</p>
<p><b>Ausgaben-Limit (seit April 2026):</b> In Settings Spending Limits kannst du ein
Tages- oder Monats-Budget setzen verhindert dass ein laufender Agent unbemerkt viel Token verbraucht.</p>
<p class="tut-sources">Weitere Infos: <a href="https://cline.bot" target="_blank" rel="noopener">cline.bot</a> · <a href="https://docs.cline.bot/best-practices/memory-bank" target="_blank" rel="noopener">Memory Bank Docs</a></p>`],
["Jan AI — einfachstes Chat-Interface", `
<p><b>Jan AI</b> ist die einfachste Art, lokal mit KI zu chatten
Desktop-App, kein Terminal, kein Docker, kein Admin-Panel.</p>
<ol class="tut-steps">
<li><b>Jan AI installieren:</b> <a href="https://jan.ai" target="_blank" rel="noopener">jan.ai</a>
Download Doppelklick-Installer (Windows/macOS/Linux).</li>
<li><b>Remote-Modell hinzufügen:</b> In der linken Leiste auf das Hub-Icon
oben rechts <b>+ Remote-Modell</b>.</li>
<li><b>Verbindung eintragen:</b> Tab <b>Verbinden</b> <em>Jan AI</em> auswählen
Basis-URL und Modell-ID kopieren und in Jan einfügen.</li>
<li>API-Key: irgendeinen Text (z.B. <code>local</code>). Speichern.</li>
</ol>
<p>Danach erscheint das Modell in der Modell-Liste auswählen und loslegen.</p>
<p><b>Gut für:</b> Chatten, Texte schreiben, übersetzen, Fragen stellen
genauso wie ChatGPT, aber 100% lokal und privat. Bilder mitschicken klappt auch
sobald ein Vision-Modell eingetragen ist.</p>
<p class="tut-sources">Download &amp; Docs: <a href="https://jan.ai" target="_blank" rel="noopener">jan.ai</a> · <a href="https://www.jan.ai/docs" target="_blank" rel="noopener">jan.ai/docs</a></p>`],
["Continue — Coding-Hilfe in VS Code ohne Agent-Overhead", `
<p><b>Continue</b> ist die einfachste Coding-Erweiterung für VS Code und JetBrains
kein autonomer Agent, sondern ein KI-Assistent der nur auf Anfrage handelt.</p>
<ol class="tut-steps">
<li><b>Extension installieren:</b> VS Code Extensions Continue" suchen Installieren.<br>
Oder: <a href="https://continue.dev" target="_blank" rel="noopener">continue.dev</a></li>
<li><b>Config holen:</b> Tab <b>Verbinden</b> <em>Continue</em> auswählen
Snippet kopieren und in <code>~/.continue/config.json</code> einfügen.</li>
<li>Continue in VS Code neu starten im Chat-Panel Modell wählen loslegen.</li>
</ol>
<p><b>Was Continue kann:</b></p>
<ul class="tut-steps" style="list-style:disc">
<li><b>Chat:</b> Frage direkt im Editor markiere Code und drücke <code>Strg+L</code></li>
<li><b>Autocomplete:</b> Ergänzt Code während du tippst (wie Copilot)</li>
<li><b>Inline-Edit:</b> Markieren <code>Strg+I</code> Refactore das zu TypeScript"</li>
</ul>
<p><b>Unterschied zu Cline:</b> Continue macht nichts von alleine du steuerst jeden Schritt.
Ideal als erster Einstieg bevor man mit vollen Agenten arbeitet.</p>
<p class="tut-sources">Weitere Infos: <a href="https://continue.dev" target="_blank" rel="noopener">continue.dev</a>
Hinweis: 2026 von Cursor akquiriert, weiterhin kostenlos &amp; open source.</p>`],
["Antigravity — Googles agentic IDE", `
<p><b>Antigravity</b> ist Googles agent-first IDE ein Fork von VS Code, der Planung,
Coding, Testing und Deployment in einem Workflow zusammenführt. Kostenlos für Einzelpersonen.</p>
<ol class="tut-steps">
<li><b>Antigravity installieren:</b>
<a href="https://antigravityide.net" target="_blank" rel="noopener">antigravityide.net</a>
Download läuft wie VS Code.</li>
<li><b>Eigenen Endpoint einrichten:</b> Settings Models <b>Custom Endpoint</b> + hinzufügen.</li>
<li><b>Verbindung eintragen:</b> Tab <b>Verbinden</b> <em>Antigravity</em> auswählen
Basis-URL und Modell-ID kopieren und eintragen.</li>
<li>API-Key: beliebiger Text (z.B. <code>local</code>). Danach das Modell aus der Liste wählen.</li>
</ol>
<p><b>Was Antigravity besonders macht:</b></p>
<ul class="tut-steps" style="list-style:disc">
<li><b>Agent-First:</b> Statt Code-Completion steht der Agent im Mittelpunkt
du beschreibst, er plant und setzt um</li>
<li><b>Multi-Agent:</b> Mehrere Agenten arbeiten parallel an verschiedenen Teilen</li>
<li><b>Manager-Oberfläche:</b> Übersicht über alle laufenden Agenten-Sessions</li>
<li><b>VS Code-kompatibel:</b> Extensions und Keybindings funktionieren wie gewohnt</li>
</ul>
<p><b>Wann Antigravity statt OpenCode?</b> Wenn du lieber eine grafische IDE als ein Terminal magst
und trotzdem volle Agenten-Kontrolle willst. OpenCode ist mächtiger im Terminal-Flow;
Antigravity ist komfortabler für Umsteiger aus VS Code.</p>
<p class="tut-sources">Weitere Infos: <a href="https://antigravityide.net" target="_blank" rel="noopener">antigravityide.net</a> ·
<a href="https://developers.googleblog.com/build-with-google-antigravity-our-new-agentic-development-platform/" target="_blank" rel="noopener">Google Dev Blog</a></p>`],
["OpenWebUI — für Teams und Power-User", `
<p>OpenWebUI ist der mächtigste lokale Chat-Client aber auch der komplexeste.
Empfohlen wenn du mehr als ein einfaches Chat-Interface brauchst.</p>
<p><b>Was OpenWebUI besonders kann (gegenüber Jan AI):</b></p>
<ul class="tut-steps" style="list-style:disc">
<li>RAG: eigene Dokumente hochladen, KI liest und beantwortet Fragen dazu</li>
<li>Mehrbenutzer: eigene Accounts für verschiedene Personen im Heimnetz</li>
<li>Plugins und Agenten: erweiterbar mit Tools und Custom-Pipelines</li>
<li>Bilder generieren (mit Stable Diffusion), Sprachausgabe, Web-Suche</li>
</ul>
<p><b>Verbinden</b> (braucht Docker oder eine lokale Installation):</p>
<ol class="tut-steps">
<li>OpenWebUI im Browser öffnen <b>Admin Panel</b> <b>Connections</b>.</li>
<li><b>+ OpenAI-Verbindung</b> Tab <b>Verbinden</b> <em>OpenWebUI</em> Basis-URL kopieren.</li>
<li>API-Key: <code>dummy-key</code>. Speichern.</li>
</ol>
<p>Danach Modell auswählen und loslegen. Für einfaches Chatten: <b>Jan AI</b> ist einfacher.
OpenWebUI lohnt sich ab dem Moment, wo du mehr als nur Chat willst.</p>
<p class="tut-sources">Weitere Infos: <a href="https://openwebui.com" target="_blank" rel="noopener">openwebui.com</a></p>`],
],
},
{
label: 'Agenten meistern',
tutorials: [
["Was ein Agent tut — und was er nicht kann", `
<p>Ein nacktes Modell antwortet auf Text das war's.
Ein <b>Agent</b> ist das Programm drumherum, das das Modell in eine <b>Handlungsschleife</b> bringt:</p>
<p style="text-align:center;padding:8px 0;font-family:monospace;font-size:12.5px;color:var(--accent)">
Planen Werkzeug nutzen Ergebnis prüfen nächsten Schritt planen
</p>
<p>Konkret: Datei lesen Code verstehen Änderung schreiben Test ausführen Fehler beheben
ohne dass du jeden Schritt anstoßen musst.</p>
<p><b>Was Agenten gut können:</b></p>
<ul class="tut-steps" style="list-style:disc">
<li>Features über mehrere Dateien implementieren</li>
<li>Bugs finden und in ihrem Kontext beheben</li>
<li>Code refactoren, Dokumentation schreiben, Tests ergänzen</li>
<li>Recherchieren und das Ergebnis direkt umsetzen (mit MCP)</li>
<li>Parallele Teilaufgaben an Subagenten delegieren (OpenCode 2026)</li>
</ul>
<p><b>Was Agenten schlecht können:</b></p>
<ul class="tut-steps" style="list-style:disc">
<li>Kreative Richtungsentscheidungen ohne klare Vorgabe treffen</li>
<li>Sehr komplexe, undokumentierte Systeme ohne Kontext verstehen</li>
<li>Fehler in externer Hardware oder fremden APIs beheben</li>
</ul>
<p><b>Faustregel:</b> Je klarer die Aufgabe, desto besser das Ergebnis. Unklarer Auftrag unklares Ergebnis.</p>`],
["Einen Agenten richtig briefen", `
<p>Der Unterschied zwischen es passiert nichts Sinnvolles" und „es funktioniert auf Anhieb"
ist meistens die Qualität des Auftrags.</p>
<p><b>Schlecht Gut:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd bad">Mach meine App besser."</div>
<div class="tut-cell-bd"> <em>Die Login-Seite in <code>src/pages/Login.tsx</code> zeigt
bei falschem Passwort gar keinen Fehler an. Füge unter dem Passwortfeld eine rote Fehlermeldung
ein wenn die API 401 zurückgibt."</em></div>
</div>
<div class="tut-row">
<div class="tut-cell-hd bad">Erkläre mir Python."</div>
<div class="tut-cell-bd"> <em>Ich bin Anfänger. Erkläre mir in 5 Schritten wie ich eine CSV-Datei
in Python einlese und die Summe einer Spalte berechne mit echtem Code-Beispiel."</em></div>
</div>
<div class="tut-row">
<div class="tut-cell-hd bad">Mach das schneller."</div>
<div class="tut-cell-bd"> <em>Die Funktion <code>loadUsers()</code> in <code>src/api/users.ts</code>
braucht 3 Sekunden. Die Daten kommen vom Backend. Prüfe ob Caching helfen würde und schlage eine
konkrete Lösung vor."</em></div>
</div>
</div>
<p style="margin-top:12px"><b>Die 3 Zutaten für einen guten Auftrag:</b></p>
<ol class="tut-steps">
<li><b>WO:</b> Welche Datei / welcher Ordner / welches Feature</li>
<li><b>WAS:</b> Was ist das genaue Problem oder Ziel</li>
<li><b>WIE:</b> Welches Ergebnis erwartest du</li>
</ol>
<p><b>Trick:</b> Wenn du nicht sicher bist ob der Auftrag klar genug ist, frag den Agenten:
<em>Hast du alles was du brauchst, oder fehlt dir etwas?"</em></p>`],
["MCP — deinem Agenten echte Werkzeuge geben", `
<p><b>MCP</b> (Model Context Protocol) ist ein einheitlicher Stecker", über den ein Agent
zusätzliche <b>Werkzeuge</b> bekommt. Ohne MCP kann er nur Text lesen und schreiben.
Mit MCP kann er z.B.:</p>
<ul class="tut-steps" style="list-style:disc">
<li>Im Web suchen (Brave Search, Tavily)</li>
<li>GitHub Issues lesen und kommentieren</li>
<li>Deine Datenbank abfragen</li>
<li>Screenshots machen, E-Mails lesen, Kalender verwalten</li>
</ul>
<p><b>Wie MCPs eingebunden werden</b> (Beispiel für OpenCode):</p>
<p>In der Konfigurationsdatei (<code>~/.config/opencode/opencode.json</code>) einen
<code>"mcp"</code>-Block hinzufügen:</p>
<pre class="tut-code">{
"mcp": {
"brave-search": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": { "BRAVE_API_KEY": "dein-key" }
}
}
}</pre>
<p>Danach OpenCode neu starten der Agent nutzt das Werkzeug automatisch wenn er es braucht.</p>
<p><b>Empfohlene MCPs 2026 nach Anwendungsfall:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">Web-Suche</div>
<div class="tut-cell-bd"><b>brave-search</b> oder <b>tavily</b> recherchieren, aktuelle Infos holen</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">GitHub</div>
<div class="tut-cell-bd"><b>github</b> meistinstallierter MCP überhaupt. Issues, PRs, Commits, Repos direkt im Chat verwalten</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Lokale Dateien</div>
<div class="tut-cell-bd"><b>filesystem</b> meistgenutzter MCP überhaupt. Sichere Dateizugriffe auf beliebige Ordner</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Browser</div>
<div class="tut-cell-bd"><b>playwright</b> Browser-Automatisierung und UI-Tests. Agent öffnet Seiten, klickt, prüft</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Gedächtnis</div>
<div class="tut-cell-bd"><b>memory / mem0</b> persistentes Gedächtnis über Sessions hinweg. Agent erinnert sich an deine Präferenzen und Projekt-Kontext auch in neuen Chats</div>
</div>
</div>
<p style="margin-top:8px">Über <b>36.000 MCPs</b> sind mittlerweile verfügbar (Stand Juni 2026).
Fang mit filesystem + github an die decken 80% der täglichen Arbeit ab.</p>
<p class="tut-sources"><b>Wo MCPs finden:</b>
<a href="https://mcp.run" target="_blank" rel="noopener">mcp.run</a> ·
<a href="https://smithery.ai" target="_blank" rel="noopener">smithery.ai</a> ·
<a href="https://modelcontextprotocol.io/servers" target="_blank" rel="noopener">modelcontextprotocol.io/servers</a>
oder fragen: <em>Welche MCPs gibt es für [dein Werkzeug]?"</em></p>
<p><b>Merksatz:</b> Fehlt dem Agenten eine <em>Fähigkeit</em> MCP.
Soll er etwas nur <em>besser machen</em> Skill (nächster Abschnitt).</p>`],
["Skills & Rules — Agenten Gewohnheiten beibringen", `
<p><b>Skills</b> (auch <em>Rules</em>, <em>AGENTS.md</em> oder <em>Instructions</em> genannt)
sind kurze Texte, die dem Agenten beibringen wie er in deinem Projekt arbeiten soll
immer, ohne dass du es jedes Mal neu sagen musst.</p>
<p><b>Beispiel</b> eine einfache AGENTS.md:</p>
<pre class="tut-code">Schreib alle Variablen in camelCase.
Kommentare immer auf Deutsch.
Tests immer mit describe/it-Blöcken (Jest).
Importiere nichts was nicht schon in package.json steht.
Bevor du Dateien änderst: erkläre kurz was du vorhast.</pre>
<p>Der Agent liest das vor jeder Aufgabe und hält sich daran.</p>
<p><b>Wo die Datei ablegen:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">OpenCode</div>
<div class="tut-cell-bd"><code>AGENTS.md</code> im Projektordner (pro Projekt)<br>
<code>~/.config/opencode/AGENTS.md</code> (global, gilt überall)</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Cline</div>
<div class="tut-cell-bd"><code>.clinerules</code> im Projektordner<br>
<code>~/.clinerules</code> (global)</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Zed</div>
<div class="tut-cell-bd">Zed Settings Assistant System Prompt</div>
</div>
</div>
<p style="margin-top:10px"><b>Fertige Skills finden:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd"><a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a></div>
<div class="tut-cell-bd">Von Vercel gestartet (Jan 2026) das npm für Agent-Skills. Funktioniert mit Claude Code, OpenCode, Cursor u.v.m. One-Command-Installer.
<b>Hinweis:</b> Community-Inhalte ohne formale Prüfung Skill vor dem Einsatz kurz lesen.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd"><a href="https://skillhub.ai" target="_blank" rel="noopener">SkillHub</a></div>
<div class="tut-cell-bd">KI-bewertete Skills (S/A/B-Ranking nach Qualität, Klarheit, Wirkung). Kleinerer Katalog, höhere Signal-Qualität.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd"><a href="https://cursor.directory" target="_blank" rel="noopener">cursor.directory</a></div>
<div class="tut-cell-bd">Vorgefertigte Rules für React, Vue, Python, Go etc. direkt als .clinerules oder AGENTS.md nutzbar.</div>
</div>
</div>
<p style="margin-top:8px"><b>Sicherheitshinweis:</b> Ein Snyk-Audit (2026) fand in 13,4% der Community-Skills
kritische Probleme (Prompt Injection, exponierte Secrets). Regel: Skill-Datei kurz lesen bevor du sie einsetzt.</p>
<p style="margin-top:8px">Oder einfach fragen: <em>Schreib mir eine AGENTS.md für ein React + TypeScript Projekt mit Tailwind."</em></p>`],
["Die Persona — Rolle, Beziehung & Onboarding", `
<p>Eine Persona hat <b>zwei Dimensionen</b> beide sind wichtig:</p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">Was der Agent tut</div>
<div class="tut-cell-bd">Fokus, Fachgebiet, Grenzen die klassische Rollenbeschreibung.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Wie er mit dir umgeht</div>
<div class="tut-cell-bd">Ton, Proaktivität, Feedback-Stil antwortet er kurz oder ausführlich?
Hakt er nach oder liefert er einfach? Spricht er Fehler direkt an oder verpackt er sie?</div>
</div>
</div>
<p style="margin-top:10px">Ein Agent der technisch gleich konfiguriert ist, verhält sich komplett anders
wenn er weiß: <em>Du willst kurze Antworten ohne Erklärung.</em> Oder: <em>Du lernst noch
ermutige mich und erkläre immer warum.</em></p>
<p><b>Das vollständige Persona-Template:</b></p>
<pre class="tut-code">Du bist [Name], [Rolle].
Fokus: [Was du hauptsächlich tust]
Wie du kommunizierst: [Stil direkt/erklärend/ermutigend, kurze oder ausführliche Antworten]
Wie du mit mir umgehst: [Proaktivität, Rückfragen, Feedback-Stil, Fehlerkultur]
Sprache: Immer auf Deutsch. Code-Kommentare auf Englisch.
Grenzen: [Was du nicht tust, was du nachfragst statt zu raten]</pre>
<p><b>Beispiel 1 Coding-Assistent (direkt, erfahren):</b></p>
<pre class="tut-code">Du bist Alex, ein erfahrener Senior-Entwickler spezialisiert auf TypeScript und React.
Fokus: Code schreiben, reviewen, erklären immer mit konkreten Beispielen.
Wie du kommunizierst: direkt, pragmatisch. Keine Erklärung wenn der Code für sich spricht.
Wie du mit mir umgehst: Du fragst nach bevor du loslegst wenn die Aufgabe unklar ist.
Fehler sprichst du klar an aber ohne Schuldzuweisungen. Du machst Gegenvorschläge
wenn du eine bessere Lösung siehst, aber respektierst meine Entscheidung.
Sprache: Deutsch. Code-Kommentare auf Englisch.
Grenzen: Kein Code ohne Datei-Kontext. Keine neuen Dependencies ohne Rückfrage.</pre>
<p><b>Beispiel 2 Lernbegleiter (geduldig, ermutigend):</b></p>
<pre class="tut-code">Du bist Sam, eine geduldige Lernbegleiterin für Programmier-Anfänger.
Fokus: Konzepte erklären, Fehler korrigieren, Schritt für Schritt führen.
Wie du kommunizierst: freundlich, mit Analogien aus dem Alltag. Fachbegriffe sofort erklären.
Wie du mit mir umgehst: Du lobst Fortschritte, auch kleine. Fehler sind Lernchancen
du zeigst was falsch war und warum, ohne zu urteilen. Du fragst ob ich es verstanden
habe bevor du weitermachst.
Sprache: Immer auf Deutsch. Niemals Englisch ohne Übersetzung.</pre>
<hr style="border:none;border-top:1px solid var(--line);margin:18px 0">
<p><b>Der schnellste Weg: Onboarding-Interview</b></p>
<p>Statt die Persona selbst auszufüllen lass den Agenten <em>dich</em> befragen.
Starte einen frischen Chat und gib ihm diesen Prompt:</p>
<pre class="tut-code">Ich möchte dich in mein Projekt und meinen Arbeitsstil einarbeiten.
Führe mit mir ein kurzes Interview durch stelle mir 68 Fragen,
eine nach der anderen um zu verstehen:
1. Wer ich bin und wie ich am liebsten kommuniziere
2. Was unser Projekt ist und welches Ziel es hat
3. Was ich gut kann und wobei ich Unterstützung brauche
4. Meine Arbeitsgewohnheiten und Präferenzen
5. Was mir wichtig ist und was mich stört
Wenn du alle Antworten hast, erstelle daraus:
- Eine vollständige AGENTS.md als dauerhaftes Fundament
- Einen Satz der zusammenfasst wer ich für dich bin</pre>
<p>Das Ergebnis: eine maßgeschneiderte Persona die <em>wirklich</em> zu dir passt
weil du sie nicht selbst formulieren musstest, sondern im Gespräch entstanden ist.</p>
<p><b>Wo eintragen:</b></p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">Für ein Projekt</div>
<div class="tut-cell-bd">Erste Zeilen der <code>AGENTS.md</code> im Projektordner.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Global (überall)</div>
<div class="tut-cell-bd"><code>~/.config/opencode/AGENTS.md</code> (OpenCode) ·
Settings System Prompt (OpenWebUI)</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">Nur dieser Chat</div>
<div class="tut-cell-bd"><em>Ab jetzt bist du "</em> direkt eintippen.</div>
</div>
</div>
<p class="tut-sources" style="margin-top:10px">Fertige Personas: <a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a> · <a href="https://cursor.directory" target="_blank" rel="noopener">cursor.directory</a></p>`],
["AGENTS.MD & SAVEPOINT.MD — Gedächtnis für den Agenten", `
<p>Jedes Mal wenn du einen neuen Chat startest, weiß der Agent <b>nichts</b> über dein Projekt.
Diese zwei Dateien lösen das Problem.</p>
<p><b>AGENTS.md ist 2026 der plattformübergreifende Standard</b> nativ gelesen von:
OpenCode, Claude Code, Codex CLI, Cursor, Aider, Devin, GitHub Copilot, Gemini CLI, Windsurf, Amazon Q.
Eine Datei, alle Tools.</p>
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">AGENTS.md</div>
<div class="tut-cell-bd"><b>Was das Projekt ist</b> Tech-Stack, Konventionen,
Struktur. Wird vor jeder Aufgabe gelesen. Einmal schreiben, immer wirksam.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">SAVEPOINT.md</div>
<div class="tut-cell-bd"><b>Wo wir gerade stehen</b> aktueller Stand, offene Punkte,
nächste Schritte. Wird am Ende einer Session aktualisiert und zu Beginn der nächsten gelesen.</div>
</div>
</div>
<p style="margin-top:14px"><b>AGENTS.MD Vorlage:</b></p>
<pre class="tut-code"># Projekt: [Name]
## Persona
Du bist [Name], [Rolle]. [Stil, Sprache, Grenzen]
## Tech-Stack
- Frontend: [z.B. React 18, TypeScript, Tailwind CSS]
- Backend: [z.B. FastAPI, Python 3.12]
- Datenbank:[z.B. PostgreSQL via Prisma]
## Konventionen
- Variablen: camelCase (JS) / snake_case (Python)
- Kommentare: auf Deutsch
- Commits: konventionell (feat/fix/chore/docs)
- Tests: immer mit describe/it-Blöcken
## Projektstruktur
src/
components/ UI-Komponenten (eine Datei = eine Komponente)
pages/ Seiten / Routes
api/ Backend-Calls und Typen
## Regeln
- Vor jeder Dateiänderung kurz erklären was geplant ist
- Keine neuen Dependencies ohne Rückfrage hinzufügen
- Bei Unklarheiten fragen, nicht raten
<!-- Ziel: 200500 Zeilen. AGENTS.md wird in jeden Kontext geladen —
kürzer = mehr Platz für Code und Gespräch. --></pre>
<p style="color:var(--mut);font-size:12px;margin:4px 0 8px">
<b>Größe:</b> 200500 Zeilen sind der empfohlene Sweet Spot (Stand 2026).
AGENTS.md wird in jeden Kontext geladen je kürzer, desto mehr Platz bleibt für Code und Gespräch.</p>
<p><b>SAVEPOINT.MD Vorlage:</b></p>
<pre class="tut-code"># Savepoint [Datum / Uhrzeit]
## Aktueller Stand
[12 Sätze: wo steht das Projekt gerade?]
## Zuletzt erledigt
- [Aufgabe]
- [Aufgabe]
## In Arbeit / halbfertig
- [Was noch offen ist welche Datei, welcher Schritt fehlt noch]
## Als nächstes
1. [Konkrete nächste Aufgabe]
2. [Übernächste Aufgabe]
## Wichtige Entscheidungen
- [Was entschieden wurde]: [Warum]
## Bekannte Probleme
- [Problem]: [Was bekannt ist, noch nicht gelöst]</pre>
<p><b>Workflow:</b></p>
<ol class="tut-steps">
<li><b>Session starten:</b> <em>Lies AGENTS.md und SAVEPOINT.md und mach weiter."</em></li>
<li><b>Session beenden:</b> <em>Aktualisiere SAVEPOINT.md mit dem aktuellen Stand."</em></li>
</ol>
<p><b>Tipp:</b> Lass den Agenten die SAVEPOINT.md selbst schreiben er sieht was er getan hat.
Du prüfst nur ob der Stand stimmt und ergänzt bei Bedarf.</p>
<p class="tut-sources">Vorlagen &amp; Inspiration: <a href="https://skills.sh" target="_blank" rel="noopener">skills.sh</a> · <a href="https://opencode.ai/docs" target="_blank" rel="noopener">opencode.ai/docs</a></p>`],
],
},
{
label: 'Wenn etwas nicht klappt',
tutorials: [
["Häufige Probleme & Lösungen", `
<div class="tut-table">
<div class="tut-row">
<div class="tut-cell-hd">🔴 Engine offline, rotes Licht in der Topbar</div>
<div class="tut-cell-bd">Tab <b>Server</b> LLM-Engine-Karte <b>Neustart</b>.
Warte 1015 Sekunden danach sollte die Verbindung wieder grün sein.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd"> Modell lädt länger als 5 Minuten</div>
<div class="tut-cell-bd">Tab <b>Aktivität</b> prüfen: läuft ein Download-Job?
Der muss erst abgeschlossen sein bevor das Modell ladbar ist.
Bei Fehler Job-Zeile aufklappen, Log lesen.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">💬 context length exceeded" Fehler im Tool</div>
<div class="tut-cell-bd">Neuen Chat starten. Vorher: <em>Fasse kurz zusammen was wir
erreicht haben"</em> eingeben und diesen Text in den neuen Chat mitnehmen.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">🌐 Modell antwortet immer auf Englisch</div>
<div class="tut-cell-bd">Am Anfang der Unterhaltung schreiben: <em>Antworte immer auf Deutsch."</em>
Für dauerhaft: eine <code>AGENTS.md</code> / <code>.clinerules</code> mit dieser Anweisung anlegen
(siehe Abschnitt Skills & Rules).</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">🔌 Tool findet keine Modelle / Verbindung schlägt fehl</div>
<div class="tut-cell-bd">Tab <b>Verbinden</b> <b>Verbindung testen</b>. Wenn Fehler:
läuft llama-swap? (Tab Server). URL korrekt? Muss <code>http://</code> (nicht https) sein,
Port <b>8080</b>. Über Proxy? LAN-IP direkt nutzen: <code>http://192.168.178.151:8080/v1</code></div>
</div>
<div class="tut-row">
<div class="tut-cell-hd"> Download schlägt fehl / bricht ab</div>
<div class="tut-cell-bd">Tab <b>Aktivität</b> Job-Zeile aufklappen Log lesen.
Häufigste Ursache: HuggingFace braucht einen Token für dieses Modell (gated model).
Einstellungen (Zahnrad-Icon oben) <b>HuggingFace-Token</b> eintragen.</div>
</div>
<div class="tut-row">
<div class="tut-cell-hd">🔄 Seite lädt / Dashboard hängt komplett</div>
<div class="tut-cell-bd">Strg+Shift+R (Hard Reload). Wenn das nicht hilft:
Tab <b>Server</b> Mission Control <b>Neu starten</b>.</div>
</div>
</div>`],
],
},
]
export const CONCEPTS: [string, string][] = [
["LLM / Modell", "Die KI selbst — versteht und schreibt Text (wie ChatGPT, nur lokal auf deinem PC). Ein Modell ist eine konkrete Datei, oft auf etwas spezialisiert (Code, Bilder, Chat)."],
["GGUF", "Das lokal lauffähige Dateiformat. llama-swap lädt nur GGUF — Repos ohne GGUF kann die Engine nicht nutzen."],
["Quantisierung (Q4_K_M)", "Verkleinert ein Modell (16-Bit → 4-Bit), damit es in den Speicher passt — bei kaum Qualitätsverlust. Q4_K_M ist der bewährte Sweetspot. Für Coding/Reasoning: Q8_0 (nahezu verlustfrei). Wenn Q4_K_M 1 GB zu groß ist: IQ4_XS als Alternative (ähnliche Qualität, etwas kleiner)."],
["Kontextfenster", "Das Kurzzeitgedächtnis: wie viel Text sich das Modell gleichzeitig merken kann. Größer = mehr Überblick, mehr Speicher, etwas langsamer."],
["Token", "Die kleinste Einheit die ein Modell verarbeitet — meist 34 Zeichen oder ein kurzes Wort. 1 000 Tokens ≈ 750 Wörter. Kontextfenster, Geschwindigkeit und Kosten werden in Tokens gemessen."],
["Auto-Swap", "llama-swap lädt/entlädt Modelle automatisch — immer nur eins gleichzeitig. Du managst nichts."],
["TTL (Time to Live)", "Wie lange ein Modell nach der letzten Anfrage im RAM bleibt bevor es entladen wird. Standard: 5 Minuten. ∞ = bleibt dauerhaft geladen (sinnvoll für den Scout)."],
["Agent", "Ein Programm (OpenCode, Cline, Continue), das das Modell in einer Handlungsschleife laufen lässt — Dateien lesen/ändern, Befehle ausführen, planen, bis die Aufgabe erledigt ist."],
["MCP", "Model Context Protocol — der Stecker, über den ein Agent zusätzliche Werkzeuge bekommt (Web, Datenbank, GitHub …). MCP = neue Fähigkeiten."],
["Skills / Rules", "Kurze Textdatei (AGENTS.md, .clinerules …) mit Anweisungen wie der Agent in deinem Projekt arbeiten soll — einmal geschrieben, immer befolgt. Seit 2026 plattformübergreifender Standard: AGENTS.md wird von über 10 Tools nativ gelesen."],
["Memory Bank", "Clines Konzept für dauerhaften Projekt-Kontext: projectbrief.md (Ziel & Scope), activeContext.md (aktueller Fokus), progress.md (Stand). Wird zu Beginn jeder Aufgabe automatisch gelesen — ähnlich wie AGENTS.md, aber aufgeteilt nach Zweck."],
["System-Prompt", "Eine unsichtbare Anweisung die vor deiner Nachricht steht und das Verhalten des Modells grundlegend prägt (Sprache, Ton, Rolle). Skills nutzen oft den System-Prompt."],
["Vision", "Modelle mit Vision-Fähigkeit können Bilder verstehen — Screenshots, Diagramme, Fehlerfotos. Erkennbar am mmproj-Projektor der mitgeladen wird."],
]
+14
View File
@@ -0,0 +1,14 @@
let _value = $state<any[]>([])
let _tracked = $state<string[]>([])
export const jobsStore = {
get value() { return _value },
set(jobs: any[]) { _value = jobs || [] },
get tracked() { return _tracked },
track(id: string) { if (!_tracked.includes(id)) _tracked = [..._tracked, id] },
toggle(id: string) {
_tracked = _tracked.includes(id)
? _tracked.filter(x => x !== id)
: [..._tracked, id]
},
}
+6
View File
@@ -0,0 +1,6 @@
let _value = $state<any>(null)
export const statusStore = {
get value() { return _value },
set(s: any) { _value = s },
}
+6
View File
@@ -0,0 +1,6 @@
let _value = $state<any>(null)
export const systemStore = {
get value() { return _value },
set(sys: any) { _value = sys },
}
+13
View File
@@ -0,0 +1,13 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
compilerOptions: {
runes: true,
},
onwarn: (warning, handler) => {
// a11y-Warnungen nicht als Build-Fehler behandeln
if (warning.code.startsWith('a11y')) return
handler(warning)
},
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": false,
"noEmit": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"allowJs": true,
"checkJs": false
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { resolve } from 'path'
export default defineConfig({
plugins: [svelte()],
base: '/static/dist/',
build: {
outDir: resolve(__dirname, '../static/dist'),
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/main.ts'),
output: {
entryFileNames: 'main.js',
chunkFileNames: '[name].js',
assetFileNames: '[name][extname]',
// Alles in eine Datei — kein dynamisches Chunking für einfaches Deploy
manualChunks: undefined,
},
},
},
resolve: {
alias: {
'@core': resolve(__dirname, '../static/js/core'),
'@panels': resolve(__dirname, '../static/js/panels'),
},
},
})
+231
View File
@@ -0,0 +1,231 @@
"""
Hermes-Agent Control-Plane read-only Einblicke fuer das Dashboard (Phase 4).
Macht das "geschenkte v9" sichtbar: Agent-Status, Cron-Scheduler und Skills des
Nous-Hermes-Agent-Frameworks. Quellen (MC laeuft als hitonabi auf dem Bosgame,
also direkter Zugriff):
- Status : ~/.hermes/gateway_state.json + cron-Heartbeat-Datei (sauberes JSON,
kein Parsing). Liveness kommt aus dem Heartbeat, nicht aus dem evtl.
beim Start eingefrorenen gateway_state.json.
- Cron : `hermes cron list --all` (kanonisch; kein stabiles Storage-Schema).
- Skills : `hermes skills list` (kanonisch) + ~/.hermes/skills/.usage.json
(Nutzungszaehler). Rich-Tabellen werden mit COLUMNS=400 ohne
Truncation erzeugt und ueber die ``/``-Spalten geparst.
Alles mit kurzem TTL-Cache, damit Dashboard-Polling die CLI nicht haemmert.
"""
import json
import os
import re
import subprocess
import time
from typing import Callable
from config import HERMES_HOME, HERMES_BIN
_CACHE_TTL = 10.0 # Sekunden
_cache: dict[str, tuple[float, object]] = {}
def _cached(key: str, fn: Callable[[], object]) -> object:
now = time.monotonic()
hit = _cache.get(key)
if hit and now - hit[0] < _CACHE_TTL:
return hit[1]
val = fn()
_cache[key] = (now, val)
return val
def _run_cli(args: list[str], timeout: float = 45.0) -> str:
"""`hermes <args>` ausfuehren. COLUMNS=400 verhindert Rich-Truncation."""
env = dict(os.environ, COLUMNS="400", NO_COLOR="1")
try:
p = subprocess.run(
[HERMES_BIN, *args],
capture_output=True, text=True, timeout=timeout, env=env,
)
return p.stdout or ""
except Exception:
return ""
def _parse_rich_table(text: str) -> list[dict]:
"""Eine Rich-Box-Tabelle in list[dict] verwandeln.
Erste Zeile mit Spaltentrennern (`` oder ``) ist der Header, der Rest sind
Datenzeilen. Trenn-/Rahmenzeilen ( usw.) enthalten keine Trenner -> ignoriert.
"""
header: list[str] | None = None
rows: list[list[str]] = []
for ln in text.splitlines():
if "" not in ln and "" not in ln:
continue
cells = [c.strip() for c in re.split(r"[│┃]", ln)]
# fuehrende/abschliessende Rahmen-Leerzellen entfernen
while cells and cells[0] == "":
cells.pop(0)
while cells and cells[-1] == "":
cells.pop()
if not cells:
continue
if header is None:
header = [c.lower() for c in cells]
else:
rows.append(cells)
if not header:
return []
out = []
for r in rows:
if len(r) != len(header):
continue
out.append(dict(zip(header, r)))
return out
# ---------------------------------------------------------------------------
# Oeffentliche Reads
# ---------------------------------------------------------------------------
def agent_status() -> dict:
"""Live-Status des Hermes-Gateways + Cron-Tickers."""
def _load() -> dict:
st = {
"gateway_state": "unknown",
"pid": None,
"active_agents": None,
"api_server": "unknown",
"cron_running": False,
"heartbeat_age_s": None,
"version": None,
}
try:
d = json.loads((HERMES_HOME / "gateway_state.json").read_text())
st["gateway_state"] = d.get("gateway_state", "unknown")
st["pid"] = d.get("pid")
st["active_agents"] = d.get("active_agents")
api = (d.get("platforms") or {}).get("api_server") or {}
st["api_server"] = api.get("state", "unknown")
except Exception:
pass
# Liveness: Cron-Ticker-Heartbeat (Unix-Timestamp). < 120s = laeuft.
try:
ts = float((HERMES_HOME / "cron" / "ticker_heartbeat").read_text().strip())
age = max(0, int(time.time() - ts))
st["heartbeat_age_s"] = age
st["cron_running"] = age < 120
except Exception:
pass
try:
st["version"] = (HERMES_HOME / "PINNED_VERSION").read_text().strip() or None
except Exception:
pass
return st
return _cached("agent", _load)
def _parse_cron(text: str) -> list[dict]:
"""`hermes cron list` parsen. Format pro Job (KEINE Rich-Tabelle):
7ac183295470 [active]
Name: Memory-Kurator
Schedule: 0 4 * * 0
Next run: 2026-06-28T04:00:00+00:00
Deliver: local
"""
jobs: list[dict] = []
cur: dict | None = None
for ln in text.splitlines():
m = re.match(r"\s*([0-9a-fA-F]{6,})\s+\[(\w+)\]\s*$", ln)
if m:
cur = {"id": m.group(1), "status": m.group(2)}
jobs.append(cur)
continue
if cur is not None:
kv = re.match(r"\s+([A-Za-z][\w ]*?):\s+(.+?)\s*$", ln)
if kv:
key = kv.group(1).strip().lower().replace(" ", "_")
cur[key] = kv.group(2).strip()
return jobs
def cron_jobs() -> list[dict]:
"""Geplante Jobs (inkl. pausierter)."""
return _cached("cron", lambda: _parse_cron(_run_cli(["cron", "list", "--all"]))) # type: ignore[return-value]
def skills() -> list[dict]:
"""Installierte Skills, angereichert um Nutzungszaehler aus .usage.json."""
def _load() -> list[dict]:
rows = _parse_rich_table(_run_cli(["skills", "list"]))
usage = {}
try:
usage = json.loads((HERMES_HOME / "skills" / ".usage.json").read_text())
except Exception:
pass
for r in rows:
u = usage.get(r.get("name", ""), {})
r["use_count"] = u.get("use_count", 0)
r["last_used_at"] = u.get("last_used_at")
r["pinned"] = bool(u.get("pinned", False))
return rows
return _cached("skills", _load) # type: ignore[return-value]
def learned_profile() -> dict:
"""Was Hermes ueber den Nutzer gelernt hat (Phase 8).
`~/.hermes/memories/USER.md` ist das vom Curator destillierte Nutzerprofil
(Eintraege mit `§` getrennt), `MEMORY.md` allgemeine gelernte Fakten. Direkter
Beleg, dass der Agent automatisch mitlernt.
"""
def _read(name: str) -> str:
try:
return (HERMES_HOME / "memories" / name).read_text(errors="replace").strip()
except Exception:
return ""
def _load() -> dict:
user_raw = _read("USER.md")
facts = [s.strip() for s in user_raw.split("§") if s.strip()]
return {"user_facts": facts, "memory": _read("MEMORY.md")}
return _cached("learned", _load) # type: ignore[return-value]
def insights() -> dict:
"""Aktivitaets-Kennzahlen via `hermes insights` (Phase 8).
Die CLI rendert ein Box-Art-Layout (kein --json) -> Headline-Zahlen + Top-Tools
per Regex herausziehen (best effort; faellt leer aus, wenn sich das Format aendert).
"""
def _num(pat: str, text: str):
m = re.search(pat, text)
return m.group(1).strip() if m else None
def _load() -> dict:
txt = _run_cli(["insights", "--days", "30"])
out: dict = {
"sessions": _num(r"Sessions:\s+([\d,]+)", txt),
"messages": _num(r"Messages:\s+([\d,]+)", txt),
"tool_calls": _num(r"Tool calls:\s+([\d,]+)", txt),
"total_tokens": _num(r"Total tokens:\s+([\d,]+)", txt),
"active_time": _num(r"Active time:\s+(~?[\dhm ]+?)\s{2,}", txt),
"top_tools": [],
}
# "Top Tools"-Sektion: Zeilen <name> <calls> <pct>%
sec = txt.split("Top Tools", 1)
if len(sec) == 2:
for ln in sec[1].splitlines():
m = re.match(r"\s*(\S.*?)\s+(\d+)\s+([\d.]+)%\s*$", ln)
if m:
out["top_tools"].append(
{"name": m.group(1), "calls": int(m.group(2)), "pct": float(m.group(3))}
)
return out
return _cached("insights", _load) # type: ignore[return-value]
+66 -8
View File
@@ -3,6 +3,8 @@ Extrahierte Mathematik aus dem Odysseus Projekt zur VRAM/RAM Berechnung.
Abgestimmt auf APUs mit Unified Memory (Bosgame M5 / Strix Halo).
"""
import re
# Annahme: Bytes per Parameter für GGUF Quants
QUANT_BYTES_PER_PARAM = {
"Q2_K": 0.35,
@@ -34,21 +36,36 @@ def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
return weights + context_vram
def estimate_speed(req_gb: float, sys_ram_gb: float) -> float:
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU."""
# Strix Halo hat ca 273 GB/s Unified Memory Bandbreite.
def extract_active_params_b(name: str) -> float | None:
"""Aktive Parameter bei MoE-Modellen aus dem Namen (z.B. '30B-A3B' → 3.0).
Gibt None zurück für Dense-Modelle (kein MoE-Suffix erkannt)."""
m = re.search(r'(?<![a-zA-Z])a(\d+(?:\.\d+)?)b\b', name.lower())
return float(m.group(1)) if m else None
def estimate_speed(req_gb: float, sys_ram_gb: float, moe_active_ratio: float = 1.0) -> float:
"""Berechnet die geschätzte Tokens/s basierend auf der 273 GB/s Bandbreite der APU.
moe_active_ratio = aktive_params / gesamt_params; < 1 bei MoE (z.B. 0.1 für 30B-A3B)."""
bw = 273 if sys_ram_gb > 8 else 70
if req_gb <= 0:
return 0.0
# (Bandbreite / Modellgröße) * Effizienz (0.55)
raw_tps = (bw / req_gb) * 0.55
# MoE-Boost: nur ein Bruchteil der Gewichte wird pro Token aktiviert →
# effektiv höhere Durchsatzrate als ein Dense-Modell gleicher Gesamtgröße.
# sqrt-Dämpfung: Routing-Overhead + VRAM-Traffic für alle Experten bleibt.
if moe_active_ratio < 0.8:
raw_tps *= (1.0 / moe_active_ratio) ** 0.5
return raw_tps
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> dict:
"""Berechnet den Fit für ein System mit Shared Memory (APU)."""
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float,
name: str = "") -> dict:
"""Berechnet den Fit für ein System mit Shared Memory (APU).
name wird für MoE-Erkennung genutzt (optional)."""
req_gb = estimate_memory_gb(params_b, quant, ctx)
tps = estimate_speed(req_gb, sys_ram_gb)
active_b = extract_active_params_b(name) if name else None
moe_ratio = (active_b / params_b) if (active_b and params_b > 0) else 1.0
tps = estimate_speed(req_gb, sys_ram_gb, moe_ratio)
# Das OS und andere Prozesse brauchen RAM. Wir lassen 4GB Puffer.
usable_ram = max(sys_ram_gb - 4.0, 0)
@@ -69,3 +86,44 @@ def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float) -> di
"req_gb": round(req_gb, 1),
"tps": round(tps, 0)
}
def extract_params_b(name: str) -> float:
"""Parametergröße (Mrd.) aus Repo-/Dateiname. 8x7B (MoE) -> 56."""
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", name)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", name)
if m:
return float(m.group(1))
return 7.0
# "Schöne" Kontextstufen, die UIs/Modelle gern mögen.
_NICE_CTX = [2048, 4096, 8192, 16384, 32768, 49152, 65536, 98304, 131072]
def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
"""Größter 'schöner' Kontext, der komfortabel passt (80% des nutzbaren RAM, 4 GB OS-Puffer).
Umkehrung von estimate_memory_gb: löst die Kontext-Heuristik nach ctx auf."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
usable = max(sys_ram_gb - 4.0, 0) * 0.8 # gleicher Komfort wie evaluate_fit "perfect"
ctx_budget = usable - weights # GB, die für den KV-Cache übrig sind
if ctx_budget <= 0:
return 2048 # Modell selbst schon knapp -> Minimal-Kontext
per_8k = (max(params_b, 7) / 7) * 0.8 # GB pro 8k Kontext (aus der Heuristik)
raw_ctx = (ctx_budget / per_8k) * 8192
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
"""Empfohlener Kontext + Klartext-Begründung (für die UI)."""
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
+145 -8
View File
@@ -6,6 +6,7 @@ zeilenweisem Log-Capture. Keine Persistenz, kein Broker. Genutzt von allen
Routern, die laenger laufende Shell-Befehle anstossen (Download, Update, ...).
"""
import glob
import os
import shlex
import subprocess
@@ -14,9 +15,56 @@ import time
import uuid
JOBS: dict[str, dict] = {}
_PROCS: dict[str, subprocess.Popen] = {} # laufende Prozesse je Job-ID (fuer Abbruch)
_LOG_CAP = 400
def _append_log(job: dict, line: str) -> None:
job["log"].append(line)
if len(job["log"]) > _LOG_CAP:
del job["log"][0]
def _pump_output(job: dict, stream) -> None:
"""Liest die Ausgabe byteweise und behandelt `\\r` (Fortschrittsbalken wie bei
`hf download`/tqdm) als Ueberschreiben der letzten Zeile statt als neue Zeile.
So erscheint der Download-Fortschritt live, ohne das Log mit tausenden Frames zu
fluten. Bewusst BINAER gelesen: im Text-Modus wuerde Python ein einzelnes `\\r`
zu `\\n` uebersetzen (universal newlines) und das Ueberschreiben unmoeglich machen."""
buf = b""
overwrite = False # soll die naechste committete Zeile die letzte ueberschreiben?
pending_cr = False # haben wir gerade ein `\r` gesehen und warten auf das naechste Byte?
def commit():
line = buf.decode("utf-8", "replace")
if overwrite and job["log"]:
job["log"][-1] = line
else:
_append_log(job, line)
while True:
ch = stream.read(1)
if not ch:
break
if pending_cr:
pending_cr = False
if ch == b"\n": # `\r\n` zusammen = echte neue Zeile
commit(); overwrite = False; buf = b""
continue
commit(); overwrite = True; buf = b"" # einzelnes `\r` = Fortschritt (ueberschreiben)
# ch faellt unten normal durch
if ch == b"\r":
pending_cr = True
elif ch == b"\n":
commit(); overwrite = False; buf = b""
else:
buf += ch
if pending_cr:
commit(); overwrite = True; buf = b""
if buf:
commit()
def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data: str | None = None):
job = JOBS[job_id]
job["state"] = "running"
@@ -26,31 +74,120 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, stdin_data:
stdin=subprocess.PIPE if stdin_data is not None else None,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
bufsize=0, # binaer + ungepuffert -> Fortschritt (\r) kommt live an
env={**os.environ, **(env or {})},
)
_PROCS[job_id] = proc
if stdin_data is not None:
# Secret (z.B. sudo-Passwort) ueber stdin fuettern — landet NICHT im Log/ps.
try:
proc.stdin.write(stdin_data) # type: ignore[union-attr]
proc.stdin.write(stdin_data.encode()) # type: ignore[union-attr]
proc.stdin.close() # type: ignore[union-attr]
except Exception: # noqa: BLE001
pass
for line in proc.stdout: # type: ignore[union-attr]
job["log"].append(line.rstrip("\n"))
if len(job["log"]) > _LOG_CAP:
del job["log"][0]
_pump_output(job, proc.stdout)
proc.wait()
if job.get("canceled"):
job["state"] = "canceled"
job["returncode"] = proc.returncode
_append_log(job, "[mission-control] Vorgang abgebrochen.")
else:
job["returncode"] = proc.returncode
job["state"] = "done" if proc.returncode == 0 else "failed"
except Exception as exc: # noqa: BLE001
job["log"].append(f"[mission-control] Fehler: {exc}")
_append_log(job, f"[mission-control] Fehler: {exc}")
job["state"] = "failed"
job["returncode"] = -1
finally:
_PROCS.pop(job_id, None)
job["finished_at"] = time.time()
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
"""Echten Download-Fortschritt (in %) auf den Job legen. `hf` gibt im Nicht-TTY-
Modus keinen Fortschritt aus, schreibt aber in <local_dir>/.cache/huggingface/
download/*.incomplete (waechst). Wir vergleichen dessen Groesse mit total_bytes
(aus der HF-Tree-API). Ein Daemon-Thread aktualisiert job["progress"]."""
if not total_bytes or total_bytes <= 0:
return
job = JOBS.get(job_id)
if job is not None:
job["progress"] = 0
job["total_bytes"] = total_bytes
def _watch():
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "*.incomplete")
prev_t, prev_b = None, None
rate = 0.0 # geglaettete Download-Rate (Bytes/s), EMA gegen Zappeln
while True:
j = JOBS.get(job_id)
if not j or j["state"] in ("done", "failed", "canceled"):
break
try:
inc = glob.glob(pat)
cur = sum(os.path.getsize(f) for f in inc) if inc else 0
if cur:
j["progress"] = min(99, int(cur * 100 / total_bytes))
j["done_bytes"] = cur
now = time.time()
if prev_t is not None and now > prev_t and cur >= prev_b:
inst = (cur - prev_b) / (now - prev_t)
rate = inst if rate == 0 else 0.3 * inst + 0.7 * rate
if rate > 0:
j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur
except Exception: # noqa: BLE001
pass
time.sleep(1.0)
j = JOBS.get(job_id)
if j and j["state"] == "done":
j["progress"] = 100
j.pop("eta_s", None)
threading.Thread(target=_watch, daemon=True).start()
def cancel_job(job_id: str) -> bool:
"""Laufenden Job abbrechen: Prozess terminieren. Liefert False, wenn der Job
nicht (mehr) laeuft oder unbekannt ist."""
job = JOBS.get(job_id)
if not job or job["state"] in ("done", "failed", "canceled"):
return False
job["canceled"] = True
_append_log(job, "[mission-control] Abbruch angefordert…")
proc = _PROCS.get(job_id)
if proc is not None:
try:
proc.terminate()
except Exception: # noqa: BLE001
pass
else:
# Prozess noch nicht gestartet -> direkt als abgebrochen markieren.
job["state"] = "canceled"
job["finished_at"] = time.time()
return True
def delete_job(job_id: str) -> bool:
"""Einen abgeschlossenen Job aus dem Verlauf entfernen. Laufende Jobs werden
NICHT geloescht (erst abbrechen). Liefert False, wenn unbekannt oder noch aktiv."""
job = JOBS.get(job_id)
if not job or job["state"] not in ("done", "failed", "canceled"):
return False
JOBS.pop(job_id, None)
return True
def clear_finished() -> int:
"""Alle abgeschlossenen Jobs (fertig/fehler/abgebrochen) aus dem Verlauf raeumen.
Laufende bleiben. Liefert die Anzahl entfernter Eintraege."""
done = [jid for jid, j in JOBS.items() if j["state"] in ("done", "failed", "canceled")]
for jid in done:
JOBS.pop(jid, None)
return len(done)
def start_job(args: list[str], label: str, env: dict | None = None,
stdin_data: str | None = None, log_cmd: str | None = None) -> str:
job_id = uuid.uuid4().hex[:12]
+55 -1
View File
@@ -6,10 +6,53 @@ Helfer rund um llama-swap und dessen config.yaml.
sodass Kommentare und Quotes erhalten bleiben.
"""
import os
import re
import httpx
from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml
# Kanonische Rollen (Tags auf den Modellen). Bewusst eine kleine, feste Palette fuer die
# UI-Schnellwahl — frei waehlbare Rollen sind trotzdem erlaubt.
ROLE_IDS = {"vision", "coder", "scout", "reviewer", "manager"}
def model_id_from_path(model_path: str) -> str:
"""Sprechende Modell-ID (= llama-swap-Modellname, den man in der API angibt) aus dem
GGUF-Pfad ableiten: der Repo-Ordnername ohne '-GGUF'. Fallback: Dateiname ohne
Quant-Suffix/.gguf. So steht in der Modell-Liste der echte Name statt 'coder'."""
d = os.path.basename(os.path.dirname(model_path))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
fn = re.sub(r"-\d+-of-\d+$", "", fn)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
return name or "modell"
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als llama-swap-`aliases` auf ein Modell legen (damit der Rollenname in der API
ebenfalls funktioniert). Haelt den Alias EINDEUTIG: derselbe Rollen-Alias wird vorher
bei allen anderen Modellen entfernt. role=None/leer entfernt den Alias."""
models = cfg.get("models") or {}
role = (role or "").strip().lower()
if role:
for mid, spec in models.items():
if mid == model_id or not isinstance(spec, dict):
continue
al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role]
if al:
spec["aliases"] = al
else:
spec.pop("aliases", None)
spec = models.get(model_id)
if isinstance(spec, dict):
if role and role != model_id.lower():
spec["aliases"] = [role]
else:
spec.pop("aliases", None)
def _swap_get(path: str):
with httpx.Client(timeout=5.0) as c:
@@ -29,6 +72,17 @@ def read_config() -> dict:
def write_config(cfg: dict) -> None:
"""Schreibt die config.yaml atomar (tmp-Datei + os.replace), damit llama-swap
mit -watch-config nie eine halb geschriebene Datei sieht. Fehlende Schreibrechte
werden in eine klare Meldung uebersetzt statt als roher Traceback zu landen."""
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with CONFIG_PATH.open("w", encoding="utf-8") as f:
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
yaml.dump(cfg, f)
os.replace(tmp, CONFIG_PATH)
except PermissionError as exc:
raise PermissionError(
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
f"Einmalig Besitz uebergeben: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
) from exc
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Mission Control Memory MCP Server
Stdio-MCP-Server fuer persistentes Gedaechtnis.
Laeuft als Subprocess von Cline / Claude Code / OpenCode auf dem Client-Rechner.
Ruft die /api/memory-Endpunkte von Mission Control via HTTP auf.
Konfiguration via Env-Vars:
MC_URL Mission Control URL (default: http://192.168.178.151:9000)
MC_TOKEN Auth-Token (default: leer)
Einmalig installieren (auf dem Rechner wo das KI-Tool laeuft):
pip install mcp httpx
"""
import os
import httpx
from mcp.server.fastmcp import FastMCP
MC_URL = os.environ.get("MC_URL", "http://192.168.178.151:9000").rstrip("/")
MC_TOKEN = os.environ.get("MC_TOKEN", "")
mcp = FastMCP("mission-control-memory")
def _headers() -> dict:
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
def _get(path: str, **params) -> list | dict:
r = httpx.get(f"{MC_URL}{path}", headers=_headers(), params=params, timeout=10)
r.raise_for_status()
return r.json()
def _post(path: str, data: dict) -> dict:
r = httpx.post(f"{MC_URL}{path}", headers=_headers(), json=data, timeout=10)
r.raise_for_status()
return r.json()
def _put(path: str, data: dict) -> dict:
r = httpx.put(f"{MC_URL}{path}", headers=_headers(), json=data, timeout=10)
r.raise_for_status()
return r.json()
def _delete(path: str) -> dict:
r = httpx.delete(f"{MC_URL}{path}", headers=_headers(), timeout=10)
r.raise_for_status()
return r.json()
@mcp.tool()
def get_memories(category: str = "") -> str:
"""Laedt gespeicherte Fakten und Entscheidungen aus dem geteilten Gedaechtnis.
RUFE DAS ZU BEGINN JEDER SESSION AUF, um vorhandenen Kontext (Konventionen,
Entscheidungen, Nutzer-Praeferenzen) zu laden, BEVOR du handelst oder nachfragst.
category: user | instruction | stable | versioned | ephemeral | (leer = alle)"""
params = {"category": category} if category else {}
items = _get("/api/memory", **params)
if not items:
return "Keine Memories gespeichert."
cat_icon = {"stable": "🔵", "versioned": "🟡", "ephemeral": ""}
return "\n".join(
f"{cat_icon.get(m['category'], '·')} [{m['category']}] {m['content']}"
f" (Quelle: {m['source']}, ID: {m['id'][:8]})"
for m in items
)
@mcp.tool()
def add_memory(content: str, category: str = "stable", source: str = "agent") -> str:
"""Speichert einen Fakt/eine Entscheidung dauerhaft im GETEILTEN Gedaechtnis (alle Tools sehen es).
RUFE DAS PROAKTIV AUF, sobald waehrend der Arbeit etwas Dauerhaftes entsteht eine
Konvention, Architektur-Entscheidung, Tech-Version, Nutzer-Praeferenz oder ein Projektfakt.
Warte NICHT, bis man dich bittet.
Mache VORHER `search_memories` mit Stichworten, um Dubletten zu vermeiden; existiert ein
passender Eintrag, nutze `update_memory` statt neu anzulegen. Halte Eintraege knapp & atomar.
category: user=Fakten ueber den User (Praeferenzen/Arbeitsweise), instruction=Verhaltensregeln fuer alle Tools, stable=Projektfakten, versioned=Tech-Versionen, ephemeral=temporaer/Session-Notiz (7 Tage, dann weg).
"""
m = _post("/api/memory", {"content": content, "category": category, "source": source})
return f"Gespeichert (ID: {m['id'][:8]}): {content}"
@mcp.tool()
def search_memories(q: str) -> str:
"""Sucht per Stichwort in allen gespeicherten Fakten. Nutze das VOR `add_memory`
(Dubletten-Check) und immer dann, wenn du fruehere Entscheidungen/Kontext brauchst."""
items = _get("/api/memory", q=q)
if not items:
return f"Keine Treffer fuer '{q}'."
return "\n".join(
f"[{m['category']}] {m['content']} (ID: {m['id'][:8]})"
for m in items
)
@mcp.tool()
def update_memory(memory_id: str, content: str = "", category: str = "") -> str:
"""Aktualisiert einen bestehenden Eintrag (z.B. nach Tech-Update). Nur gesetzte Felder werden geaendert."""
data: dict = {}
if content:
data["content"] = content
if category:
data["category"] = category
if not data:
return "Nichts zu aktualisieren (content und category sind leer)."
m = _put(f"/api/memory/{memory_id}", data)
return f"Aktualisiert: {m['content']}"
@mcp.tool()
def delete_memory(memory_id: str) -> str:
"""Loescht einen veralteten oder falschen Eintrag anhand seiner ID."""
_delete(f"/api/memory/{memory_id}")
return f"Eintrag {memory_id[:8]} geloescht."
if __name__ == "__main__":
mcp.run()
+3 -1
View File
@@ -13,7 +13,9 @@ Environment=MC_CONFIG_PATH=/etc/llama-swap/config.yaml
Environment=MC_MODELS_DIR=/srv/models
Environment=MC_DEFAULT_TTL=300
# Befehl zum Starten eines Modells (in config.yaml geschrieben). ${PORT} bleibt stehen!
Environment=MC_CMD_TEMPLATE=llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap
# WICHTIG: Wert MUSS gequotet sein — sonst splittet systemd an Leerzeichen und es kommt
# nur "llama-server" an (Mission Control faengt das zur Sicherheit auch im Code ab).
Environment="MC_CMD_TEMPLATE=llama-server -m {model} --host 127.0.0.1 --port ${PORT} -c {ctx} -ngl 999 -fa 1 --no-mmap"
# Update-Befehl (z.B. kyuz0 refresh oder podman pull):
Environment=MC_UPDATE_CMD=/opt/amd-strix-halo-toolboxes/refresh-toolboxes.sh all
# Optionales Token fuer minimale Absicherung im LAN:
+151
View File
@@ -0,0 +1,151 @@
"""
Modell-Capabilities (Phase 10) EINE Quelle der Wahrheit fuer Modell-Eigenschaften.
Statt Einzel-Label ("Code"/"Text") ein Satz unabhaengiger Tags, die ein Modell
gleichzeitig tragen kann (MoE + Tools + Reasoning + Coder + Long-Context ).
Quellen, geschichtet (sicher Fallback):
1) GGUF-Metadaten der lokalen Datei (architecture, expert_count, context_length,
parameter_count) authoritativ & **offline** (kein Netz, passt zu 100%-lokal).
2) cmd-Flags der llama-swap-Config: `--jinja` = Tool-Template aktiv, `--mmproj` = Vision.
3) duenner Namens-/Familien-Fallback, wenn Metadaten fehlen.
4) optional `hf`-Block (HF-API: tags + gguf.chat_template) fuer die Profi-Suche (Schritt 2).
Tool-Faehigkeit kommt NICHT aus dem Dateinamen, sondern aus `--jinja` (bestaetigt) /
dem Chat-Template (HF) / der Familie (vermutet) drei Stufen: yes | likely | no.
"""
import re
import struct
from hw_math import extract_params_b, extract_active_params_b
# GGUF-Skalar-Typen → Bytebreite (fuer Skip)
_GGUF_FIXED = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8}
def _read_gguf_meta(path: str) -> dict:
"""Liest nur den GGUF-Metadaten-Header (architecture/context_length/expert_count/
parameter_count). Bricht vor dem riesigen Tokenizer-Array ab schnell, laedt NICHT
das Modell. Robust: gibt {} bei jedem Fehler."""
out: dict = {}
try:
with open(path, "rb") as f:
if f.read(4) != b"GGUF":
return {}
struct.unpack("<I", f.read(4))[0] # version
f.read(8) # tensor_count (u64)
kv = struct.unpack("<Q", f.read(8))[0]
def ru32() -> int: return struct.unpack("<I", f.read(4))[0]
def ru64() -> int: return struct.unpack("<Q", f.read(8))[0]
def rstr() -> str: return f.read(ru64()).decode("utf-8", "replace")
def rval(t: int):
if t == 8: return rstr()
if t == 0: return struct.unpack("<B", f.read(1))[0]
if t == 1: return struct.unpack("<b", f.read(1))[0]
if t == 2: return struct.unpack("<H", f.read(2))[0]
if t == 3: return struct.unpack("<h", f.read(2))[0]
if t == 4: return struct.unpack("<I", f.read(4))[0]
if t == 5: return struct.unpack("<i", f.read(4))[0]
if t == 6: return struct.unpack("<f", f.read(4))[0]
if t == 7: return f.read(1) != b"\x00"
if t == 10: return struct.unpack("<Q", f.read(8))[0]
if t == 11: return struct.unpack("<q", f.read(8))[0]
if t == 12: return struct.unpack("<d", f.read(8))[0]
if t == 9: # Array: nur ueberlesen (brauchen wir nicht)
et = ru32(); cnt = ru64()
if et == 8:
for _ in range(cnt):
f.seek(ru64(), 1)
elif et == 9:
for _ in range(cnt):
rval(9)
else:
f.seek(cnt * _GGUF_FIXED.get(et, 0), 1)
return None
raise ValueError(f"unbekannter GGUF-Typ {t}")
want = {"architecture", "context_length", "expert_count", "parameter_count"}
for _ in range(kv):
key = rstr()
t = ru32()
if key == "tokenizer.ggml.tokens": # ab hier nur noch riesige Arrays
break
v = rval(t)
short = key.split(".")[-1]
if short in want and short not in out:
out[short] = v
except Exception:
return out
return out
# Familien-Fallback (nur wenn Metadaten schweigen). Bewusst kurz gehalten.
_TOOL_FAMILIES = (
"qwen2.5", "qwen3", "qwen2", "hermes", "mistral", "mixtral", "devstral",
"command-r", "command_r", "llama-3.1", "llama3.1", "llama-3.3", "llama-4", "llama4",
"functionary", "watt", "firefunction", "granite", "glm-4", "ministral",
)
_REASON_KW = (
"-r1", "deepseek-r1", "qwq", "magistral", "-think", "thinking", "-o1",
"gpt-oss", "reasoning", "exaone-deep", "phi-4-reasoning", "phi-4-mini-reasoning",
)
_CODE_KW = ("coder", "-code", "code-", "codestral", "starcoder", "deepseek-coder")
_VISION_KW = ("-vl", "vision", "llava", "pixtral", "multimodal", "-mm-", "qwen3vl", "qwen2-vl")
_EMBED_KW = ("bge", "e5-", "gte-", "nomic-embed", "embed")
_MOE_ARCH = ("moe", "mixtral", "deepseek2", "deepseek3", "llama4", "qwen3moe", "grok")
def capabilities(name: str = "", cmd: str = "", gguf_path: str = "", hf: dict | None = None) -> dict:
"""Capability-Tag-Set fuer ein Modell. Alle Quellen optional — nutzt, was da ist."""
low = (name or "").lower()
cmdl = (cmd or "").lower()
hf = hf or {}
meta = _read_gguf_meta(gguf_path) if gguf_path else {}
arch = str(meta.get("architecture") or hf.get("architecture") or "").lower()
tags = [str(t).lower() for t in (hf.get("tags") or [])]
chat_tpl = str(hf.get("chat_template") or "")
# --- MoE ---
expert_count = int(meta.get("expert_count") or 0)
moe = (
expert_count > 1
or any(a in arch for a in _MOE_ARCH)
or bool(re.search(r"\d+x\d+\.?\d*b", low)) # 8x7B
or bool(re.search(r"a\d+\.?\d*b", low)) # 30B-A3B
)
active_b = extract_active_params_b(name)
# --- Params / Kontext ---
pcount = int(meta.get("parameter_count") or 0)
params_b = round(pcount / 1e9, 1) if pcount else extract_params_b(name)
ctx = meta.get("context_length")
if not ctx:
m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl)
ctx = int(m.group(1)) if m else None
# --- Tools: yes (bestaetigt) | likely (Familie) | no ---
tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl
tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags
tools = "yes" if tool_confirmed else ("likely" if tool_family else "no")
vision = "--mmproj" in cmdl or "vl" in arch or "clip" in arch or any(k in low for k in _VISION_KW)
coder = any(k in low for k in _CODE_KW)
reasoning = any(k in low for k in _REASON_KW) or "reasoning" in tags
embedding = "bert" in arch or any(k in low for k in _EMBED_KW)
return {
"moe": moe,
"active_b": active_b,
"tools": tools, # yes | likely | no
"vision": vision,
"coder": coder,
"reasoning": reasoning,
"embedding": embedding,
"ctx": ctx,
"params_b": params_b or None,
"arch": arch or None,
}
+136
View File
@@ -0,0 +1,136 @@
"""
Kuratierte Use-Case-Setups" (Stacks) fuers Cookbook 2.0. Stand: Juni 2026.
Best-in-class je Use-Case bewusst herstelleruebergreifend (Qwen, Google Gemma, Mistral, DeepSeek),
nicht alles Qwen. Pro Modell nur Repo + Groesse/Quant; die GGUF-Datei wird beim Installieren dynamisch
aufgeloest (robust). Fit + optimaler Kontext kommen zur Laufzeit aus hw_math.
"""
RECIPES = [
{
"id": "elite", "title": "Elite-Lineup (für deine APU)", "icon": "bolt",
"desc": "Das Stärkste, das auf deinem Strix Halo wirklich flott läuft: bevorzugt MoE-Modelle (viel Wissen, wenige aktive Parameter). Ein Spitzenmodell je Rolle — sie wechseln sich automatisch ab, es läuft immer nur eins.",
"models": [
{"role": "coder", "name": "Qwen3-Coder 30B-A3B", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"params_b": 30, "quant": "Q4_K_M", "why": "Bestes lokales Coder-Modell — MoE (nur ~3B aktiv) → 30B-Wissen bei hohem Tempo."},
{"role": "manager", "name": "Qwen3 30B-A3B", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
"params_b": 30, "quant": "Q4_K_M", "why": "Starker Allrounder & Orchestrator (MoE) — plant, delegiert, fasst zusammen."},
{"role": "reviewer", "name": "Gemma 3 27B", "repo": "unsloth/gemma-3-27b-it-GGUF",
"params_b": 27, "quant": "Q4_K_M", "why": "Googles Modell mit feinem Sprachgefühl — ideal zum Prüfen & Gegenlesen von Code/Texten."},
{"role": "vision", "name": "Qwen3-VL 8B", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Liest Screenshots, Diagramme & Fehlerbilder — der Blick fürs Visuelle."},
{"role": "scout", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Flinker Kundschafter für schnelle Fragen & einfache Aufgaben."},
],
},
{
"id": "coding", "title": "Coden & Programmieren", "icon": "code",
"desc": "Aktueller Top-Coder (MoE, nur 3B aktiv → schnell), ein flinker Helfer und Vision für Screenshots.",
"models": [
{"role": "coder", "name": "Qwen3-Coder 30B-A3B", "repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF",
"params_b": 30, "quant": "Q4_K_M", "why": "Bestes lokales Coder-Modell — versteht große Codebasen, läuft dank MoE flott."},
{"role": "coder-fast", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Schneller Helfer für einfache Edits & Autovervollständigung."},
{"role": "vision", "name": "Qwen3-VL 8B", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Liest Screenshots & Fehlerbilder für die Analyse."},
],
},
{
"id": "vision", "title": "Bilder verstehen", "icon": "eye",
"desc": "Ein multimodales Modell, das Bilder und Text gemeinsam versteht.",
"models": [
{"role": "vision", "name": "Qwen3-VL 8B", "repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Aktuelles Vision-Modell — beschreibt Bilder, liest Diagramme & Screenshots."},
],
},
{
"id": "chat", "title": "Allrounder & Chat", "icon": "compass",
"desc": "Vielseitig für Alltag, Texte und Brainstorming — Googles Gemma als starke Alternative zu Qwen.",
"models": [
{"role": "scout", "name": "Gemma 3 27B", "repo": "unsloth/gemma-3-27b-it-GGUF",
"params_b": 27, "quant": "Q4_K_M", "why": "Googles Allrounder — exzellent bei Sprache, Wissen und langem Kontext."},
{"role": "scout-fast", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Schnelle Alternative für kurze Fragen."},
],
},
{
"id": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
"desc": "Ein Reasoning-Modell, das Schritt für Schritt denkt — für Mathe, Logik, knifflige Probleme.",
"models": [
{"role": "thinker", "name": "DeepSeek-R1 Distill 7B", "repo": "unsloth/DeepSeek-R1-Distill-Qwen-7B-GGUF",
"params_b": 7, "quant": "Q4_K_M", "why": "DeepSeeks Reasoning-Ansatz — denkt laut mit, stark bei Logik & Mathe."},
],
},
{
"id": "longdoc", "title": "Lange Dokumente", "icon": "file",
"desc": "Großes Modell mit großem Kontext — für lange Texte, Verträge, Bücher.",
"models": [
{"role": "reader", "name": "Gemma 3 27B", "repo": "unsloth/gemma-3-27b-it-GGUF",
"params_b": 27, "quant": "Q4_K_M", "why": "Starke Qualität bei sehr großem Kontextfenster (auto-optimiert)."},
],
},
{
"id": "agents", "title": "Agenten & Tool-Use", "icon": "layers",
"desc": "Modelle, die gut mit Werkzeugen/Funktionen umgehen (MCP) — Mistral als Tool-Use-Spezialist.",
"models": [
{"role": "agent", "name": "Mistral Small 3.2 24B", "repo": "unsloth/Mistral-Small-3.2-24B-Instruct-2506-GGUF",
"params_b": 24, "quant": "Q4_K_M", "why": "Mistrals Stärke: zuverlässiges Function-Calling für Agenten."},
{"role": "agent-think", "name": "Qwen3 30B-A3B", "repo": "unsloth/Qwen3-30B-A3B-Instruct-2507-GGUF",
"params_b": 30, "quant": "Q4_K_M", "why": "Starkes mehrstufiges Denken (MoE) als Backup-Hirn."},
],
},
{
"id": "fast", "title": "Schnell & sparsam", "icon": "bolt",
"desc": "Kleine, flinke Modelle — ideal bei wenig Speicher oder für schnelle Antworten.",
"models": [
{"role": "mini", "name": "Qwen3 4B", "repo": "Qwen/Qwen3-4B-GGUF",
"params_b": 4, "quant": "Q4_K_M", "why": "Winzig & sehr schnell für einfache Aufgaben."},
{"role": "scout-fast", "name": "Qwen3 8B", "repo": "unsloth/Qwen3-8B-GGUF",
"params_b": 8, "quant": "Q4_K_M", "why": "Etwas mehr Können, immer noch flott."},
],
},
]
# ---------------------------------------------------------------------------
# Dynamische Setups: KONZEPT bleibt kuratiert (Rollen-Slots), die Modelle werden
# zur Laufzeit aus `discover` (live HF + Hardware-Fit) gefuellt → Setups
# aktualisieren sich von selbst und passen sich der Hardware an.
# slots = Rollen aus sources.CATEGORIES (vision/coder/reasoning/agent/scout).
# ---------------------------------------------------------------------------
RECIPE_TEMPLATES = [
{"id": "auto-elite", "title": "Elite-Lineup (für deine APU)", "icon": "bolt",
"desc": "Das Stärkste, das auf deiner Hardware flott läuft — bevorzugt MoE (viel Wissen, "
"wenige aktive Parameter). Ein Spitzenmodell je Rolle, automatisch aktuell gehalten.",
"slots": ["coder", "reasoning", "agent", "vision", "scout"]},
{"id": "auto-coding", "title": "Coden & Programmieren", "icon": "code",
"desc": "Bestes Coder-Modell für deine Hardware + ein Reasoning-Modell zum Gegenlesen.",
"slots": ["coder", "reasoning"]},
{"id": "auto-chat", "title": "Alltag & Chat", "icon": "compass",
"desc": "Vielseitiger Allrounder + Bildverständnis für den täglichen Gebrauch.",
"slots": ["scout", "vision"]},
{"id": "auto-agent", "title": "Agenten & Tools", "icon": "layers",
"desc": "Tool-fähige Modelle für autonome Aufgaben, ergänzt um einen Coder.",
"slots": ["agent", "coder"]},
]
# Kurzbegruendung je Rolle (ersetzt die handgeschriebenen „why"-Texte bei Auto-Setups).
ROLE_WHY = {
"coder": "Bestes lokales Coder-Modell für deine Hardware.",
"reasoning": "Reasoning-Modell für Mathe, Logik & knifflige Aufgaben.",
"agent": "Tool-fähig — plant, ruft Werkzeuge auf, arbeitet mehrschrittig.",
"vision": "Versteht Screenshots, Diagramme & Fehlerbilder.",
"scout": "Flinker Allrounder für schnelle Fragen & Chat.",
}
# Upgrade-Map: hat der Nutzer ein „altes" Modell, schlagen wir das aktuelle vor.
UPGRADES = [
{"match": ["qwen2.5-coder", "qwen2_5-coder"], "name": "Qwen3-Coder 30B-A3B",
"repo": "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF", "params_b": 30, "quant": "Q4_K_M",
"why": "Nachfolger deines Coders: MoE mit nur 3B aktiven Parametern → schneller und stärker auf SWE-bench."},
{"match": ["qwen2.5-7b", "qwen2_5-7b", "qwen2.5-instruct", "llama-3.1-8b", "llama-3-8b"], "name": "Qwen3 8B",
"repo": "unsloth/Qwen3-8B-GGUF", "params_b": 8, "quant": "Q4_K_M",
"why": "Neuere Generation deines Allrounders — besser im Reasoning und bei Tool-Use."},
{"match": ["llama-3.2-11b-vision", "llama-3.2-vision", "qwen2.5-vl"], "name": "Qwen3-VL 8B",
"repo": "Qwen/Qwen3-VL-8B-Instruct-GGUF", "params_b": 8, "quant": "Q4_K_M",
"why": "Modernes Vision-Modell — kleiner und treffsicherer beim Bildverständnis."},
]
+5
View File
@@ -3,3 +3,8 @@ uvicorn[standard]>=0.29
httpx>=0.27
ruamel.yaml>=0.18
psutil>=5.9.0
huggingface_hub>=0.34 # liefert die `hf`-CLI fuer Modell-Downloads
mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
faster-whisper>=1.0 # Lokale Sprach-zu-Text Transkription (Hermes Voice)
paramiko>=3.0 # SSH-Client fuer Hermes → Windows-PC Zugriff
websockets>=12.0 # WebSocket-Client fuer den Hermes-Dashboard-Reverse-Proxy
+537 -13
View File
@@ -3,16 +3,121 @@ Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechn
"""
import httpx
import json
import os
import re
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import psutil
from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth
from hw_math import evaluate_fit
from hw_math import evaluate_fit, max_ctx_for, extract_params_b, extract_active_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
from jobengine import start_job, JOBS, attach_download_progress
from recipes import RECIPES, UPGRADES, RECIPE_TEMPLATES, ROLE_WHY
from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
# ---------------------------------------------------------------------------
# Eigene Setups (vom Nutzer) — persistente JSON-Datei, gleiches Schema wie RECIPES.
# ---------------------------------------------------------------------------
def load_user_recipes() -> list:
try:
if USER_RECIPES_PATH.exists():
return json.loads(USER_RECIPES_PATH.read_text(encoding="utf-8")) or []
except Exception: # noqa: BLE001
pass
return []
def save_user_recipes(items: list) -> None:
USER_RECIPES_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = USER_RECIPES_PATH.with_name(USER_RECIPES_PATH.name + ".tmp")
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, USER_RECIPES_PATH)
def _is_moe(name: str) -> bool:
low = (name or "").lower()
return (extract_active_params_b(name) is not None
or "mixtral" in low or bool(re.search(r"\d+x\d+\.?\d*b", low)))
def _tool_capable(m: dict) -> bool:
"""Tool-Fähigkeit eines (noch nicht installierten) Modells aus Name + HF-Tags."""
from model_caps import capabilities
c = capabilities(name=m.get("repo", ""), hf={"tags": m.get("tags", [])})
return c["tools"] != "no"
def _safe_discover(ram_gb: float) -> dict | None:
"""discover-Daten aus Cache (oder live), ohne zu werfen — None wenn nichts da."""
cached = _load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached
try:
return refresh_discover(ram_gb)
except Exception: # noqa: BLE001
return cached
def build_dynamic_recipes(disc: dict) -> list:
"""Fuellt die kuratierten Rollen-Slot-Templates LIVE aus discover.
Pro Slot das beste passende Modell (perfect>marginal, MoE bevorzugt fuer APU,
dann Beliebtheit); dedupe ueber Slots; leere Slots werden still ausgelassen."""
cats = {c["role"]: c for c in (disc.get("categories") or [])}
all_models = [m for c in cats.values() for m in (c.get("models") or [])]
out = []
for tpl in RECIPE_TEMPLATES:
used: set[str] = set()
models = []
for role in tpl["slots"]:
# "agent" = bestes TOOL-FÄHIGES Modell (kategorieübergreifend, via caps),
# nicht die dünne Name-Keyword-Kategorie. Sonst: Modelle der Rolle.
if role == "agent":
pool = [m for m in all_models if _tool_capable(m)]
else:
cat = cats.get(role)
pool = (cat.get("models") if cat else []) or []
cands = [m for m in pool
if m["fit"]["level"] != "too_tight" and m["repo"] not in used]
if not cands:
continue
cands.sort(key=lambda m: (_FIT_ORDER[m["fit"]["level"]],
0 if _is_moe(m["repo"]) else 1,
-int(m.get("downloads") or 0)))
pick = cands[0]
used.add(pick["repo"])
models.append({
"role": role, "name": pick["name"], "repo": pick["repo"],
"params_b": pick["params_b"], "quant": pick.get("quant", "Q4_K_M"),
"tags": pick.get("tags", []), "why": ROLE_WHY.get(role, ""),
})
if models:
out.append({"id": tpl["id"], "title": tpl["title"], "icon": tpl["icon"],
"desc": tpl["desc"], "models": models, "auto": True})
return out
def all_recipes() -> list:
"""Auto-komponierte Setups (live aus discover, Hardware-aware) + eigene Setups.
Fallback auf die statischen RECIPES, wenn discover gerade nichts liefert."""
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
disc = _safe_discover(ram_gb)
base = build_dynamic_recipes(disc) if disc else []
if not base:
base = list(RECIPES)
return base + load_user_recipes()
class AnalyzeRequest(BaseModel):
repo_id: str
ctx: int = 8192
@@ -22,17 +127,33 @@ class EvaluateRequest(BaseModel):
quant: str
ctx: int
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
class InstallRecipeReq(BaseModel):
recipe_id: str
hf_token: str | None = None
class InstallModelReq(BaseModel):
repo: str
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
class UserRecipeModelReq(BaseModel):
repo: str
role: str
quant: str = "Q4_K_M"
name: str | None = None
why: str | None = None
class UserRecipeReq(BaseModel):
title: str
desc: str = ""
icon: str = "box"
models: list[UserRecipeModelReq]
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)
@@ -64,7 +185,7 @@ async def analyze_repo(req: AnalyzeRequest):
results = []
for f in gguf_files:
quant = extract_quant(f)
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb)
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb, name=req.repo_id)
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
# "Q4_K_M" ist oft der Sweetspot.
@@ -79,6 +200,7 @@ async def analyze_repo(req: AnalyzeRequest):
"filename": f,
"quant": quant,
"fit": fit,
"optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"priority": priority
})
@@ -96,5 +218,407 @@ async def analyze_repo(req: AnalyzeRequest):
def evaluate_single(req: EvaluateRequest):
ram_gb = psutil.virtual_memory().total / (1024**3)
fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
return fit
@router.get("/recipes")
def recipes():
"""Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN
Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit."""
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
out = []
for r in all_recipes():
models, worst = [], "perfect"
for m in r["models"]:
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb, name=m.get("name", ""))
models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
worst = fit["level"]
out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))})
# „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt.
fitting = [r for r in out if not r["user"] and r["fit_level"] != "too_tight"]
rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None
return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None}
@router.post("/user-recipe")
def create_user_recipe(req: UserRecipeReq):
"""Eigenes Cookbook-Setup anlegen. params_b wird aus dem Repo-Namen abgeleitet, damit der
Nutzer nur Repo + Rolle + Quant angeben muss; Fit/Kontext kommen wie immer zur Laufzeit."""
if not req.title.strip():
raise HTTPException(400, "Bitte einen Titel angeben.")
models = []
for m in req.models:
if not m.repo.strip():
continue
models.append({
"role": (m.role or "modell").strip(),
"name": (m.name or m.repo.split("/")[-1]).strip(),
"repo": m.repo.strip(),
"params_b": extract_params_b(m.repo),
"quant": (m.quant or "Q4_K_M").strip(),
"why": (m.why or "").strip(),
})
if not models:
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
items = load_user_recipes()
base = "user-" + (re.sub(r"[^a-z0-9]+", "-", req.title.lower()).strip("-") or "setup")
rid, n = base, 2
existing = {r.get("id") for r in items} | {r["id"] for r in RECIPES}
while rid in existing:
rid, n = f"{base}-{n}", n + 1
items.append({"id": rid, "title": req.title.strip(), "icon": (req.icon or "box").strip(),
"desc": req.desc.strip(), "models": models, "user": True})
save_user_recipes(items)
return {"ok": True, "id": rid}
@router.put("/user-recipe/{recipe_id}")
def update_user_recipe(recipe_id: str, req: UserRecipeReq):
"""Bestehendes eigenes Setup aktualisieren (Titel, Beschreibung, Modelle)."""
items = load_user_recipes()
idx = next((i for i, r in enumerate(items) if r.get("id") == recipe_id), None)
if idx is None:
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
if not req.title.strip():
raise HTTPException(400, "Bitte einen Titel angeben.")
models = []
for m in req.models:
if not m.repo.strip():
continue
models.append({
"role": (m.role or "modell").strip(),
"name": (m.name or m.repo.split("/")[-1]).strip(),
"repo": m.repo.strip(),
"params_b": extract_params_b(m.repo),
"quant": (m.quant or "Q4_K_M").strip(),
"why": (m.why or "").strip(),
})
if not models:
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
items[idx] = {**items[idx], "title": req.title.strip(), "icon": (req.icon or "box").strip(),
"desc": req.desc.strip(), "models": models}
save_user_recipes(items)
return {"ok": True, "id": recipe_id}
@router.delete("/user-recipe/{recipe_id}")
def delete_user_recipe(recipe_id: str):
items = load_user_recipes()
kept = [r for r in items if r.get("id") != recipe_id]
if len(kept) == len(items):
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
save_user_recipes(kept)
return {"ok": True}
@router.post("/install-recipe")
def install_recipe(req: InstallRecipeReq):
"""Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
übernimmt es, sobald die Datei da ist."""
recipe = next((r for r in all_recipes() if r["id"] == req.recipe_id), None)
if not recipe:
raise HTTPException(404, "Setup nicht gefunden.")
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
cfg = read_config()
job_ids = []
for m in recipe["models"]:
file = _pick_gguf(m["repo"], m.get("quant", "Q4_K_M"))
if not file:
continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
# Vision-Modelle: mmproj-Projektor mitholen (nötig für --mmproj in llama.cpp).
mmproj = _pick_mmproj(m["repo"])
target = MODELS_DIR / m["repo"].split("/")[-1]
target.mkdir(parents=True, exist_ok=True)
args = [hf_bin(), "download", m["repo"], file]
if mmproj:
args.append(mmproj) # hf-cli unterstützt mehrere Dateien in einem Aufruf
args += ["--local-dir", str(target)]
jid = start_job(args, f"download {m['name']}", env=env)
JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(m["repo"], file))
job_ids.append(jid)
# Eintrag jetzt schon schreiben — optimaler Kontext für die Hardware.
ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb)
path = str(target / file)
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
if mmproj:
cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
# Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias.
mid = model_id_from_path(path)
cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
set_role_alias(cfg, mid, m["role"])
write_config(cfg)
return {"job_ids": job_ids, "count": len(job_ids)}
def hf_file_size(repo: str, file: str) -> int:
"""Groesse einer Datei im HF-Repo (Bytes) fuer die Fortschrittsanzeige. 0 wenn unbekannt.
GGUFs sind LFS -> ggf. unter 'lfs.size'."""
try:
with httpx.Client(timeout=10.0) as c:
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
for f in tree:
if isinstance(f, dict) and f.get("path") == file:
return int(f.get("size") or (f.get("lfs") or {}).get("size") or 0)
except Exception: # noqa: BLE001
return 0
return 0
def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None:
"""Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile."""
try:
with httpx.Client(timeout=10.0) as c:
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
except Exception: # noqa: BLE001
return None
ggufs = [f["path"] for f in tree if isinstance(f, dict) and str(f.get("path", "")).endswith(".gguf")]
if not ggufs:
return None
pref = [g for g in ggufs if quant.lower() in g.lower() and "-of-" not in g]
nosplit = [g for g in ggufs if "-of-" not in g]
return (pref or nosplit or ggufs)[0]
def _pick_mmproj(repo: str) -> str | None:
"""Sucht den mmproj-Projektor (Vision-Modelle) im Repo — F16/BF16 bevorzugt.
Gibt None zurueck, wenn kein Projektor vorhanden (= kein Vision-Modell)."""
try:
with httpx.Client(timeout=10.0) as c:
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
except Exception: # noqa: BLE001
return None
candidates = [
f["path"] for f in tree
if isinstance(f, dict) and "mmproj" in str(f.get("path", "")).lower()
and str(f.get("path", "")).endswith(".gguf")
]
if not candidates:
return None
# F16/BF16 hat die beste Projektor-Qualität; nimm das erste wenn keins passt.
pref = [c for c in candidates if "f16" in c.lower() or "bf16" in c.lower()]
return (pref or candidates)[0]
# ---------------------------------------------------------------------------
# Automatische Modell-Entdeckung ("aktuell beste Modelle", live von HF + Cache).
# ---------------------------------------------------------------------------
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout" # Fallback: Allrounder
def _installed_index() -> dict:
"""alias -> kleingeschriebene cmd-Zeile der installierten Modelle (fuer Bestandsabgleich)."""
return {alias: str(spec.get("cmd", "")).lower()
for alias, spec in (read_config().get("models") or {}).items()}
def _model_installed(repo: str, idx: dict) -> str | None:
"""Liefert den Alias, unter dem dieses Repo schon installiert ist — sonst None."""
base = repo.split("/")[-1].lower()
# GGUF-Repos heissen meist '<modell>-GGUF' -> auch ohne Suffix vergleichen
stem = base[:-5] if base.endswith("-gguf") else base
for alias, cmd in idx.items():
if base in cmd or (stem and stem in cmd):
return alias
return None
def _model_requirements(role: str, quant: str, fit: dict) -> list[str]:
"""Ehrliche Voraussetzungen/Hinweise pro Modell (Klartext fuer Anfaenger)."""
reqs = []
if role == "vision":
reqs.append("Bild-Modell: Projektor (mmproj-F16) wird automatisch mitgeladen und --jinja gesetzt.")
if quant.upper() in ("FP16", "BF16", "F16", "F32"):
reqs.append("Unquantisiert — sehr groß. Eine Q4/Q5-Variante ist meist die bessere Wahl.")
if fit["level"] == "too_tight":
reqs.append("Größer als dein Speicher — nur mit kleinerem Quant realistisch.")
elif fit["level"] == "marginal":
reqs.append("Läuft, wird aber knapp — andere Programme währenddessen besser schließen.")
return reqs
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception: # noqa: BLE001
return []
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, nach Hardware-Fit + Beliebtheit ranken
und cachen. Wirft nur, wenn KEINE einzige Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0:
raise RuntimeError("Keine Quelle erreichbar.")
by_cat = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant fuer die Fit-Einschaetzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant,
"tags": [str(t) for t in (m.get("tags") or [])], # HF-Tags für Capability-Erkennung (Phase 10)
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
})
cats = []
for c in CATEGORIES:
items = by_cat[c["role"]]
# Passendes zuerst (perfect/marginal vor too_tight), dann nach Downloads.
items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True)
top = items[:4]
if top:
cats.append({"role": c["role"], "title": c["title"], "icon": c["icon"], "models": top})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception: # noqa: BLE001
pass # Cache ist nur Beschleunigung — Entdeckung funktioniert auch ohne Schreibrecht
return data
def _load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
pass
return None
@router.get("/discover")
def discover(force: bool = False):
"""Aktuell beste Modelle je Kategorie — live aus den Quellen (gecacht, TTL).
Markiert pro Modell, ob es schon installiert ist, und nennt Voraussetzungen."""
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
cached = _load_discover()
fresh = bool(cached) and (time.time() - cached.get("updated", 0) < DISCOVER_TTL)
if fresh and not force:
data = cached
else:
try:
data = refresh_discover(ram_gb)
except Exception: # noqa: BLE001
if cached:
data = cached # Quellen down -> alter Cache ist besser als nichts
else:
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut versuchen.")
# Bestand + Voraussetzungen frisch dazurechnen (aendern sich unabhaengig vom Cache).
idx = _installed_index()
out = []
for c in data["categories"]:
ms = [{**m, "installed": _model_installed(m["repo"], idx),
"requirements": _model_requirements(m["role"], m.get("quant", "Q4_K_M"), m["fit"])}
for m in c["models"]]
# „Beste Wahl" je Kategorie: das lauffähige Modell mit dem besten Fit (perfect vor
# marginal), bei Gleichstand das meistgeladene. Zu große Modelle kommen nie in Frage.
ranked = sorted([m for m in ms if m["fit"]["level"] != "too_tight"],
key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0)))
out.append({**c, "models": ms, "recommended": ranked[0]["repo"] if ranked else None})
return {"updated": data["updated"], "categories": out,
"sys_ram_gb": round(ram_gb, 1), "stale": not fresh}
def compute_upgrades(ram_gb):
"""Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map)."""
installed = (read_config().get("models") or {})
out, seen = [], set()
for up in UPGRADES:
new_base = up["repo"].split("/")[-1].lower()
if any(new_base in str(s.get("cmd", "")).lower() for s in installed.values()):
continue # neueres Modell schon installiert
old = None
for alias, spec in installed.items():
hay = (alias + " " + str(spec.get("cmd", ""))).lower()
if any(k in hay for k in up["match"]):
old = alias
break
if not old or up["repo"] in seen:
continue
seen.add(up["repo"])
out.append({
"name": up["name"], "repo": up["repo"], "params_b": up["params_b"], "quant": up["quant"],
"why": up["why"], "old": old, "role": old,
"fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb, name=up.get("name", "")),
"optimal_ctx": max_ctx_for(up["params_b"], up["quant"], ram_gb),
})
return out
@router.get("/upgrades")
def upgrades():
return {"upgrades": compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))}
@router.post("/install-model")
def install_model(req: InstallModelReq):
"""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)
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
target = MODELS_DIR / req.repo.split("/")[-1]
target.mkdir(parents=True, exist_ok=True)
args = [hf_bin(), "download", req.repo, file]
if mmproj:
args.append(mmproj)
args += ["--local-dir", str(target)]
jid = start_job(args, f"download {req.repo.split('/')[-1]}", env=env)
JOBS[jid]["result_path"] = str(target / file)
attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
cfg = read_config()
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:
cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
mid = model_id_from_path(path)
cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
set_role_alias(cfg, mid, req.role)
write_config(cfg)
return {"job_id": jid, "model_id": mid}
+238
View File
@@ -0,0 +1,238 @@
"""
Hermes Router Voice-Endpoints, Status & Cockpit-Reads.
Der Chat selbst ist in das eingebettete Hermes-Web-Dashboard umgezogen
(siehe `routers/hermes_ui.py`); der fruehere Eigenbau-Chat-WS/Proxy entfiel.
Endpunkte:
POST /api/hermes/transcribe Audio Text (Whisper)
POST /api/hermes/tts Text Audio WAV (Piper)
GET /api/hermes/status Komponentenstatus (Hermes, Whisper, Piper, SSH)
GET /api/hermes/{agent,cron,skills} Cockpit-Reads (Phase 4)
GET /api/hermes/pubkey Bosgame SSH-Public-Key fuer Windows-Setup
"""
import asyncio
import hashlib
import subprocess
import tempfile
import threading
from pathlib import Path
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, UploadFile, File
from fastapi.responses import Response, JSONResponse
from auth import auth
from config import (
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
HERMES_API_URL,
)
router = APIRouter(prefix="/api")
# ---------------------------------------------------------------------------
# Whisper (lazy-loaded, einmalig in RAM)
# ---------------------------------------------------------------------------
_whisper_model = None
_whisper_loading = False
_whisper_lock = threading.Lock()
def _get_whisper():
global _whisper_model, _whisper_loading
with _whisper_lock:
if _whisper_model is not None:
return _whisper_model
if _whisper_loading:
return None
_whisper_loading = True
try:
from faster_whisper import WhisperModel
model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8")
with _whisper_lock:
_whisper_model = model
return model
except Exception:
return None
finally:
with _whisper_lock:
_whisper_loading = False
# ---------------------------------------------------------------------------
# TTS-Cache (in-memory, max 64 Eintraege)
# ---------------------------------------------------------------------------
_tts_cache: dict[str, bytes] = {}
def _tts(text: str) -> Optional[bytes]:
key = hashlib.md5(text.encode()).hexdigest()
if key in _tts_cache:
return _tts_cache[key]
if not PIPER_BIN.exists() or not PIPER_VOICE.exists():
return None
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav_path = f.name
try:
result = subprocess.run(
[str(PIPER_BIN), "--model", str(PIPER_VOICE), "--output_file", wav_path],
input=text, capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
print(f"[Piper] Fehler: {result.stderr or 'exit '+str(result.returncode)}")
return None
audio = Path(wav_path).read_bytes()
if len(_tts_cache) >= 64:
_tts_cache.pop(next(iter(_tts_cache)))
_tts_cache[key] = audio
return audio
except Exception:
return None
finally:
Path(wav_path).unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
async def transcribe_audio(audio: UploadFile = File(...)):
"""Audio-Datei (webm/ogg/wav) per Whisper transkribieren."""
model = _get_whisper()
if model is None:
return JSONResponse(
status_code=503,
content={"error": "Whisper nicht verfuegbar. Bitte faster-whisper installieren."}
)
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
tmp = Path(f.name)
tmp.write_bytes(await audio.read())
try:
segments, _ = await asyncio.get_running_loop().run_in_executor(
None, lambda: model.transcribe(str(tmp), language="de")
)
text = " ".join(s.text.strip() for s in segments).strip()
return {"text": text}
except Exception as exc:
return JSONResponse(status_code=500, content={"error": str(exc)})
finally:
tmp.unlink(missing_ok=True)
@router.post("/hermes/tts", dependencies=[Depends(auth)])
async def text_to_speech(body: dict):
"""Text zu WAV-Audio per Piper TTS konvertieren."""
text = (body.get("text") or "").strip()
if not text:
return JSONResponse(status_code=400, content={"error": "Kein Text angegeben."})
audio = await asyncio.get_running_loop().run_in_executor(None, lambda: _tts(text))
if audio is None:
return JSONResponse(
status_code=503,
content={"error": "Piper TTS nicht verfuegbar. Bitte Piper Binary + Stimme installieren."}
)
return Response(content=audio, media_type="audio/wav")
@router.get("/hermes/status", dependencies=[Depends(auth)])
def hermes_status():
"""Status aller Hermes-Komponenten."""
# Whisper
try:
from faster_whisper import WhisperModel # noqa
whisper = "ready" if _whisper_model else "installed"
except ImportError:
whisper = "not_installed"
# Piper
piper = "ready" if (PIPER_BIN.exists() and PIPER_VOICE.exists()) else "not_installed"
# SSH
ssh_configured = bool(HERMES_WINDOWS_HOST) and HERMES_SSH_KEY.exists()
ssh_ok = False
if ssh_configured:
try:
import paramiko
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
HERMES_WINDOWS_HOST,
username=HERMES_WINDOWS_USER,
key_filename=str(HERMES_SSH_KEY),
timeout=3,
)
c.close()
ssh_ok = True
except Exception:
ssh_ok = False
# Hermes-Agent-Server (:8642) — erreichbar?
try:
httpx.get(HERMES_API_URL.rsplit("/v1", 1)[0] + "/", timeout=2)
hermes = "ready"
except Exception:
hermes = "offline"
return {
"hermes": hermes,
"whisper": whisper,
"piper": piper,
"ssh_configured": ssh_configured,
"ssh_ok": ssh_ok,
"windows_host": HERMES_WINDOWS_HOST or None,
}
@router.get("/hermes/agent", dependencies=[Depends(auth)])
def hermes_agent_status():
"""Phase 4 — Live-Status des Hermes-Agent-Gateways + Cron-Tickers."""
from hermes_control import agent_status
return agent_status()
@router.get("/hermes/cron", dependencies=[Depends(auth)])
def hermes_cron():
"""Phase 4 — geplante Cron-Jobs des Hermes-Agenten (inkl. pausierter)."""
from hermes_control import cron_jobs
return {"jobs": cron_jobs()}
@router.get("/hermes/skills", dependencies=[Depends(auth)])
def hermes_skills():
"""Phase 4 — installierte Skills des Hermes-Agenten + Nutzungszaehler."""
from hermes_control import skills
return {"skills": skills()}
@router.get("/hermes/learned", dependencies=[Depends(auth)])
def hermes_learned():
"""Phase 8 — was Hermes ueber den Nutzer gelernt hat (USER.md/MEMORY.md)."""
from hermes_control import learned_profile
return learned_profile()
@router.get("/hermes/insights", dependencies=[Depends(auth)])
def hermes_insights():
"""Phase 8 — Aktivitaets-Kennzahlen (Sessions/Tokens/Top-Tools)."""
from hermes_control import insights
return insights()
@router.get("/hermes/pubkey", dependencies=[Depends(auth)])
def hermes_pubkey():
"""Gibt den SSH-Public-Key des Hermes Agent zurueck (fuer Windows authorized_keys)."""
pub = Path(str(HERMES_SSH_KEY) + ".pub")
if not pub.exists():
return JSONResponse(
status_code=404,
content={"error": "Kein SSH-Key gefunden. Bitte auf dem Bosgame generieren: "
"ssh-keygen -t ed25519 -C 'hermes-agent@bosgame' "
f"-f {HERMES_SSH_KEY} -N ''"}
)
return {"pubkey": pub.read_text().strip()}
+117
View File
@@ -0,0 +1,117 @@
"""
Hermes-UI Reverse-Proxy bettet das Hermes-Web-Dashboard in Mission Control ein.
Das Hermes-Agent-Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool-
Aktivitaet, Approval-Prompts, Settings, Sessions). Sie laeuft als eigener Dienst
lokal auf der Box (`hermes dashboard`, Port 9119, an 127.0.0.1 gebunden). MC
proxyt sie unter `/hermes-ui/` und bettet sie per iframe ein:
- Das Dashboard ist explizit fuer Prefix-Reverse-Proxy gebaut: wir setzen
`X-Forwarded-Prefix: /hermes-ui`, dann rewritet es index.html/CSS/Asset-URLs
und seinen SPA-Base-Path selbst.
- Auf Loopback injiziert das Dashboard seinen eigenen Session-Token in die SPA
-> kein zweiter Login. MC bleibt das eine Gateway (LAN-only).
Damit faellt der fruehere Eigenbau-Chat (Proxy auf :8642) weg.
"""
import asyncio
import httpx
import websockets
from fastapi import APIRouter, Request, WebSocket
from fastapi.responses import RedirectResponse, Response
from starlette.websockets import WebSocketState
from config import HERMES_DASHBOARD_URL
router = APIRouter()
PREFIX = "/hermes-ui"
_WS_BASE = HERMES_DASHBOARD_URL.replace("http://", "ws://").replace("https://", "wss://")
# Hop-by-hop-Header, die ein Proxy nicht weiterreichen darf (RFC 7230) plus solche,
# die httpx/Starlette selbst neu berechnen (Laenge/Encoding).
_HOP = {
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
"content-encoding", "upgrade", "proxy-authenticate", "proxy-authorization",
"te", "trailers",
}
_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
@router.get(PREFIX)
def _hermes_ui_root_redirect():
"""Bare /hermes-ui -> /hermes-ui/ (sonst greift das Asset-Prefix-Rewriting nicht)."""
return RedirectResponse(url=PREFIX + "/")
@router.api_route(PREFIX + "/{path:path}", methods=_METHODS)
async def hermes_ui_proxy(request: Request, path: str):
"""HTTP-Reverse-Proxy auf das Hermes-Dashboard mit Prefix-Header."""
url = f"{HERMES_DASHBOARD_URL}/{path}"
headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP}
headers["X-Forwarded-Prefix"] = PREFIX
body = await request.body()
timeout = httpx.Timeout(60.0, connect=10.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
r = await client.request(
request.method, url,
params=request.query_params, headers=headers, content=body,
)
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _HOP}
return Response(
content=r.content, status_code=r.status_code,
headers=resp_headers, media_type=r.headers.get("content-type"),
)
@router.websocket(PREFIX + "/api/{name}")
async def hermes_ui_ws(ws: WebSocket, name: str):
"""WebSocket-Bruecke fuer die Chat-/Event-WS des Dashboards (pty/ws/pub/events)."""
await ws.accept()
qs = ws.url.query
target = f"{_WS_BASE}/api/{name}" + (f"?{qs}" if qs else "")
try:
async with websockets.connect(
target,
additional_headers={"X-Forwarded-Prefix": PREFIX},
max_size=None, open_timeout=10, ping_interval=None,
) as up:
async def client_to_upstream():
try:
while True:
msg = await ws.receive()
if msg.get("type") == "websocket.disconnect":
break
if msg.get("text") is not None:
await up.send(msg["text"])
elif msg.get("bytes") is not None:
await up.send(msg["bytes"])
except Exception:
pass
finally:
await up.close()
async def upstream_to_client():
try:
async for m in up:
if isinstance(m, (bytes, bytearray)):
await ws.send_bytes(m)
else:
await ws.send_text(m)
except Exception:
pass
finally:
if ws.application_state != WebSocketState.DISCONNECTED:
await ws.close()
await asyncio.gather(client_to_upstream(), upstream_to_client())
except Exception:
if ws.application_state != WebSocketState.DISCONNECTED:
try:
await ws.close()
except Exception:
pass
+24
View File
@@ -0,0 +1,24 @@
"""
Integrations-Router: testet die OpenAI-kompatible Verbindung zur LLM-Engine.
Damit der Verbinden"-Assistent dem Nutzer sagen kann: funktioniert ✓ + welche Modelle.
"""
from fastapi import APIRouter, Depends
from auth import auth
from config import LLAMA_SWAP_URL
from llamaswap import _swap_get
router = APIRouter(prefix="/api/integration", dependencies=[Depends(auth)])
@router.get("/test")
def test_connection():
"""Ruft die OpenAI-kompatible Modell-Liste der Engine ab (das, was externe Tools nutzen)."""
try:
data = _swap_get("/v1/models")
items = data.get("data", data) if isinstance(data, dict) else data
models = [m.get("id") for m in (items or []) if isinstance(m, dict) and m.get("id")]
return {"ok": True, "models": models, "url": LLAMA_SWAP_URL}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc), "url": LLAMA_SWAP_URL}
+21 -1
View File
@@ -6,7 +6,7 @@ Liefert die Daten fuer das Aktivitaets-Panel mit Live-Log.
from fastapi import APIRouter, Depends, HTTPException
from auth import auth
from jobengine import JOBS
from jobengine import JOBS, cancel_job, clear_finished, delete_job
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
@@ -19,6 +19,26 @@ def job_status(job_id: str):
return job
@router.post("/jobs/{job_id}/cancel")
def job_cancel(job_id: str):
if not cancel_job(job_id):
raise HTTPException(409, "Job laeuft nicht (mehr) oder ist unbekannt.")
return {"ok": True}
@router.post("/jobs/clear")
def jobs_clear():
"""Alle abgeschlossenen Jobs aus dem Verlauf entfernen (laufende bleiben)."""
return {"ok": True, "removed": clear_finished()}
@router.delete("/jobs/{job_id}")
def job_delete(job_id: str):
if not delete_job(job_id):
raise HTTPException(409, "Job laeuft noch oder ist unbekannt — laufende erst abbrechen.")
return {"ok": True}
@router.get("/jobs")
def jobs_list():
return sorted(JOBS.values(), key=lambda j: j["started_at"], reverse=True)[:20]
+82 -3
View File
@@ -6,6 +6,7 @@ laeuft als Hintergrund-Job mit Live-Log. Spaeter wandert hier ggf. mehr
Server-Wartung hinein (siehe Roadmap: Server-Management).
"""
import os
import shlex
import subprocess
import asyncio
@@ -14,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
from pydantic import BaseModel
from auth import auth
from config import UPDATE_CMD
from config import UPDATE_CMD, SOURCE_DIR, PROD_DIR
from jobengine import start_job
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
@@ -22,11 +23,38 @@ router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
class PwdReq(BaseModel):
password: str
class UpdateReq(BaseModel):
password: str = ""
@router.post("/update")
def update():
def update(req: UpdateReq = None):
"""LLM-Engine (llama.cpp) aktualisieren — Befehl aus MC_UPDATE_CMD.
Optionales Passwort fuer sudo-Befehle (via stdin, nicht in argv)."""
if not UPDATE_CMD:
raise HTTPException(400, "Kein Update-Befehl gesetzt (MC_UPDATE_CMD).")
job_id = start_job(shlex.split(UPDATE_CMD), "update containers")
pwd = (req.password if req else "").strip()
if pwd:
args = ["sudo", "-S", "bash", "-c", UPDATE_CMD]
job_id = start_job(args, "llama.cpp-Update", stdin_data=pwd + "\n",
log_cmd=f"$ sudo {UPDATE_CMD}")
else:
job_id = start_job(shlex.split(UPDATE_CMD), "llama.cpp-Update")
return {"job_id": job_id}
@router.post("/self-update")
def self_update():
"""Mission Control selbst aktualisieren: Quelle pullen -> nach Prod rsyncen -> Dienst neu starten.
git pull liest aus Gitea; der Restart laeuft ueber die NOPASSWD-sudoers-Whitelist. Die einzelnen
Schritte sind mit && verkettet -> bei einem Fehler (z.B. Gitea offline) bricht es sauber ab."""
cmd = (
f"cd {shlex.quote(SOURCE_DIR)} && git pull --ff-only && "
f"rsync -a --exclude=.git --exclude=.venv --exclude=__pycache__ --exclude='*.pyc' "
f"--exclude='frontend/node_modules' "
f"{shlex.quote(SOURCE_DIR)}/ {shlex.quote(PROD_DIR)}/ && "
f"sudo systemctl restart mission-control"
)
job_id = start_job(["bash", "-c", cmd], "Mission Control Update",
log_cmd="$ git pull && rsync -> /opt && systemctl restart mission-control")
return {"job_id": job_id}
@router.post("/os-update")
@@ -56,6 +84,57 @@ def reboot(req: PwdReq):
pass
return {"ok": True, "note": "Reboot getriggert."}
_engine_cache = {"ts": 0.0, "avail": False}
def _engine_update_available():
"""Heuristik: ist die neueste llama.cpp-ROCm-Release neuer als die installierte? (1h gecacht)"""
import time
now = time.time()
if now - _engine_cache["ts"] < 3600:
return _engine_cache["avail"]
avail = False
try:
import os as _os
import httpx
from datetime import datetime
rel = httpx.get("https://api.github.com/repos/lemonade-sdk/llamacpp-rocm/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl"}).json()
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
inst = _os.path.getmtime("/opt/llamacpp")
avail = pub > inst + 86400 # neuer als Installation (1 Tag Puffer)
except Exception: # noqa: BLE001
avail = False
_engine_cache.update(ts=now, avail=avail)
return avail
@router.get("/updates")
def updates():
"""Ausstehende Updates für die Toolbar: OS-Pakete (apt), AI-Engine (llama.cpp), Modell-Upgrades."""
import psutil
from routers.cookbook import compute_upgrades
os_count = 0
try:
out = subprocess.run(
["bash", "-c", "apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
capture_output=True, text=True, timeout=10)
os_count = int((out.stdout or "0").strip() or 0)
except Exception: # noqa: BLE001
os_count = 0
try:
models = len(compute_upgrades(psutil.virtual_memory().total / (1024 ** 3)))
except Exception: # noqa: BLE001
models = 0
apt_cache_age_h = None
try:
import time as _time
apt_cache_age_h = round((_time.time() - os.path.getmtime("/var/lib/apt/lists")) / 3600, 1) # noqa: PTH
except Exception: # noqa: BLE001
pass
return {"os": os_count, "models": models, "engine": 1 if _engine_update_available() else 0,
"apt_cache_age_h": apt_cache_age_h}
@router.websocket("/logs/{service}")
async def stream_logs(websocket: WebSocket, service: str):
await websocket.accept()
+230
View File
@@ -0,0 +1,230 @@
"""
Memory Layer fuer Mission Control.
Persistentes Gedaechtnis fuer lokale LLM-Agenten (Cline, Claude Code, OpenCode).
SQLite aus stdlib kein Vektor-Overhead, keine neue Abhaengigkeit.
Alle MCP-Tools teilen denselben Speicher via mcp_memory.py-Wrapper.
"""
import re
import sqlite3
import uuid
from datetime import datetime, timezone
from difflib import SequenceMatcher
from enum import Enum
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import auth
from config import MEMORY_DB
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
_db_conn: Optional[sqlite3.Connection] = None
def _db() -> sqlite3.Connection:
global _db_conn
if _db_conn is None:
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True)
_db_conn = sqlite3.connect(str(MEMORY_DB), check_same_thread=False)
_db_conn.row_factory = sqlite3.Row
_db_conn.execute("PRAGMA journal_mode=WAL") # sicherer bei concurrent FastAPI-Threads
_db_conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'stable',
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
# Temporaere Eintraege nach 7 Tagen automatisch loeschen
_db_conn.execute(
"DELETE FROM memories WHERE category = 'ephemeral'"
" AND datetime(created_at) < datetime('now', '-7 days')"
)
_db_conn.commit()
return _db_conn
class _MemCategory(str, Enum):
user = "user"
instruction = "instruction"
stable = "stable"
versioned = "versioned"
ephemeral = "ephemeral"
class _MemIn(BaseModel):
content: str
category: _MemCategory = _MemCategory.stable
source: str = "manual"
class _MemUp(BaseModel):
content: Optional[str] = None
category: Optional[_MemCategory] = None
def _row(r: sqlite3.Row) -> dict:
return dict(r)
# Export muss vor /{mid} stehen, sonst wuerde FastAPI "export" als ID behandeln
@router.get("/memory/export")
def export_memories():
"""Alle Eintraege als strukturierter Plain-Text — direkt als System-Prompt-Kontext nutzbar."""
db = _db()
rows = db.execute(
"SELECT * FROM memories ORDER BY category, updated_at DESC"
).fetchall()
cat_names = {
"stable": "Stabile Fakten",
"versioned": "Versionierte Infos",
"ephemeral": "Temporaerer Kontext",
}
lines = ["# Mission Control — Gedaechtnis\n"]
current = ""
for r in rows:
cat = cat_names.get(r["category"], r["category"])
if cat != current:
lines.append(f"\n## {cat}\n")
current = cat
lines.append(
f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_"
)
return {"text": "\n".join(lines), "count": len(rows)}
def _norm(s: str) -> str:
"""Normalisiert Text fuer Dubletten-Vergleich: klein, ohne Satzzeichen, Whitespace kollabiert."""
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
return re.sub(r"\s+", " ", s).strip()
class _DedupeIn(BaseModel):
apply: bool = False # False = Dry-Run (nur Vorschau), True = wirklich loeschen
threshold: float = 0.85 # Aehnlichkeits-Schwelle (SequenceMatcher)
@router.post("/memory/dedupe")
def dedupe_memories(body: _DedupeIn):
"""Deterministischer Kurator: findet Dubletten (exakt / enthalten / aehnlich) je Kategorie,
behaelt den vollstaendigsten (laengsten) Eintrag, entfernt die uebrigen. KEIN LLM.
Konservativ: nur innerhalb derselben Kategorie, Containment braucht vollstaendige Teilstring-Deckung."""
db = _db()
# laengste zuerst -> der vollstaendigste Eintrag einer Gruppe wird zum Kanon
rows = [dict(r) for r in db.execute(
"SELECT * FROM memories ORDER BY length(content) DESC, created_at ASC"
).fetchall()]
used: set[str] = set()
groups: list[dict] = []
for i, a in enumerate(rows):
if a["id"] in used:
continue
na = _norm(a["content"])
if not na:
continue
dups = []
for b in rows[i + 1:]:
if b["id"] in used or b["category"] != a["category"]:
continue
nb = _norm(b["content"])
if not nb:
continue
contained = nb in na or na in nb
ratio = SequenceMatcher(None, na, nb).ratio()
if contained or ratio >= body.threshold:
dups.append(b)
used.add(b["id"])
if dups:
used.add(a["id"])
groups.append({
"keep": {"id": a["id"], "content": a["content"], "category": a["category"]},
"remove": [{"id": d["id"], "content": d["content"]} for d in dups],
})
dup_count = sum(len(g["remove"]) for g in groups)
removed = 0
if body.apply:
for g in groups:
for d in g["remove"]:
db.execute("DELETE FROM memories WHERE id = ?", (d["id"],))
removed += 1
if removed:
db.commit()
return {
"groups": groups,
"duplicate_count": dup_count,
"removed": removed,
"applied": body.apply,
}
@router.get("/memory")
def list_memories(q: str = "", category: str = ""):
db = _db()
sql = "SELECT * FROM memories"
params: list = []
conds: list = []
if q:
conds.append("content LIKE ?")
params.append(f"%{q}%")
if category:
conds.append("category = ?")
params.append(category)
if conds:
sql += " WHERE " + " AND ".join(conds)
sql += " ORDER BY created_at DESC"
return [_row(r) for r in db.execute(sql, params).fetchall()]
@router.post("/memory", status_code=201)
def add_memory(body: _MemIn):
now = datetime.now(timezone.utc).isoformat()
mid = str(uuid.uuid4())
db = _db()
db.execute(
"INSERT INTO memories (id, content, category, source, created_at, updated_at)"
" VALUES (?,?,?,?,?,?)",
(mid, body.content.strip(), body.category, body.source, now, now),
)
db.commit()
return {
"id": mid, "content": body.content.strip(),
"category": body.category, "source": body.source,
"created_at": now, "updated_at": now,
}
@router.put("/memory/{mid}")
def update_memory(mid: str, body: _MemUp):
db = _db()
row = db.execute("SELECT * FROM memories WHERE id = ?", (mid,)).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden")
now = datetime.now(timezone.utc).isoformat()
content = body.content.strip() if body.content is not None else row["content"]
category = body.category if body.category is not None else row["category"]
db.execute(
"UPDATE memories SET content=?, category=?, updated_at=? WHERE id=?",
(content, category, now, mid),
)
db.commit()
return {
"id": mid, "content": content, "category": category,
"source": row["source"], "created_at": row["created_at"], "updated_at": now,
}
@router.delete("/memory/{mid}")
def delete_memory(mid: str):
db = _db()
if not db.execute("SELECT id FROM memories WHERE id = ?", (mid,)).fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
db.execute("DELETE FROM memories WHERE id = ?", (mid,))
db.commit()
return {"ok": True}
+213 -19
View File
@@ -13,11 +13,18 @@ from pydantic import BaseModel
from ruamel.yaml.scalarstring import LiteralScalarString
from auth import auth
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, MODELS_DIR, TOKEN
from jobengine import JOBS, start_job
from llamaswap import _swap_get, read_config, write_config
from config import (CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, HF_DOWNLOAD_ENV, LLAMA_SWAP_URL,
MODELS_DIR, TOKEN, hf_bin)
from jobengine import JOBS, start_job, attach_download_progress
from routers.cookbook import hf_file_size
from llamaswap import (_swap_get, read_config, write_config,
model_id_from_path, set_role_alias, ROLE_IDS)
from hw_math import extract_params_b, max_ctx_for, estimate_memory_gb
from model_caps import capabilities
import re
import os
import shutil
import psutil
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
@@ -29,15 +36,22 @@ class DownloadReq(BaseModel):
repo: str
file: str
subdir: str | None = None
hf_token: str | None = None
class RegisterReq(BaseModel):
alias: str
alias: str = "" # rueckwaertskompatibel: wird als Rolle interpretiert, wenn 'role' fehlt
role: str | None = None # Rollen-Tag (vision/coder/scout/reviewer/manager o.ae.)
model_path: str
ctx: int = 8192
ctx: int | None = None # None → optimal fuer die Hardware (max_ctx_for)
ttl: int | None = None
class RoleReq(BaseModel):
alias: str # die Modell-ID (config-Key)
role: str = "" # leer = Rolle entfernen
class ChatReq(BaseModel):
model: str
message: str
@@ -45,7 +59,13 @@ class ChatReq(BaseModel):
class UpdateReq(BaseModel):
alias: str
ctx: int
ctx: int | None = None
ttl: int | None = None
class DeleteReq(BaseModel):
alias: str
delete_files: bool = True
# ---------------------------------------------------------------------------
@@ -54,6 +74,10 @@ class UpdateReq(BaseModel):
@router.get("/status")
def status():
cfg = read_config()
try:
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
except Exception: # noqa: BLE001
ram_gb = 0
configured = {}
for name, spec in (cfg.get("models") or {}).items():
spec = spec or {}
@@ -67,6 +91,7 @@ def status():
size_bytes = None
quant = ""
filename = ""
path = ""
m_path = re.search(r'-(?:m|-model)\s+([^\s]+)', cmd)
if m_path:
path = m_path.group(1).replace("'", "").replace('"', '')
@@ -77,26 +102,63 @@ def status():
if q_match:
quant = q_match.group(1).upper()
# Rolle aus dem llama-swap-Alias ableiten; Legacy-Eintraege ohne Alias, deren Key
# selbst eine Rolle ist (coder/vision/...), behalten diese als Rolle.
aliases = spec.get("aliases") or []
if isinstance(aliases, str):
aliases = [aliases]
aliases = [str(a) for a in aliases]
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
caps = ["Text"]
if "coder" in name.lower() or (m_path and "code" in m_path.group(1).lower()):
if role == "coder" or "coder" in name.lower() or (m_path and "code" in m_path.group(1).lower()):
caps = ["Code"]
if "--mmproj" in cmd:
caps.append("Bild")
# "incomplete" = Rolle existiert in der config, hat aber kein Modell (-m) hinterlegt
# (z.B. von provision.sh angelegter Platzhalter). Ehrlich kennzeichnen statt "bereit".
incomplete = not (m_path and m_path.group(1))
configured[name] = {
"name": name,
"role": role,
"aliases": aliases,
"api_ids": [name] + aliases,
"ttl": spec.get("ttl", cfg.get("globalTTL", 0)),
"cmd": cmd,
"state": "idle",
"incomplete": incomplete,
"port": None,
"meta": {
"ctx": ctx,
"size_bytes": size_bytes,
"quant": quant,
"caps": caps,
"filename": filename
"filename": filename,
"params_b": (_pb := extract_params_b(filename or name)),
"optimal_ctx": (_oc := (max_ctx_for(_pb, quant or "Q4_K_M", ram_gb) if ram_gb else None)),
"peak_ram_gb": round(estimate_memory_gb(_pb, quant or "Q4_K_M", ctx), 1),
"peak_ram_optimal_gb": (round(estimate_memory_gb(_pb, quant or "Q4_K_M", _oc), 1) if _oc else None),
"capabilities": capabilities(
name=filename or name, cmd=cmd,
gguf_path=(path if (path and os.path.exists(path)) else ""),
),
}
}
# Laufende Download-Jobs erkennnen: Modell bekommt state "downloading" + Fortschritt.
for j in JOBS.values():
if j.get("state") not in ("running", "queued"):
continue
rp = j.get("result_path", "")
if not rp:
continue
for mconf in configured.values():
m_p = re.search(r'-(?:m|-model)\s+(\S+)', mconf.get("cmd", ""))
if m_p and m_p.group(1).strip("'\"") == rp:
mconf["state"] = "downloading"
mconf["download_progress"] = j.get("progress")
break
swap_ok = True
try:
running = _swap_get("/running")
@@ -128,28 +190,69 @@ def download(req: DownloadReq):
sub = req.subdir or req.repo.split("/")[-1]
target = MODELS_DIR / sub
target.mkdir(parents=True, exist_ok=True)
args = ["hf", "download", req.repo, req.file, "--local-dir", str(target)]
job_id = start_job(args, f"download {req.repo}/{req.file}",
env={"HF_XET_HIGH_PERFORMANCE": "1"})
args = [hf_bin(), "download", req.repo, req.file, "--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
job_id = start_job(args, f"download {req.repo}/{req.file}", env=env)
JOBS[job_id]["result_path"] = str(target / req.file)
attach_download_progress(job_id, str(target), hf_file_size(req.repo, req.file))
return {"job_id": job_id, "expected_path": str(target / req.file)}
def _augment_vision(cmd: str, model_path: str) -> str:
"""Liegt im selben Ordner ein mmproj-Projektor, --mmproj + --jinja ergaenzen —
Vision-Modelle brauchen das in llama.cpp (siehe CLAUDE.md). No-op, wenn schon gesetzt
oder kein Projektor da."""
if "--mmproj" in cmd:
return cmd
try:
d = os.path.dirname(model_path)
for f in sorted(os.listdir(d)):
if "mmproj" in f.lower() and f.lower().endswith(".gguf"):
return cmd.rstrip() + f" --mmproj {os.path.join(d, f)} --jinja"
except OSError:
pass
return cmd
@router.post("/register")
def register(req: RegisterReq):
if not Path(req.model_path).exists():
raise HTTPException(404, f"Datei nicht gefunden: {req.model_path}")
# Bewusst KEIN exists()-Check: beim frischen Download läuft der hf-Job noch, die Datei kommt
# erst gleich. Eintrag jetzt schon schreiben → llama-swap (-watch-config) lädt, sobald sie da ist.
cfg = read_config()
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(req.ctx))
cfg["models"][req.alias] = {
ctx = req.ctx
if ctx is None:
params_b = extract_params_b(req.model_path)
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
ctx = max_ctx_for(params_b, "Q4_K_M", ram_gb)
cmd = CMD_TEMPLATE.replace("{model}", req.model_path).replace("{ctx}", str(ctx))
cmd = _augment_vision(cmd, req.model_path)
# Neues Schema: Schluessel = sprechender Modellname (steht so in der Modell-Liste und ist
# der API-Name), die Rolle kommt als llama-swap-Alias obendrauf (beide Namen funktionieren).
role = (req.role or req.alias or "").strip().lower()
model_id = model_id_from_path(req.model_path)
cfg["models"][model_id] = {
"cmd": LiteralScalarString(cmd + "\n"),
"ttl": req.ttl if req.ttl is not None else DEFAULT_TTL,
}
set_role_alias(cfg, model_id, role)
write_config(cfg)
return {"ok": True, "alias": req.alias,
return {"ok": True, "alias": model_id, "model_id": model_id, "role": role,
"note": "In config.yaml geschrieben. llama-swap mit -watch-config laedt automatisch neu."}
@router.post("/set_role")
def set_role(req: RoleReq):
"""Rollen-Tag eines Modells setzen/aendern (als eindeutiger llama-swap-Alias)."""
cfg = read_config()
if req.alias not in cfg.get("models", {}):
raise HTTPException(404, "Modell nicht gefunden.")
set_role_alias(cfg, req.alias, req.role.strip().lower() or None)
write_config(cfg)
return {"ok": True, "alias": req.alias, "role": req.role.strip().lower()}
@router.post("/update_model")
def update_model(req: UpdateReq):
cfg = read_config()
@@ -157,19 +260,110 @@ def update_model(req: UpdateReq):
raise HTTPException(404, "Modell nicht gefunden")
spec = cfg["models"][req.alias]
cmd = str(spec.get("cmd", ""))
# Replace or add context size
if req.ctx is not None:
cmd = str(spec.get("cmd", ""))
if re.search(r'-(?:c|-ctx-size)\s+\d+', cmd):
cmd = re.sub(r'-(?:c|-ctx-size)\s+\d+', f'-c {req.ctx}', cmd)
else:
cmd = cmd.strip() + f" -c {req.ctx}\n"
cfg["models"][req.alias]["cmd"] = LiteralScalarString(cmd)
if req.ttl is not None:
cfg["models"][req.alias]["ttl"] = req.ttl
write_config(cfg)
return {"ok": True}
def _dir_size(p: Path) -> int:
if p.is_file():
try:
return p.stat().st_size
except OSError:
return 0
total = 0
for root, _dirs, files in os.walk(p):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
def _delete_model_files(model_path: str, cfg: dict) -> tuple[str | None, int]:
"""Loescht die Dateien eines Modells — STRENG auf MODELS_DIR begrenzt. Loescht den
ganzen Repo-Unterordner (inkl. .cache), wenn dieser ein direktes Kind von MODELS_DIR
ist und kein anderes (verbleibendes) Modell eine Datei darin nutzt; sonst nur die
GGUF-Datei selbst. Gibt (geloeschter_pfad|None, freigegebene_bytes) zurueck."""
root = MODELS_DIR.resolve()
p = Path(model_path).resolve()
if root not in p.parents: # Sicherheit: niemals ausserhalb des Modell-Ordners loeschen
return None, 0
parent = p.parent
others = " ".join(str(s.get("cmd", "")) for s in (cfg.get("models") or {}).values())
parent_shared = parent != root and str(parent) in others
target = parent if (parent != root and not parent_shared) else p
if not target.exists():
return None, 0
freed = _dir_size(target)
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return str(target), freed
@router.post("/delete_model")
def delete_model(req: DeleteReq):
"""Modell komplett entfernen: aus der config.yaml austragen (+ entladen) und optional
die GGUF-Dateien loeschen, um Speicher freizugeben. Unwiderruflich."""
cfg = read_config()
models = cfg.get("models", {})
if req.alias not in models:
raise HTTPException(404, "Modell nicht gefunden.")
cmd = str(models[req.alias].get("cmd", ""))
m = re.search(r'-(?:m|-model)\s+(\S+)', cmd)
model_path = m.group(1).strip('"\'') if m else None
# Erst entladen (falls geladen) — Fehler ignorieren, das Modell soll trotzdem weg.
try:
with httpx.Client(timeout=10.0) as c:
c.post(f"{LLAMA_SWAP_URL}/api/models/unload/{req.alias}")
except Exception: # noqa: BLE001
pass
del models[req.alias]
write_config(cfg) # cfg enthaelt das Modell jetzt nicht mehr -> _delete prueft die Restmenge
note, freed = "", 0
if req.delete_files and model_path:
try:
deleted, freed = _delete_model_files(model_path, cfg)
note = (f"Dateien gelöscht ({round(freed / 1024 ** 3, 1)} GB frei)."
if deleted else "Dateien liegen außerhalb des Modell-Ordners — nur ausgetragen.")
except Exception as exc: # noqa: BLE001
note = f"Aus der Konfiguration entfernt, aber Dateien konnten nicht gelöscht werden: {exc}"
return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."}
@router.post("/preload")
def preload(model: str):
"""Modell vorwaermen: sendet eine Minimal-Anfrage damit llama-swap es laedt.
max_tokens=1 haelt die Antwort kurz; der Token wird verworfen."""
try:
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": model,
"messages": [{"role": "user", "content": "!"}],
"max_tokens": 1,
})
return {"ok": True}
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"llama-swap nicht erreichbar: {exc}")
@router.post("/unload")
def unload(model: str | None = None):
path = f"/api/models/unload/{model}" if model else "/api/models/unload"
+99
View File
@@ -0,0 +1,99 @@
"""
News-Router: aggregiert kuratierte RSS/Atom-Feeds rund um lokale LLMs.
Serverseitig geholt (httpx) + mit stdlib geparst (kein neuer Dep), In-memory-Cache (30 min).
Degradiert sauber: einzelne Feeds dürfen fehlschlagen, der Rest kommt trotzdem.
"""
import time
import xml.etree.ElementTree as ET
from datetime import datetime
from email.utils import parsedate_to_datetime
import httpx
from fastapi import APIRouter, Depends
from auth import auth
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
FEEDS = [
("HuggingFace", "https://huggingface.co/blog/feed.xml"),
("r/LocalLLaMA", "https://www.reddit.com/r/LocalLLaMA/.rss"),
("Simon Willison", "https://simonwillison.net/atom/everything/"),
("Latent Space", "https://www.latent.space/feed"),
("Ahead of AI", "https://magazine.sebastianraschka.com/feed"),
("The Batch", "https://www.deeplearning.ai/the-batch/rss.xml"),
]
_UA = {"User-Agent": "Mozilla/5.0 (MissionControl NewsBot)"}
_TTL = 1800 # 30 min
_CACHE = {"ts": 0.0, "items": []}
_RELEVANT = ("rocm", "gfx1151", "strix", "amd", "vulkan", "llama.cpp", "gguf", "quant")
def _tag(t):
return t.split("}")[-1]
def _epoch(s):
if not s:
return 0.0
try:
return parsedate_to_datetime(s).timestamp()
except Exception: # noqa: BLE001
pass
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except Exception: # noqa: BLE001
return 0.0
def _parse(source, xml_text):
out = []
try:
root = ET.fromstring(xml_text)
except Exception: # noqa: BLE001
return out
for el in root.iter():
if _tag(el.tag) not in ("item", "entry"):
continue
title = link = date = ""
for ch in el:
t = _tag(ch.tag)
if t == "title":
title = (ch.text or "").strip()
elif t == "link":
link = ch.get("href") or (ch.text or "").strip() or link
elif t in ("pubDate", "published", "updated") and not date:
date = (ch.text or "").strip()
if title:
low = title.lower()
out.append({
"source": source, "title": title, "link": link,
"ts": _epoch(date),
"relevant": any(k in low for k in _RELEVANT),
})
return out[:8]
def get_news():
now = time.time()
if now - _CACHE["ts"] < _TTL and _CACHE["items"]:
return _CACHE["items"]
items = []
with httpx.Client(timeout=8.0, headers=_UA, follow_redirects=True) as c:
for source, url in FEEDS:
try:
r = c.get(url)
r.raise_for_status()
items += _parse(source, r.text)
except Exception: # noqa: BLE001
continue
items.sort(key=lambda x: x["ts"], reverse=True)
items = items[:24]
_CACHE.update(ts=now, items=items)
return items
@router.get("/news")
def news():
return {"items": get_news()}
+33
View File
@@ -0,0 +1,33 @@
"""
Vertrauenswuerdige Quellen + Kategorien fuer die AUTOMATISCHE Modell-Entdeckung
(Cookbook aktuell beste Modelle"). Bewusst nur Daten, kein Code.
Idee: Statt Modelle hartzukodieren, fragt MC diese HF-Orgs live ab (sie pflegen
zuverlaessig hochwertige GGUF-Quants), ordnet die Treffer per Stichwort einer
Kategorie zu, filtert per hw_math auf passt zu deiner Hardware" und cached das
Ergebnis (siehe DISCOVER_CACHE_PATH/_TTL in config). So bleibt das Cookbook von
selbst aktuell, ohne Code-Aenderung.
"""
# HF-Orgs, die zuverlaessig aktuelle, hochwertige GGUF-Quants veroeffentlichen.
TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Prioritaet). Ein Modell wird der
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; bleibt nichts
# uebrig, faellt es in „scout" (Allrounder). Die `role` ist zugleich der Alias-Vorschlag.
CATEGORIES = [
{"role": "vision", "title": "Bilder verstehen", "icon": "eye",
"kw": ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"]},
{"role": "coder", "title": "Coden & Programmieren", "icon": "code",
"kw": ["coder", "-code-", "code-", "codestral", "starcoder"]},
{"role": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
"kw": ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"]},
{"role": "agent", "title": "Agenten & Tool-Use", "icon": "layers",
"kw": ["hermes", "-tool", "command-r", "watt", "-fc-", "function"]},
{"role": "scout", "title": "Allrounder & Chat", "icon": "compass",
"kw": []}, # Fallback: instruct/chat-Modelle
]
# Repo-Namensteile, die wir bei der Entdeckung ueberspringen (Roh-/Spezialformate,
# die fuer Anfaenger nicht die richtige Wahl sind).
SKIP_TOKENS = ["-base", "-bnb-", "-gptq", "-awq", "-fp8", "draft", "tokenizer"]
+62 -4
View File
@@ -91,8 +91,16 @@ a{color:var(--accent);text-decoration:none}
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
.dot.off{background:var(--err)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
.top-stat{font-size:var(--text-sm);color:var(--mut)}
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px}
.top-stat{font-size:var(--text-sm);color:var(--mut);display:flex;align-items:center;gap:5px}
.top-stat b{color:var(--accent);font-family:var(--mono);font-weight:600;letter-spacing:.01em}
/* Aktives Modell in Topbar — klarer sichtbar wenn geladen */
#top-active-text{
font-family:var(--mono);font-size:var(--text-sm);
padding:4px 10px;border-radius:999px;
border:1px solid transparent;transition:border-color .3s,background .3s;
}
#top-active-text b{font-weight:600}
#top-active-text:has(b){border-color:rgba(45,212,191,.25);background:rgba(45,212,191,.07)}
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
/* Security-Chip (zeigt LAN/Token-Status) */
@@ -122,10 +130,60 @@ a{color:var(--accent);text-decoration:none}
.split{display:grid;gap:var(--sp-4);grid-template-columns:1.05fr 1fr}
@media(max-width:1180px){.grid-3{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}}
@media(max-width:900px){.split{grid-template-columns:1fr}}
@media(max-width:760px){.grid-3,.grid-2,.kpis{grid-template-columns:1fr}
@media(max-width:760px){
.grid-3,.grid-2,.kpis{grid-template-columns:1fr}
.sidebar{width:60px;flex-basis:60px}
.ni-tx,.brand-tx{display:none}
.nav-item{justify-content:center}.brand{justify-content:center}}
.nav-item{justify-content:center}.brand{justify-content:center}
}
/* ---- Mobile (≤ 520px): Bottom-Nav, kompaktes Layout ---- */
@media(max-width:520px){
:root{ --side:0px }
/* Sidebar → unsichtbar, Navigation nach unten */
.sidebar{
position:fixed;bottom:0;left:0;right:0;top:auto;
width:100%;height:56px;flex-basis:auto;z-index:30;
flex-direction:row;padding:0;
border-right:none;border-top:1px solid var(--line);
background:var(--bg2);
overflow-x:auto;gap:0;
}
.brand{display:none}
.side-nav{flex-direction:row;gap:0;flex:1;overflow-x:auto}
.nav-cap,.side-foot{display:none}
.nav-item{
flex-direction:column;gap:2px;padding:6px 8px;
font-size:9px;min-width:52px;flex:1;border-radius:0;
justify-content:center;align-items:center;border:none;
}
.ni-tx{display:block;font-size:9px;line-height:1;color:inherit}
.ni-ic svg{width:20px;height:20px}
/* Main-Bereich: Bottom-Padding für die Nav */
.main{ padding-bottom:56px }
/* Topbar kompakter */
.topbar{padding:var(--sp-2) var(--sp-3);gap:var(--sp-2)}
.top-clock,.sec-chip{display:none}
#update-badges .upd-badge{font-size:10px;padding:3px 7px}
/* Content-Padding reduzieren */
.content{padding:var(--sp-3) var(--sp-3) var(--sp-6)}
/* Karten kompakter */
.card{padding:var(--sp-3) var(--sp-3)}
.modal-card{padding:var(--sp-4)}
.modal-overlay{padding:12px}
/* Buttons: mindestens 44px Höhe für Touch */
button{min-height:40px}
button.ghost{min-height:34px}
/* Tabs in Guides horizontal scrollbar */
.guide-tabs{flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px}
}
/* Alert-Banner */
.alert{
+43
View File
@@ -4,6 +4,14 @@
Legacy-Klassen bleiben erhalten, bis alle Panels migriert sind.
========================================================================= */
/* ---- Live-Swap-Flash (Topbar-Highlight wenn Modell wechselt) ---- */
@keyframes swap-flash {
0% { color:var(--tx); }
25% { color:var(--accent); }
100% { color:var(--tx); }
}
.swap-flash { animation: swap-flash 0.9s ease-out forwards; }
/* ---- Karte (Grundbaustein) ---- */
.card{
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
@@ -145,6 +153,14 @@ input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical;min-height:64px;font-family:var(--sans)}
.row{display:flex;gap:10px}.row>div{flex:1}
.hint{font-size:12px;color:var(--mut);margin:-4px 0 12px}
.info-dot{display:inline-flex;vertical-align:-3px;color:var(--dim);cursor:help;margin-left:5px}
.info-dot svg{width:14px;height:14px}
.info-dot:hover{color:var(--accent)}
.upd-badge{font-size:11.5px;padding:4px 10px;border-radius:999px;cursor:pointer;white-space:nowrap;
background:rgba(68,147,224,.14);color:#86b9ff;border:1px solid rgba(68,147,224,.25)}
.upd-badge.warn{background:rgba(224,163,46,.14);color:#f0c570;border-color:rgba(224,163,46,.25)}
.upd-badge.ok{background:rgba(63,185,80,.12);color:#7ee29a;border-color:rgba(63,185,80,.22)}
.upd-badge:hover{filter:brightness(1.12)}
.mono-sm{font-family:var(--mono);font-size:11.5px;color:var(--mut)}
/* ---- Buttons ---- */
@@ -157,6 +173,8 @@ button.danger{background:var(--err);border-color:var(--err);color:#fff}
button.danger:hover{filter:brightness(1.06)}
button.warn{background:var(--warn);border-color:var(--warn);color:#3a2a05}
button.ghost{padding:6px 11px;font-size:12.5px;background:none}
button.ghost.del{color:var(--err)}
button.ghost.del:hover{border-color:var(--err);background:var(--red-dim)}
button:disabled{opacity:.5;cursor:not-allowed}
.btn-row{display:flex;gap:10px;flex-wrap:wrap}
@@ -179,6 +197,28 @@ button:disabled{opacity:.5;cursor:not-allowed}
.modal-card p{margin:0 0 18px;color:var(--mut);font-size:13.5px;line-height:1.55}
.modal-actions{display:flex;gap:10px;justify-content:flex-end}
/* ---- News (Magazin) ---- */
.news-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}
.news-card{display:flex;flex-direction:column;gap:10px;background:var(--panel);border:1px solid var(--line);
border-radius:var(--radius);padding:15px 17px;text-decoration:none;color:inherit;transition:.15s}
.news-card:hover{border-color:var(--accent);background:var(--bg2)}
.news-card.featured{grid-column:span 2}
.news-meta{display:flex;align-items:center;gap:8px;font-size:11.5px}
.news-src{color:var(--accent);font-weight:500}
.news-rel{color:#7ee29a;background:rgba(63,185,80,.13);border-radius:5px;padding:1px 7px}
.news-date{margin-left:auto;color:var(--dim);font-family:var(--mono)}
.news-title{font-size:14px;line-height:1.45;color:var(--tx)}
.news-card.featured .news-title{font-size:18px;font-weight:500;line-height:1.35}
@media(max-width:620px){.news-card.featured{grid-column:span 1}}
/* ---- Guide (Konzept-Karten) ---- */
.concept-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
.concept{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:15px 17px}
.concept h4{margin:0 0 6px;font-size:14px;font-weight:500;color:var(--tx)}
.concept p{margin:0;font-size:13px;color:var(--mut);line-height:1.55}
.tut-steps{padding-left:20px;line-height:1.85;margin:6px 0 0}
.tut-steps li{margin-bottom:4px}
/* ---- Toast ---- */
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--panel2);
border:1px solid var(--line2);border-radius:10px;padding:11px 16px;font-size:13.5px;
@@ -209,5 +249,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
border-radius:var(--radius);padding:var(--sp-3) var(--sp-4);cursor:pointer;transition:border-color .15s,background .15s;
color:var(--tx);font-family:var(--sans)}
.card-btn:hover,.card-btn:focus{border-color:var(--accent);background:var(--bg2);outline:none}
.card-btn.cb-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
.card.res-best{border:2px solid var(--accent);background:rgba(45,212,191,.05)}
.cb-best-tag{display:inline-block;font-size:11px;color:var(--accent-ink);background:var(--accent);border-radius:6px;padding:2px 9px;margin-bottom:8px;font-weight:500}
.card-btn h3{margin:0;font-size:var(--text-lg);font-weight:500}
.card-btn p{margin:var(--sp-2) 0 0;font-size:var(--text-sm);color:var(--mut)}
+1
View File
@@ -0,0 +1 @@
.guide-tabs.svelte-112sh7i{display:flex;gap:6px;flex-wrap:wrap;margin:-6px 0 2px}.guide-tab.svelte-112sh7i{font-size:12px;padding:5px 13px;border-radius:999px;background:var(--tile);border:1px solid var(--line);color:var(--mut);cursor:pointer;transition:.13s;font-family:var(--sans)}.guide-tab.svelte-112sh7i:hover{color:var(--tx);border-color:var(--line2)}.guide-tab.active.svelte-112sh7i{background:#2dd4bf1f;border-color:#2dd4bf4d;color:var(--accent)}.guide-card.svelte-112sh7i{padding:0;overflow:hidden;margin-bottom:0}.guide-group-hd.svelte-112sh7i{padding:9px 18px 7px;font-size:10.5px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--accent);background:#2dd4bf0f;border-bottom:1px solid var(--line)}.tut-table{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:6px;overflow:hidden;margin-top:8px;font-size:12.5px}.tut-row{display:grid;grid-template-columns:1fr 2fr;border-bottom:1px solid var(--line)}.tut-row:last-child{border-bottom:none}.tut-cell-hd{padding:10px 14px;font-weight:500;border-right:1px solid var(--line);background:#ffffff06;line-height:1.5}.tut-cell-hd.bad{color:var(--mut);text-decoration:line-through;font-style:italic}.tut-cell-bd{padding:10px 14px;line-height:1.6;color:var(--fg)}.tut-sources{margin-top:14px;padding-top:10px;border-top:1px solid var(--line);font-size:11.5px;color:var(--mut)}.tut-sources a{color:var(--accent);text-decoration:none}.tut-sources a:hover{text-decoration:underline}.tut-code{display:block;background:var(--bg, #0d0d0d);border:1px solid var(--line);border-radius:6px;padding:12px 14px;font-family:monospace;font-size:11.5px;line-height:1.65;white-space:pre;overflow-x:auto;margin:10px 0;color:var(--fg)}
+665
View File
File diff suppressed because one or more lines are too long
+23 -49
View File
@@ -4,13 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mission Control</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230f1720'/%3E%3Ccircle cx='16' cy='16' r='8' fill='none' stroke='%232dd4bf' stroke-width='3'/%3E%3Ccircle cx='16' cy='16' r='2.5' fill='%232dd4bf'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/components.css">
</head>
<body>
<div id="app">
<!-- Sidebar: beschriftete Bereichs-Navigation -->
<aside class="sidebar">
<div class="brand"><span class="brand-logo" id="logo"></span><span class="brand-tx">Mission Control</span></div>
<nav class="side-nav">
@@ -19,7 +19,11 @@
<a class="nav-item" data-view="activity"><span class="ni-ic" data-ic="pulse"></span><span class="ni-tx">Aktivität</span></a>
<a class="nav-item" data-view="server"><span class="ni-ic" data-ic="server"></span><span class="ni-tx">Server</span></a>
<a class="nav-item" data-view="cookbook"><span class="ni-ic" data-ic="book"></span><span class="ni-tx">Cookbook</span></a>
<a class="nav-item" data-view="connect"><span class="ni-ic" data-ic="swap"></span><span class="ni-tx">Verbinden</span></a>
<a class="nav-item" data-view="news"><span class="ni-ic" data-ic="info"></span><span class="ni-tx">News</span></a>
<a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
<a class="nav-item" data-view="memory"><span class="ni-ic" data-ic="database"></span><span class="ni-tx">Gedächtnis</span></a>
<a class="nav-item" data-view="hermes"><span class="ni-ic" data-ic="bolt"></span><span class="ni-tx">Hermes</span></a>
</nav>
<div class="side-foot">
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
@@ -27,60 +31,31 @@
</aside>
<div class="main">
<!-- Topbar -->
<header class="topbar">
<span class="status-pill"><span id="swdot" class="dot"></span><span id="swlabel">verbinde…</span></span>
<span class="spacer"></span>
<span id="update-badges" class="flex" style="gap:6px"></span>
<span class="top-stat" id="top-active-text">Kein Modell geladen</span>
<span class="sec-chip" id="sec-chip"><span data-ic="shield"></span><span id="sec-chip-tx">Nur im Heimnetz</span></span>
</header>
<!-- Alert-Banner (wird per JS ein-/ausgeblendet) -->
<div id="alert" class="alert" style="display:none"></div>
<!-- Content: genau eine .view ist sichtbar (Hash-Routing) -->
<main class="content">
<section class="view" data-view="overview">
<div id="ov-hero"></div>
<div class="tiles" id="ov-tiles"></div>
<div class="split">
<div class="card" id="ov-health"></div>
<div class="card" id="ov-quickstart"></div>
</div>
<div class="card" id="ov-stack"></div>
</section>
<section class="view" data-view="activity" hidden>
<div id="act-head"></div>
<div class="tiles" id="act-kpis"></div>
<div class="card" id="act-sys"></div>
<div class="card" id="v-activity"></div>
</section>
<section class="view" data-view="server" hidden>
<div class="card" id="wartung"></div>
</section>
<section class="view" data-view="models" hidden>
<div id="m-head"></div>
<div class="card" id="m-chat"></div>
<div class="card" id="m-table"></div>
</section>
<section class="view" data-view="cookbook" hidden>
<!-- Wird von cookbook.js gerendert -->
</section>
<section class="view" data-view="guides" hidden>
<!-- Wird von guides.js gerendert -->
</section>
<section class="view" data-view="overview"></section>
<section class="view" data-view="activity" hidden></section>
<section class="view" data-view="server" hidden></section>
<section class="view" data-view="models" hidden></section>
<section class="view" data-view="cookbook" hidden></section>
<section class="view" data-view="connect" hidden></section>
<section class="view" data-view="news" hidden></section>
<section class="view" data-view="guides" hidden></section>
<section class="view" data-view="memory" hidden></section>
<section class="view" data-view="hermes" hidden></section>
</main>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" style="display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.62); z-index:100; align-items:center; justify-content:center;">
<div class="card" style="width:100%; max-width:420px; position:relative">
<button id="sm-close" class="ghost" style="position:absolute; top:12px; right:12px;">Schließen</button>
@@ -90,17 +65,16 @@
<input id="token" class="tokin" placeholder="Nur nötig, wenn der Server geschützt ist…" autocomplete="off">
<div class="hint mt-2">Schützt den Zugriff. Wird für API-Aufrufe und Live-Verbindungen mitgeschickt.</div>
</div>
<div class="mt-4">
<label>HuggingFace-Token (optional)</label>
<input id="hf-token" class="tokin" placeholder="hf_…" autocomplete="off">
<div class="hint mt-2">Nicht nötig für öffentliche Modelle — vermeidet aber Rate-Limits und erlaubt zugangsbeschränkte Modelle.</div>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<!-- Icons in Nav/Logo/Chip einsetzen, bevor das Haupt-Modul laedt -->
<script type="module">
import { ICON } from "/static/js/core/ui.js";
document.getElementById("logo").innerHTML = ICON.logo;
document.querySelectorAll("[data-ic]").forEach(n => (n.innerHTML = ICON[n.dataset.ic] || ""));
</script>
<script type="module" src="/static/js/main.js"></script>
<link rel="stylesheet" href="/static/dist/main.css">
<script type="module" src="/static/dist/main.js"></script>
</body>
</html>
+4
View File
@@ -16,6 +16,10 @@ export function hdr() {
return h;
}
// HuggingFace-Token (optional) — wird bei Downloads als HF_TOKEN mitgesendet.
export function getHfToken() { return localStorage.getItem("mc_hf_token") || ""; }
export function setHfToken(t) { localStorage.setItem("mc_hf_token", (t || "").trim()); }
export async function api(path, opts = {}) {
const r = await fetch(path, { headers: hdr(), ...opts });
const data = await r.json().catch(() => ({}));
+10 -1
View File
@@ -19,9 +19,13 @@ export function toast(msg, err = false) {
}
// Modell-Status -> Badge-HTML
export function badge(state) {
export function badge(state, progress) {
if (state === "running" || state === "ready") return '<span class="badge b-run">geladen</span>';
if (state === "loading" || state === "starting") return '<span class="badge b-load">lädt…</span>';
if (state === "downloading") {
const pct = progress != null ? ` ${Math.round(progress)}%` : "";
return `<span class="badge b-load">↓ Download${pct}</span>`;
}
return '<span class="badge b-run">bereit</span>';
}
@@ -74,6 +78,11 @@ export const ICON = {
export function icon(name) { return ICON[name] || ""; }
// kleines Info-ⓘ mit Klartext-Tooltip (native title) — für Erklärungen direkt am Feld.
export function infoDot(text) {
return `<span class="info-dot" tabindex="0" title="${esc(text)}" aria-label="${esc(text)}">${ICON.info}</span>`;
}
// ---------------------------------------------------------------------------
// Format-Helfer (Klartext-Zahlen)
// ---------------------------------------------------------------------------
-138
View File
@@ -1,138 +0,0 @@
// main.js — App-Boot: Panels mounten, Nav starten, Topbar/Alert pflegen, Polling fahren.
// Panel-Vertrag: { id, mount?(), onStatus?(s), onJobs?(jobs) }.
import { api, getToken, setToken } from "./core/api.js";
import { $ } from "./core/ui.js";
import { initNav } from "./core/nav.js";
import overview from "./panels/overview.js";
import models from "./panels/models.js";
import server from "./panels/server.js";
import jobs from "./panels/jobs.js";
import cookbook from "./panels/cookbook.js";
import guides from "./panels/guides.js";
const panels = [overview, models, server, jobs, cookbook, guides];
let lastJobs = [];
let lastSystem = null;
// ---- Topbar / Alert aus dem Status ableiten ----
function applyStatus(s) {
const dot = $("#swdot"), label = $("#swlabel"), alert = $("#alert");
if (!s) {
dot.className = "dot off";
label.textContent = "Backend nicht erreichbar";
$("#top-active-text").textContent = "Backend offline";
showAlert("Backend nicht erreichbar läuft uvicorn?", false);
} else {
const host = s.swap_url.replace(/^https?:\/\//, "");
dot.className = "dot " + (s.swap_ok ? "on" : "off");
label.textContent = (s.swap_ok ? "LLM-Engine: Online " : "LLM-Engine: Offline ") + host;
const active = (s.models || []).find(m => m.state === "running");
if (active) {
$("#top-active-text").innerHTML = `Geladen: <b style="color:var(--teal)">${active.name}</b>`;
} else {
$("#top-active-text").innerHTML = "Kein Modell im VRAM";
}
if (s.swap_ok) hideAlert();
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true);
}
setSecChip(s);
for (const p of panels) p.onStatus?.(s);
}
// Security-Chip: ehrlich anzeigen, ob ein Zugangs-Token aktiv ist.
function setSecChip(s) {
const chip = $("#sec-chip"), tx = $("#sec-chip-tx");
if (!chip || !tx) return;
const secured = !!s?.secured;
chip.classList.toggle("ok", secured);
tx.textContent = secured ? "Geschützt · Heimnetz" : "Nur im Heimnetz (offen)";
}
function applyJobs(jobs) {
lastJobs = jobs || [];
for (const p of panels) p.onJobs?.(lastJobs);
}
function applySystem(sys) {
lastSystem = sys;
for (const p of panels) p.onSystem?.(sys);
}
function showAlert(html, warn) {
const a = $("#alert");
a.className = "alert" + (warn ? " warn" : "");
a.innerHTML = `<span class="a-dot"></span><span>${html}</span>`;
a.style.display = "flex";
}
function hideAlert() { $("#alert").style.display = "none"; }
// ---- Polling ----
async function pollStatus() {
try { applyStatus(await api("/api/status")); }
catch { applyStatus(null); }
}
async function pollJobs() {
try { applyJobs(await api("/api/jobs")); }
catch { /* still */ }
}
function connectSystemStream() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/system/stream`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
applySystem(data);
} catch (err) {}
};
ws.onclose = () => {
// Bei Verbindungsabbruch nach 3 Sekunden erneut versuchen
setTimeout(connectSystemStream, 3000);
};
ws.onerror = () => ws.close();
}
// ---- Boot ----
function bootToken() {
const i = $("#token");
if (i) {
i.value = getToken();
i.addEventListener("change", e => { setToken(e.target.value); pollStatus(); });
}
const sbtn = $("#nav-settings");
const smod = $("#settings-modal");
const scls = $("#sm-close");
if (sbtn && smod && scls) {
sbtn.addEventListener("click", () => smod.style.display = "flex");
scls.addEventListener("click", () => smod.style.display = "none");
}
}
function tickClock() {
const c = $("#clock");
if (c) c.textContent = new Date().toTimeString().slice(0, 5);
}
for (const p of panels) p.mount?.();
initNav("overview");
bootToken();
tickClock();
document.addEventListener("mc:refresh", pollStatus);
pollStatus();
pollJobs();
connectSystemStream();
setInterval(tickClock, 1000);
setInterval(pollStatus, 3000);
setInterval(pollJobs, 1500);
-280
View File
@@ -1,280 +0,0 @@
// cookbook.js — "App-Store" für Modelle (v3): Hardware-Fit als Ampel, Klartext-Urteile.
// Backend: /api/cookbook/{analyze,evaluate} (hw_math), /api/download, /api/register.
import { api } from "../core/api.js";
import { $, esc, icon, toast } from "../core/ui.js";
const CURATED = [
{ name: "Qwen 2.5 Coder 7B", repo: "unsloth/Qwen2.5-Coder-7B-Instruct-GGUF", file: "Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf",
desc: "Schnell und locker auf deiner Kiste — ideal zum Programmieren.", params_b: 7, quant: "Q4_K_M", ctx: 32768, alias: "coder-fast" },
{ name: "Qwen 2.5 Coder 32B", repo: "unsloth/Qwen2.5-Coder-32B-Instruct-GGUF", file: "Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf",
desc: "Mehr Können, etwas langsamer — der starke Allround-Coder.", params_b: 32, quant: "Q4_K_M", ctx: 32768, alias: "coder" },
{ name: "Llama 3.2 Vision 11B", repo: "unsloth/Llama-3.2-11B-Vision-Instruct-GGUF", file: "Llama-3.2-11B-Vision-Instruct-Q4_K_M.gguf",
desc: "Versteht Bilder und Text — gut für multimodale Aufgaben.", params_b: 11, quant: "Q4_K_M", ctx: 8192, alias: "vision" },
{ name: "Qwen 2.5 7B", repo: "unsloth/Qwen2.5-7B-Instruct-GGUF", file: "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
desc: "Hervorragender Allrounder — schnell und vielseitig.", params_b: 7, quant: "Q4_K_M", ctx: 8192, alias: "scout" },
];
const FILTERS = [
{ id: "", label: "Alle" }, { id: "coder", label: "Coder" }, { id: "scout", label: "Scout" },
{ id: "vision", label: "Vision" }, { id: "manager", label: "Manager" }, { id: "reviewer", label: "Reviewer" },
];
let lastSys = null;
let currentResults = [];
let currentAnalysis = null;
let activeFilter = "";
const fitCls = lvl => lvl === "perfect" ? "ok" : lvl === "marginal" ? "warn" : "bad";
function mount() {
const c = $(".view[data-view='cookbook']");
c.innerHTML = `
<div class="pagehead"><div>
<h1>Modell-Cookbook</h1>
<div class="sub">Finde ein KI-Modell, das auf deinen Mini-PC passt den Hardware-Check rechnen wir für dich aus.</div>
</div></div>
<div class="flex gap-3" style="margin:4px 0 6px">
<input id="cb-search" placeholder="Modell suchen, z.B. „Llama 3"" style="margin:0;flex:1">
<button class="primary" id="cb-btn-search" style="white-space:nowrap">Suchen</button>
</div>
<div class="flex gap-2 items-center" style="flex-wrap:wrap">
<div id="cb-filters" class="flex gap-2" style="flex-wrap:wrap;flex:1"></div>
<select id="cb-sort" style="width:auto;margin:0;padding:7px 10px;font-family:var(--sans)">
<option value="downloads">Beliebteste Downloads</option>
<option value="likes">Am beliebtesten (Likes)</option>
<option value="lastModified">Zuletzt aktualisiert</option>
<option value="trendingScore">Im Trend</option>
</select>
</div>
<div class="card-h" style="margin-top:18px;align-items:center">
<h3 id="cb-section-title">Empfohlen für deine Hardware</h3>
<span class="chip" id="cb-hw">deine Hardware</span>
</div>
<div class="legend mb-4">
<span><i style="background:var(--on)"></i>passt locker</span>
<span><i style="background:var(--warn)"></i>läuft, aber knapp</span>
<span><i style="background:var(--err)"></i>zu groß für deinen Speicher</span>
</div>
<div class="grid grid-3" id="cb-grid"></div>
<div id="cb-modal" class="modal-overlay" style="display:none">
<div class="modal-card" style="max-width:560px">
<button id="cb-modal-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3 id="cb-m-title">Modell</h3>
<p class="mono-sm" id="cb-m-repo" style="margin:-4px 0 16px">repo/name</p>
<label>Quantisierung (GGUF-Datei) wählen</label>
<select id="cb-m-files"></select>
<div class="hint" id="cb-m-loading" style="display:none">Lade Dateien von HuggingFace</div>
<div class="row" style="margin-top:4px">
<div><label>Alias (Rolle)</label><input id="cb-m-alias" placeholder="z.B. coder"></div>
<div><label>Kontext-Größe</label><input id="cb-m-ctx" type="number" value="8192"></div>
</div>
<div id="cb-m-fit" class="tile" style="display:flex;justify-content:space-between;align-items:center;margin:8px 0 18px">
<div><div style="font-size:13px">Ressourcen-Check</div>
<div class="hint" id="cb-m-fit-text" style="margin:4px 0 0">Berechne</div></div>
<span id="cb-m-fit-badge"></span>
</div>
<button class="primary" id="cb-m-download" style="width:100%">Herunterladen &amp; Einpflegen</button>
</div>
</div>`;
renderFilters();
$("#cb-btn-search").addEventListener("click", doSearch);
$("#cb-search").addEventListener("keydown", e => { if (e.key === "Enter") doSearch(); });
$("#cb-sort").addEventListener("change", () => { if ($("#cb-search").value.trim() || activeFilter) doSearch(); });
$("#cb-modal-close").addEventListener("click", () => $("#cb-modal").style.display = "none");
$("#cb-modal").addEventListener("click", e => { if (e.target.id === "cb-modal") $("#cb-modal").style.display = "none"; });
$("#cb-m-download").addEventListener("click", doDownload);
$("#cb-m-files").addEventListener("change", updateLiveFit);
$("#cb-m-ctx").addEventListener("change", reanalyzeCtx);
renderHwChip();
renderCurated();
}
function renderFilters() {
$("#cb-filters").innerHTML = FILTERS.map(f =>
`<button class="${activeFilter === f.id ? "primary" : "ghost"}" data-f="${f.id}" style="border-radius:999px">${f.label}</button>`
).join("");
$("#cb-filters").querySelectorAll("[data-f]").forEach(b =>
b.addEventListener("click", () => { activeFilter = b.getAttribute("data-f"); renderFilters(); doSearch(); }));
}
function renderHwChip() {
const el = $("#cb-hw"); if (!el) return;
if (lastSys?.ram?.total) {
el.innerHTML = `${icon("cpu")}${Math.round(lastSys.ram.total / 1024 ** 3)} GB Speicher`;
}
}
// ---- Karten ----
function metricLine(fit) { return `~${fit.req_gb.toFixed(1)} GB · ~${Math.round(fit.tps)} Tok/s`; }
function curatedCard(m, i, fit) {
return `<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-cur="${i}">
<div class="flex justify-between items-center"><h3 style="margin:0;font-size:15px;font-weight:500">${esc(m.name)}</h3>
<span class="fit-badge ${fitCls(fit.level)}">${esc(fit.text)}</span></div>
<div class="text-sm text-mut" style="margin:12px 0;flex:1;line-height:1.5">${esc(m.desc)}</div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
<span class="mono-sm">${metricLine(fit)}</span><span class="mono-sm">${esc(m.quant)}</span></div>
</div>`;
}
async function renderCurated() {
$("#cb-section-title").textContent = "Empfohlen für deine Hardware";
const grid = $("#cb-grid"); if (!grid) return;
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Berechne Hardware-Fit…</div>`;
try {
let html = "";
for (let i = 0; i < CURATED.length; i++) {
const m = CURATED[i];
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
html += curatedCard(m, i, fit);
}
grid.innerHTML = html;
grid.querySelectorAll("[data-cur]").forEach(el => el.addEventListener("click", () => openCurated(+el.getAttribute("data-cur"))));
} catch (e) {
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">Konnte Empfehlungen nicht laden: ${esc(e.message)}</div>`;
}
}
async function doSearch() {
let q = $("#cb-search").value.trim();
if (activeFilter) q = q ? q + " " + activeFilter : activeFilter;
if (!q) return renderCurated();
const sort = $("#cb-sort")?.value || "downloads";
const btn = $("#cb-btn-search"); btn.disabled = true; btn.textContent = "Lade…";
$("#cb-section-title").textContent = "Suchergebnisse: " + q;
const grid = $("#cb-grid");
grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Suche auf HuggingFace…</div>`;
try {
const url = `https://huggingface.co/api/models?search=${encodeURIComponent(q)}&filter=gguf&sort=${encodeURIComponent(sort)}&direction=-1&limit=12`;
const r = await fetch(url);
currentResults = await r.json();
renderResults(currentResults);
} catch (e) {
grid.innerHTML = `<div class="alert err" style="grid-column:1/-1">${esc(e.message)}</div>`;
}
btn.disabled = false; btn.textContent = "Suchen";
}
function renderResults(results) {
const grid = $("#cb-grid");
if (!results?.length) { grid.innerHTML = `<div class="empty" style="grid-column:1/-1;text-align:center">Keine GGUF-Modelle gefunden.</div>`; return; }
grid.innerHTML = results.map((m, i) => `
<div class="card" style="display:flex;flex-direction:column;cursor:pointer" data-res="${i}">
<div class="flex justify-between" style="align-items:flex-start;gap:8px">
<div style="min-width:0"><h3 style="margin:0;font-size:14.5px;font-weight:500;word-break:break-word">${esc(m.id.split("/").pop())}</h3>
<div class="text-xs text-mut" style="margin-top:3px">${esc(m.author || "")}</div></div>
<span class="fit-badge warn" id="cb-b-${i}">prüfe</span>
</div>
<div style="flex:1;margin-top:12px"></div>
<div class="flex justify-between items-center text-xs text-mut" style="border-top:1px solid var(--line);padding-top:11px">
<span class="mono-sm" id="cb-m-${i}">Hardware-Fit</span>
<span class="mono-sm"> ${(m.downloads || 0).toLocaleString()}</span></div>
</div>`).join("");
grid.querySelectorAll("[data-res]").forEach(el => el.addEventListener("click", () => openResult(+el.getAttribute("data-res"))));
results.forEach((m, i) => fetchFitForCard(i, m.id));
}
async function fetchFitForCard(i, repo_id) {
try {
const res = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id, ctx: 8192 }) });
const b = $("#cb-b-" + i), mt = $("#cb-m-" + i);
if (!b || !mt) return;
if (!res.files?.length) { b.className = "fit-badge bad"; b.textContent = "keine GGUFs"; mt.textContent = "—"; return; }
let best = res.files.find(f => f.quant?.includes("Q4_K_M")) || res.files[0];
b.className = "fit-badge " + fitCls(best.fit.level); b.textContent = best.fit.text;
mt.textContent = metricLine(best.fit) + " · " + (best.quant || "GGUF");
} catch {
const b = $("#cb-b-" + i); if (b) { b.className = "fit-badge bad"; b.textContent = "Fehler"; }
}
}
// ---- Modal ----
function showFit() {
const file = $("#cb-m-files").value;
const f = currentAnalysis?.files.find(x => x.filename === file);
if (!f) { $("#cb-m-fit").style.display = "none"; return; }
$("#cb-m-fit").style.display = "flex";
$("#cb-m-fit-text").innerHTML = `Bedarf: <b>~${f.fit.req_gb.toFixed(1)} GB</b> · ${currentAnalysis.params_b}B · ${esc(f.quant)} · ~${Math.round(f.fit.tps)} Tok/s`;
$("#cb-m-fit-badge").innerHTML = `<span class="fit-badge ${fitCls(f.fit.level)}">${esc(f.fit.text)}</span>`;
const btn = $("#cb-m-download");
if (f.fit.level === "too_tight") { btn.className = "primary warn"; btn.textContent = "Trotzdem holen (zu groß)"; }
else { btn.className = "primary"; btn.textContent = "Herunterladen & Einpflegen"; }
}
function updateLiveFit() { showFit(); }
async function reanalyzeCtx() {
if (!currentAnalysis) return;
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
const file = $("#cb-m-files").value;
try {
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: currentAnalysis.repo, ctx }) });
$("#cb-m-files").value = file; showFit();
} catch {}
}
function openModalBase(title, repo) {
$("#cb-modal").style.display = "flex";
$("#cb-m-title").textContent = title;
$("#cb-m-repo").textContent = repo;
}
async function openResult(i) {
const m = currentResults[i]; if (!m) return;
openModalBase(m.id.split("/").pop(), m.id);
$("#cb-m-alias").value = m.id.split("/").pop().toLowerCase().replace(/[^a-z0-9]/g, "-");
$("#cb-m-files").style.display = "none"; $("#cb-m-loading").style.display = "block"; $("#cb-m-download").disabled = true;
try {
const ctx = parseInt($("#cb-m-ctx").value) || 8192;
currentAnalysis = await api("/api/cookbook/analyze", { method: "POST", body: JSON.stringify({ repo_id: m.id, ctx }) });
$("#cb-m-loading").style.display = "none"; $("#cb-m-files").style.display = "block";
if (!currentAnalysis.files?.length) { $("#cb-m-files").innerHTML = "<option>Keine GGUF-Dateien gefunden</option>"; $("#cb-m-fit").style.display = "none"; return; }
$("#cb-m-files").innerHTML = currentAnalysis.files.map(f => {
const mark = f.fit.level === "perfect" ? "●" : f.fit.level === "marginal" ? "◐" : "○";
return `<option value="${esc(f.filename)}">${mark} ${esc(f.filename)}</option>`;
}).join("");
$("#cb-m-download").disabled = false; showFit();
} catch (e) { $("#cb-m-loading").textContent = "Fehler: " + e.message; }
}
async function openCurated(i) {
const m = CURATED[i]; if (!m) return;
openModalBase(m.name, m.repo);
$("#cb-m-files").style.display = "block"; $("#cb-m-loading").style.display = "none";
$("#cb-m-files").innerHTML = `<option value="${esc(m.file)}">${esc(m.file)}</option>`;
$("#cb-m-alias").value = m.alias; $("#cb-m-ctx").value = m.ctx; $("#cb-m-download").disabled = false;
try {
const fit = await api("/api/cookbook/evaluate", { method: "POST", body: JSON.stringify({ params_b: m.params_b, quant: m.quant, ctx: m.ctx }) });
currentAnalysis = { repo: m.repo, params_b: m.params_b, files: [{ filename: m.file, quant: m.quant, fit }] };
showFit();
} catch {}
}
async function doDownload() {
const repo = $("#cb-m-repo").textContent, file = $("#cb-m-files").value;
const alias = $("#cb-m-alias").value.trim(), ctx = parseInt($("#cb-m-ctx").value) || 8192;
if (!repo || !file || !alias) return toast("Bitte alle Felder ausfüllen.", true);
const btn = $("#cb-m-download"); btn.disabled = true; btn.textContent = "Starte…";
try {
const res = await api("/api/download", { method: "POST", body: JSON.stringify({ repo, file }) });
await api("/api/register", { method: "POST", body: JSON.stringify({ alias, model_path: res.expected_path, ctx }) });
toast("Download gestartet — siehe Aktivität.");
$("#cb-modal").style.display = "none";
document.querySelector(".nav-item[data-view='activity']")?.click();
} catch (e) { toast("Fehler: " + e.message, true); }
btn.disabled = false; btn.textContent = "Herunterladen & Einpflegen";
}
function onSystem(sys) { lastSys = sys; renderHwChip(); }
export default { id: "cookbook", mount, onSystem };
-89
View File
@@ -1,89 +0,0 @@
// guides.js — Integrations-Anleitungen (v3): Copy-Paste-Configs für externe Tools.
import { $ } from "../core/ui.js";
// Basis-URL aus dem Browser ableiten (zeigt die echte Bosgame-Adresse statt localhost).
function apiBase() {
const host = location.hostname || "localhost";
return `${location.protocol}//${host}:8080/v1`;
}
function render() {
const c = $(".view[data-view='guides']");
if (!c) return;
const url = apiBase();
c.innerHTML = `
<div class="pagehead"><div>
<h1>Guides &amp; Integrationen</h1>
<div class="sub">Binde deine lokalen Modelle in andere Tools ein fertige Configs zum Kopieren.</div></div></div>
<div class="card" style="padding:0;overflow:hidden">
<details class="guide-acc">
<summary>Cline / Cursor</summary>
<div class="acc-body">
<p>Nutze deine lokalen Modelle kostenlos in Cursor oder Cline (Provider: OpenAI Compatible").</p>
<label>Modell-ID</label>
<input class="mono-sm" readonly value="coder">
<label>Basis-URL (OpenAI-kompatibel)</label>
<input class="mono-sm" readonly value="${url}">
<label>API-Key</label>
<input class="mono-sm" readonly value="(dein Token oder leer lassen)">
</div>
</details>
<details class="guide-acc">
<summary>OpenWebUI</summary>
<div class="acc-body">
<p>Settings Admin Connections neue OpenAI-Verbindung:</p>
<label>Basis-URL</label>
<input class="mono-sm" readonly value="${url}">
<label>API-Key</label>
<input class="mono-sm" readonly value="dummy-key">
<p class="hint" style="margin-top:10px">OpenWebUI erkennt automatisch, wenn ein Modell getauscht wird.</p>
</div>
</details>
<details class="guide-acc">
<summary>Python / LangChain (openai-SDK)</summary>
<div class="acc-body">
<p>Mit dem offiziellen <code>openai</code>-Paket:</p>
<div class="log" style="max-height:none"><code>from openai import OpenAI
client = OpenAI(
base_url="${url}",
api_key="dein_token" # optional
)
resp = client.chat.completions.create(
model="coder", # Alias aus dem Cookbook
messages=[{"role": "user", "content": "Hallo Modell!"}]
)
print(resp.choices[0].message.content)</code></div>
</div>
</details>
<details class="guide-acc">
<summary>n8n (AI-Agent-Nodes)</summary>
<div class="acc-body">
<p>OpenAI Chat Model"-Node Credentials Custom URL:</p>
<label>Basis-URL</label>
<input class="mono-sm" readonly value="${url}">
<p class="hint" style="margin-top:10px">Modell-ID coder" eintragen und loslegen.</p>
</div>
</details>
<details class="guide-acc">
<summary>Begriffe einfach erklärt (Glossar)</summary>
<div class="acc-body">
<p><b>LLM-Engine (llama-swap):</b> Der Dienst im Hintergrund, der die Sprachmodelle lädt und eine OpenAI-kompatible Schnittstelle bereitstellt.</p>
<p><b>VRAM / Grafikspeicher:</b> Der schnelle Speicher, in dem ein Modell laufen muss. Große Modelle brauchen viel davon.</p>
<p><b>Quantisierung (z.B. Q4_K_M):</b> Verkleinert ein Modell (16-Bit 4-Bit), damit es in den Speicher passt bei kaum Qualitätsverlust.</p>
<p><b>Kontext-Größe:</b> Wie viel Text sich das Modell gleichzeitig merken" kann. Mehr Kontext = deutlich mehr Speicherbedarf.</p>
</div>
</details>
</div>`;
}
function mount() { render(); }
export default { id: "guides", mount };
-102
View File
@@ -1,102 +0,0 @@
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
import { $, esc, fmtBytes } from "../core/ui.js";
const tracked = new Set();
let JOBS = [];
let SYS = null;
export function track(id) { tracked.add(id); renderJobs(); }
const hist = { cpu: [], ram: [], gpu: [] };
const MAX_HIST = 60;
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : '<span class="badge b-load">läuft…</span>'; }
function dotClass(s) { return s === "done" ? "on" : s === "failed" ? "" : "load"; }
function tile(label, value, sub) {
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
}
function meter(label, pct) {
const p = Math.max(0, Math.min(100, pct || 0));
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span><span class="mv">${Math.round(p)} %</span></div><div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
}
function spark(arr, varName) {
return '<div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">' +
arr.map(v => `<div style="width:4px;background:var(${varName});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`).join("") + "</div>";
}
function gpuPct() {
const g = SYS?.gpu;
if (g && (g.vram.total + g.gtt.total) > 0) return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
return 0;
}
function renderSys() {
if (!SYS) return;
hist.cpu.push(SYS.cpu.percent); hist.ram.push(SYS.ram.percent); hist.gpu.push(gpuPct());
for (const k of ["cpu", "ram", "gpu"]) if (hist[k].length > MAX_HIST) hist[k].shift();
const k = $("#act-kpis");
if (k) k.innerHTML =
tile("Prozessor (CPU)", `${Math.round(SYS.cpu.percent)}<small> %</small>`, SYS.cpu.temp != null ? `${Math.round(SYS.cpu.temp)}° CPU-Temp` : "Auslastung") +
tile("Arbeitsspeicher", `${Math.round(SYS.ram.percent)}<small> %</small>`, `${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}`) +
tile("Grafikspeicher", `${Math.round(gpuPct())}<small> %</small>`, SYS.gpu_temp != null ? `${Math.round(SYS.gpu_temp)}° GPU-Temp` : "VRAM + GTT");
const g = SYS.gpu;
const gpuStr = g && (g.vram.total + g.gtt.total) > 0 ? `${fmtBytes(g.vram.used + g.gtt.used)} / ${fmtBytes(g.vram.total + g.gtt.total)}` : "";
const s = $("#act-sys");
if (s) s.innerHTML = `
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}</span></div>
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span><span class="kv-v">${gpuStr}</span></div>
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">${Math.round(SYS.disk.percent)} % belegt</span></div>
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span><span class="kv-v">${SYS.gpu_temp != null ? Math.round(SYS.gpu_temp) + "°" : ""} / ${SYS.cpu.temp != null ? Math.round(SYS.cpu.temp) + "°" : ""}</span></div>
</div>
<div class="grid grid-3" style="margin-top:14px">
<div><div class="meta text-xs">CPU-Verlauf</div>${spark(hist.cpu, "--accent")}</div>
<div><div class="meta text-xs">RAM-Verlauf</div>${spark(hist.ram, "--purple")}</div>
<div><div class="meta text-xs">VRAM-Verlauf</div>${spark(hist.gpu, "--on")}</div>
</div>`;
}
function mount() {
$("#act-head").innerHTML = `<div class="pagehead"><div>
<h1>Aktivität</h1>
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
$("#v-activity").innerHTML = `
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
<div class="card-sub">Downloads &amp; Updates erscheinen hier mit Live-Protokoll zum Aufklappen klicken.</div>
<div id="jobs"></div>
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig keine laufenden Aufgaben.</div></div>`;
$("#v-activity").addEventListener("click", e => {
const h = e.target.closest(".job-h"); if (!h) return;
const id = h.getAttribute("data-id");
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
renderJobs();
});
if (SYS) renderSys();
}
function renderJobs() {
const c = $("#jobs"); if (!c) return;
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
const failed = JOBS.filter(j => j.state === "failed").length;
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
c.innerHTML = JOBS.map(j => {
const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${statusBadge(j.state)}</div>${log}</div>`;
}).join("");
}
function onJobs(jobs) { JOBS = jobs || []; renderJobs(); }
function onSystem(sys) { SYS = sys; if ($("#act-sys")) renderSys(); }
export default { id: "jobs", mount, onJobs, onSystem };
-127
View File
@@ -1,127 +0,0 @@
// models.js — "Modelle"-Ansicht (v3): Schnelltest-Chat, Modell-Tabelle, Kontext-Konfiguration.
import { api } from "../core/api.js";
import { $, badge, esc, toast, confirmModal } from "../core/ui.js";
let ALL = [];
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function capTags(caps) {
if (!caps?.length) return "";
return caps.map(c =>
c === "Code" ? `<span class="tag code">Code</span>`
: c === "Bild" ? `<span class="tag img">Bild</span>`
: `<span class="tag text">Text</span>`).join(" ");
}
function details(meta) {
if (!meta) return "";
const q = meta.quant || "?";
const c = meta.ctx ? Math.round(meta.ctx / 1024) + "K" : "?";
const s = meta.size_bytes ? (meta.size_bytes / 1024 ** 3).toFixed(1) + " GB" : "?";
return `<span class="mono-sm">${esc(q)} · ${c} · ${s}</span>`;
}
function mount() {
$("#m-head").innerHTML = `<div class="pagehead"><div>
<h1>Modelle</h1>
<div class="sub">Deine konfigurierten Modelle testen, Kontext anpassen oder aus dem Speicher werfen.</div></div></div>`;
$("#m-chat").innerHTML = `
<div class="card-h"><h3>Schnelltest</h3></div>
<div class="card-sub">Schreib eine Nachricht das gewählte Modell wird automatisch geweckt.</div>
<label>Modell</label>
<select id="chat-model"></select>
<label>Nachricht</label>
<textarea id="chat-msg" placeholder="z.B. „Erklär mir kurz, was du kannst."></textarea>
<button class="primary" id="chat-btn">Senden</button>
<div id="chat-reply" class="reply" style="display:none"></div>`;
$("#m-table").innerHTML = `
<div class="card-h"><h3>Modelle &amp; Ports</h3><span class="meta" id="m-count"></span></div>
<div class="card-sub">Modelle laden automatisch, sobald eine Anfrage kommt du musst nichts manuell starten.</div>
<table>
<thead><tr><th>Modell</th><th>Kann</th><th>Details</th><th>Status</th><th>Port</th><th style="text-align:right">Aktionen</th></tr></thead>
<tbody id="models"></tbody>
</table>
<div id="models-empty" class="empty-c" style="display:none">
<div class="e-t">Noch keine Modelle konfiguriert</div>
<div class="e-s">Hol dir unter Cookbook" ein passendes Modell.</div>
</div>
<div id="cfg-modal" class="modal-overlay" style="display:none">
<div class="modal-card">
<button id="cfg-close" class="ghost" style="position:absolute;top:14px;right:14px">Schließen</button>
<h3>Modell konfigurieren</h3>
<p class="mono-sm" id="cfg-model-name" style="margin:-4px 0 16px"></p>
<label>Kontext-Größe (Tokens)</label>
<input id="cfg-ctx" type="number" value="8192">
<div class="hint">Höhere Werte erlauben längere Texte, brauchen aber mehr Grafikspeicher.</div>
<button class="primary" id="cfg-save" style="width:100%;margin-top:6px">Speichern</button>
</div>
</div>`;
$("#chat-btn").addEventListener("click", sendChat);
$("#cfg-close").addEventListener("click", () => $("#cfg-modal").style.display = "none");
$("#cfg-modal").addEventListener("click", e => { if (e.target.id === "cfg-modal") $("#cfg-modal").style.display = "none"; });
$("#cfg-save").addEventListener("click", saveConfig);
}
function onStatus(s) {
ALL = s?.models || [];
const tb = $("#models"); if (!tb) return;
$("#models-empty").style.display = ALL.length ? "none" : "flex";
$("#m-count").textContent = ALL.length ? ALL.length + " konfiguriert" : "";
const sel = $("#chat-model"), cur = sel.value; sel.innerHTML = "";
tb.innerHTML = ALL.map(m => {
const fn = m.meta?.filename ? `<div class="li-sub mono-sm">${esc(m.meta.filename)}</div>` : "";
return `<tr>
<td class="mid" style="font-weight:500">${esc(m.name)}${fn}</td>
<td>${capTags(m.meta?.caps)}</td>
<td>${details(m.meta)}</td>
<td>${badge(m.state)}</td>
<td class="port">${m.port ?? "auto"}</td>
<td style="text-align:right;white-space:nowrap">
<button class="ghost" data-cfg="${esc(m.name)}">Konfigurieren</button>
<button class="ghost" data-unload="${esc(m.name)}">Entladen</button>
</td></tr>`;
}).join("");
for (const m of ALL) sel.insertAdjacentHTML("beforeend", `<option>${esc(m.name)}</option>`);
if (cur) sel.value = cur;
tb.querySelectorAll("[data-unload]").forEach(b => b.addEventListener("click", () => unloadOne(b.getAttribute("data-unload"))));
tb.querySelectorAll("[data-cfg]").forEach(b => b.addEventListener("click", () => openConfig(b.getAttribute("data-cfg"))));
}
function openConfig(alias) {
const m = ALL.find(x => x.name === alias); if (!m) return;
$("#cfg-model-name").textContent = m.name;
$("#cfg-ctx").value = m.meta?.ctx || 8192;
$("#cfg-modal").style.display = "flex";
}
async function saveConfig() {
const alias = $("#cfg-model-name").textContent, ctx = parseInt($("#cfg-ctx").value) || 8192;
$("#cfg-save").disabled = true;
try { await api("/api/update_model", { method: "POST", body: JSON.stringify({ alias, ctx }) }); toast("Gespeichert — aktiv beim nächsten Modell-Start."); $("#cfg-modal").style.display = "none"; refreshSoon(); }
catch (e) { toast(e.message, true); }
$("#cfg-save").disabled = false;
}
async function unloadOne(m) {
if (!await confirmModal({ title: `${m}" entladen?`, body: "Das Modell wird aus dem Grafikspeicher geworfen und lädt beim nächsten Aufruf automatisch neu.", confirmLabel: "Entladen" })) return;
try { await api("/api/unload?model=" + encodeURIComponent(m), { method: "POST" }); toast("Entladen: " + m); setTimeout(refreshSoon, 600); }
catch (e) { toast(e.message, true); }
}
async function sendChat() {
const model = $("#chat-model").value, message = $("#chat-msg").value.trim();
if (!model) return toast("Kein Modell vorhanden.", true);
if (!message) return;
const btn = $("#chat-btn"); btn.disabled = true; btn.textContent = "…";
const box = $("#chat-reply"); box.style.display = "block"; box.textContent = "(wecke Modell, kann beim Laden kurz dauern…)";
try { const r = await api("/api/chat", { method: "POST", body: JSON.stringify({ model, message }) }); box.textContent = r.reply; }
catch (e) { box.textContent = "Fehler: " + e.message; }
btn.disabled = false; btn.textContent = "Senden"; refreshSoon();
}
export default { id: "models", mount, onStatus };
-143
View File
@@ -1,143 +0,0 @@
// overview.js — Dashboard (v3): Klartext-Urteil, Metrik-Kacheln, System-Gesundheit,
// geführter Schnellstart, "Dein Stack". Speist sich aus /api/status + /api/system/stream.
import { api } from "../core/api.js";
import { $, esc, icon, toast, fmtBytes, confirmModal } from "../core/ui.js";
let S = null; // letzter Status
let SYS = null; // letzte System-Metriken
const RUNNING = new Set(["running", "ready", "loading", "starting"]);
function models() { return S?.models || []; }
function activeModel() { return models().find(m => RUNNING.has(m.state)); }
// ---- Aktionen (geführt) ----
function go(view) { document.querySelector(`.nav-item[data-view="${view}"]`)?.click(); }
async function freeMemory() {
const ok = await confirmModal({
title: "Speicher freigeben?",
body: "Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. " +
"Beim nächsten Aufruf lädt das jeweilige Modell einfach neu — es geht nichts verloren.",
confirmLabel: "Speicher freigeben",
});
if (!ok) return;
try { await api("/api/unload", { method: "POST" }); toast("Speicher freigegeben."); setTimeout(() => document.dispatchEvent(new Event("mc:refresh")), 600); }
catch (e) { toast(e.message, true); }
}
window.mcOv = { go, freeMemory };
// ---- Hero (menschliches Urteil) ----
function renderHero() {
let title = "Verbinde…", sub = "";
if (S) {
const total = models().length, act = activeModel();
if (!S.swap_ok) { title = "LLM-Engine offline"; sub = "Der llama-swap-Dienst antwortet gerade nicht."; }
else if (SYS && SYS.ram.percent >= 90) { title = "Achtung: Speicher wird knapp."; sub = `Arbeitsspeicher bei ${Math.round(SYS.ram.percent)} % — eventuell Speicher freigeben.`; }
else {
title = "Alles läuft rund.";
sub = `${total} ${total === 1 ? "Modell" : "Modelle"} bereit · ${act ? `${act.name}" geladen` : "keins geladen"} · keine Warnungen.`;
}
}
$("#ov-hero").innerHTML = `<div class="pagehead"><div>
<h1>${esc(title)}</h1><div class="sub">${esc(sub)}</div></div></div>`;
}
// ---- Metrik-Kacheln ----
function tile(label, valueHtml, sub, vCls = "", sCls = "") {
return `<div class="tile"><div class="t-l">${label}</div>
<div class="t-v ${vCls}">${valueHtml}</div><div class="t-s ${sCls}">${sub}</div></div>`;
}
function tempWord(t) { return t == null ? "" : t < 55 ? "kühl & gesund" : t < 72 ? "normal" : "läuft heiß"; }
function renderTiles() {
const act = activeModel();
const swap = S?.swap_ok;
let engine = tile("LLM-Engine", swap ? "Online" : "Offline", "llama-swap", swap ? "ok" : "bad");
let model = tile("Aktives Modell", act ? esc(act.name) : "Keins",
act ? "im Grafikspeicher" : "nichts geladen");
let temp, mem;
if (SYS) {
const t = SYS.gpu_temp ?? SYS.cpu.temp;
temp = tile("GPU-Temperatur", t != null ? `${Math.round(t)}°` : "", tempWord(t),
"", t != null && t < 72 ? "ok" : "");
const free = SYS.ram.total - SYS.ram.used;
mem = tile("Freier Speicher", `${(free / 1024 ** 3).toFixed(0)}<small> GB</small>`,
`von ${(SYS.ram.total / 1024 ** 3).toFixed(0)} GB`);
} else {
temp = tile("GPU-Temperatur", "", "messe…");
mem = tile("Freier Speicher", "", "messe…");
}
$("#ov-tiles").innerHTML = engine + model + temp + mem;
}
// ---- System-Gesundheit ----
function meter(label, pct) {
const p = Math.max(0, Math.min(100, pct || 0));
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span>
<span class="mv">${Math.round(p)} %</span></div>
<div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
}
function renderHealth() {
const head = `<div class="card-h"><h3>System-Gesundheit</h3></div>
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>`;
if (!SYS) { $("#ov-health").innerHTML = head + `<div class="empty">Warte auf Messwerte…</div>`; return; }
let gpuPct = 0;
const g = SYS.gpu;
if (g && (g.vram.total + g.gtt.total) > 0)
gpuPct = ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
$("#ov-health").innerHTML = head +
meter("Prozessor (CPU)", SYS.cpu.percent) +
meter("Arbeitsspeicher (RAM)", SYS.ram.percent) +
meter("Grafikspeicher (VRAM)", gpuPct);
}
// ---- Schnellstart ----
function qa(ic, tone, title, sub, onclick) {
return `<button class="qa" onclick="${onclick}">
<span class="qa-ic ${tone}">${icon(ic)}</span>
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${sub}</span></span>
<span class="qa-arrow">${icon("chevron")}</span></button>`;
}
function renderQuickstart() {
$("#ov-quickstart").innerHTML =
`<div class="card-h"><h3>Schnellstart</h3></div>
<div class="card-sub">Die häufigsten Aufgaben ein Klick.</div>` +
qa("search", "teal", "Modell finden", "Passend zu deiner Hardware", "window.mcOv.go('cookbook')") +
qa("refresh", "amber", "Speicher freigeben", "Modelle entladen · fragt vorher nach", "window.mcOv.freeMemory()") +
qa("file", "blue", "Logs ansehen", "Live mitlesen, was läuft", "window.mcOv.go('server')");
}
// ---- Dein Stack ----
function capTag(caps) {
if (!caps) return "";
if (caps.includes("Bild")) return `<span class="tag img">Bild</span>`;
if (caps.includes("Code")) return `<span class="tag code">Code</span>`;
return `<span class="tag text">Text</span>`;
}
function stackRow(m) {
const on = RUNNING.has(m.state);
const dot = m.state === "loading" || m.state === "starting" ? "load" : on ? "on" : "";
const status = on ? (m.state === "loading" ? "lädt…" : `geladen${m.port ? " · Port " + m.port : ""}`) : "bereit";
return `<div class="li"><span class="li-dot ${dot}"></span>
<span class="li-id" style="flex:1">${esc(m.name)}</span>
${capTag(m.meta?.caps)}
<span class="li-meta" style="width:120px;text-align:right">${status}</span></div>`;
}
function renderStack() {
const ms = models();
$("#ov-stack").innerHTML =
`<div class="card-h"><h3>Dein Stack</h3><span class="meta">${ms.length ? ms.length + " konfiguriert" : ""}</span></div>` +
(ms.length
? `<div class="list">${ms.map(stackRow).join("")}</div>`
: `<div class="empty-c"><div class="e-t">Noch keine Modelle</div>
<div class="e-s">Hol dir unter Cookbook" ein Modell, das auf deine Hardware passt.</div></div>`);
}
function renderAll() { renderHero(); renderTiles(); renderHealth(); renderQuickstart(); renderStack(); }
function mount() { renderQuickstart(); renderAll(); }
function onStatus(s) { S = s; renderHero(); renderTiles(); renderStack(); }
function onSystem(sys) { SYS = sys; renderHero(); renderTiles(); renderHealth(); }
export default { id: "overview", mount, onStatus, onSystem };
-109
View File
@@ -1,109 +0,0 @@
// server.js — Server & Wartung (v3): Dienste, OS-Updates, Reboot, Live-Konsole.
// Selbsterklärende Aktionszeilen + heikle Aktionen mit Klartext-Bestätigung.
import { api, getToken } from "../core/api.js";
import { $, toast, icon, confirmModal, promptModal } from "../core/ui.js";
import { track } from "./jobs.js";
function refreshSoon() { document.dispatchEvent(new Event("mc:refresh")); }
function row(id, ic, tone, title, desc) {
return `<button class="qa${tone === "red" ? " qa-danger" : ""}" id="${id}">
<span class="qa-ic ${tone}">${icon(ic)}</span>
<span class="qa-main"><span class="qa-t">${title}</span><span class="qa-s">${desc}</span></span>
<span class="qa-arrow">${icon("chevron")}</span></button>`;
}
function mount() {
$("#wartung").outerHTML = `<div id="wartung" style="display:flex;flex-direction:column;gap:var(--sp-4)">
<div class="pagehead"><div>
<h1>Server &amp; Wartung</h1>
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen ganz ohne SSH oder Terminal.</div>
</div></div>
<div class="card">
<div class="card-h"><h3>Dienste &amp; Applikation</h3></div>
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos es geht nichts dabei verloren.</div>
${row("w-restart-swap", "refresh", "teal", "LLM-Engine neustarten", "Startet llama-swap neu. Geladene Modelle werden kurz entladen (~5 Sek), laden danach automatisch wieder.")}
${row("w-restart-mc", "refresh", "blue", "Dashboard neustarten", "Startet diese Oberfläche neu. Sie trennt kurz und verbindet automatisch wieder.")}
${row("w-update", "download", "teal", "LLM-Engine aktualisieren", "Lädt die neueste llama.cpp-Version (ROCm) für deine GPU und installiert sie.")}
${row("w-unload", "database", "amber", "Grafikspeicher leeren", "Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.")}
</div>
<div class="card">
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="card-sub">Tiefe Eingriffe das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
${row("w-os-update", "refresh", "amber", "OS-Updates installieren", "Führt apt update &amp; upgrade aus. Kann ein paar Minuten dauern — Fortschritt in der Aktivität.")}
${row("w-reboot", "bolt", "red", "Server neustarten (Reboot)", "Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline, auch dieses Dashboard.")}
</div>
<div class="card">
<div class="card-h"><h3>Live-Konsole</h3>
<select id="w-console-sel" style="margin:0 0 0 auto;width:240px">
<option value="llama-swap">LLM-Engine (llama-swap)</option>
<option value="mission-control">Dashboard (mission-control)</option>
<option value="system">System (ganzer Server)</option>
</select>
</div>
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
<div id="w-console" class="console">Verbinde</div>
</div>
</div>`;
$("#w-restart-swap").addEventListener("click", () => restartService("llama-swap",
"LLM-Engine neustarten?", "Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder."));
$("#w-restart-mc").addEventListener("click", () => restartService("mission-control",
"Dashboard neustarten?", "Diese Oberfläche trennt sich kurz und verbindet automatisch wieder. Laufende Downloads laufen weiter."));
$("#w-update").addEventListener("click", update);
$("#w-unload").addEventListener("click", unloadAll);
$("#w-os-update").addEventListener("click", osUpdate);
$("#w-reboot").addEventListener("click", rebootServer);
$("#w-console-sel").addEventListener("change", connectConsole);
connectConsole();
}
let ws = null;
function connectConsole() {
if (ws) { ws.close(); ws = null; }
const svc = $("#w-console-sel").value, out = $("#w-console");
out.textContent = "Verbinde mit " + svc + "…\n";
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`);
ws.onmessage = e => { out.textContent += e.data; out.scrollTop = out.scrollHeight; };
ws.onclose = () => { out.textContent += "\n— Verbindung getrennt —"; };
}
async function restartService(name, title, body) {
if (!await confirmModal({ title, body, confirmLabel: "Neustarten" })) return;
try { await api(`/api/service/${name}/restart`, { method: "POST" }); toast("Neustart ausgelöst: " + name); setTimeout(refreshSoon, 2000); }
catch (e) { toast(e.message, true); }
}
async function unloadAll() {
if (!await confirmModal({ title: "Grafikspeicher leeren?", body: "Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.", confirmLabel: "Leeren" })) return;
try { await api("/api/unload", { method: "POST" }); toast("Grafikspeicher geleert."); setTimeout(refreshSoon, 600); }
catch (e) { toast(e.message, true); }
}
async function update() {
try { const r = await api("/api/update", { method: "POST" }); toast("llama.cpp-Update läuft — siehe Aktivität."); track(r.job_id); }
catch (e) { toast(e.message, true); }
}
async function osUpdate() {
if (!await confirmModal({ title: "OS-Updates installieren?", body: "Führt <code>apt update &amp; upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität." })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Installieren" });
if (!pwd) return;
try { const r = await api("/api/os-update", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("OS-Update gestartet."); track(r.job_id); }
catch (e) { toast(e.message, true); }
}
async function rebootServer() {
if (!await confirmModal({ title: "Server wirklich neu starten?", body: "Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.", confirmLabel: "Reboot", danger: true })) return;
const pwd = await promptModal({ title: "sudo-Passwort", body: "Für den Reboot wird einmalig dein sudo-Passwort gebraucht.", placeholder: "sudo-Passwort", password: true, confirmLabel: "Jetzt neustarten", danger: true });
if (!pwd) return;
try { await api("/api/reboot", { method: "POST", body: JSON.stringify({ password: pwd }) }); toast("Reboot ausgelöst — bis gleich."); }
catch (e) { toast(e.message, true); }
}
export default { id: "server", mount };