diff --git a/app.py b/app.py index c245b3e..185c5ba 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from routers import jobs, maintenance, models, system, cookbook, integration +from routers import jobs, maintenance, models, system, cookbook, integration, news app = FastAPI(title="Mission Control") @@ -42,6 +42,7 @@ 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) _STATIC = Path(__file__).parent / "static" diff --git a/routers/news.py b/routers/news.py new file mode 100644 index 0000000..6e64341 --- /dev/null +++ b/routers/news.py @@ -0,0 +1,98 @@ +""" +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"), + ("llama.cpp", "https://github.com/ggml-org/llama.cpp/releases.atom"), + ("Ollama", "https://github.com/ollama/ollama/releases.atom"), + ("r/LocalLLaMA", "https://www.reddit.com/r/LocalLLaMA/.rss"), + ("Simon Willison", "https://simonwillison.net/atom/everything/"), +] +_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()} diff --git a/static/index.html b/static/index.html index ace5eeb..d5a99ae 100644 --- a/static/index.html +++ b/static/index.html @@ -20,6 +20,7 @@ Server Cookbook Verbinden + News Guides
@@ -77,6 +78,10 @@ + + diff --git a/static/js/main.js b/static/js/main.js index 98fd516..d97e959 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -11,9 +11,10 @@ import server from "./panels/server.js"; import jobs from "./panels/jobs.js"; import cookbook from "./panels/cookbook.js"; import connect from "./panels/connect.js"; +import news from "./panels/news.js"; import guides from "./panels/guides.js"; -const panels = [overview, models, server, jobs, cookbook, connect, guides]; +const panels = [overview, models, server, jobs, cookbook, connect, news, guides]; let lastJobs = []; let lastSystem = null; diff --git a/static/js/panels/news.js b/static/js/panels/news.js new file mode 100644 index 0000000..67a4057 --- /dev/null +++ b/static/js/panels/news.js @@ -0,0 +1,47 @@ +// news.js — News-Board (v4): kuratierte Neuigkeiten rund um lokale LLMs. + +import { api } from "../core/api.js"; +import { $, esc, ago } from "../core/ui.js"; + +function mount() { + $(".view[data-view='news']").innerHTML = ` +
+

News

+
Neuigkeiten rund um lokale LLMs — automatisch aus kuratierten Quellen (HuggingFace, llama.cpp, Ollama, r/LocalLLaMA …).
+ +
+
+
Lade News…
+
`; + $("#news-refresh").addEventListener("click", load); + load(); +} + +function card(n) { + const date = n.ts ? ago(n.ts) : ""; + const rel = n.relevant ? ' · für deine Hardware' : ""; + return ` +
+
${esc(n.title)}
+
${esc(n.source)}${rel}
+
+
${esc(date)}
+
`; +} + +async function load() { + const el = $("#news-list"); if (!el) return; + el.innerHTML = `
Lade News… (erstes Laden kann kurz dauern)
`; + try { + const d = await api("/api/news"); + const items = d.items || []; + el.innerHTML = items.length + ? `
${items.map(card).join("")}
` + : `
Keine News geladen
+
Die Quellen sind gerade nicht erreichbar — später nochmal probieren.
`; + } catch (e) { + el.innerHTML = `
${esc(e.message)}
`; + } +} + +export default { id: "news", mount };