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>
This commit is contained in:
Hitonabi
2026-06-21 13:44:46 +02:00
parent 2a60def469
commit 26dac1a10b
5 changed files with 154 additions and 2 deletions
+2 -1
View File
@@ -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"
+98
View File
@@ -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()}
+5
View File
@@ -20,6 +20,7 @@
<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>
</nav>
<div class="side-foot">
@@ -77,6 +78,10 @@
<!-- Wird von connect.js gerendert -->
</section>
<section class="view" data-view="news" hidden>
<!-- Wird von news.js gerendert -->
</section>
<section class="view" data-view="guides" hidden>
<!-- Wird von guides.js gerendert -->
</section>
+2 -1
View File
@@ -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;
+47
View File
@@ -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 = `
<div class="pagehead">
<div><h1>News</h1>
<div class="sub">Neuigkeiten rund um lokale LLMs — automatisch aus kuratierten Quellen (HuggingFace, llama.cpp, Ollama, r/LocalLLaMA …).</div></div>
<button class="ghost" id="news-refresh">Aktualisieren</button>
</div>
<div class="card"><div id="news-list">
<div class="empty" style="text-align:center;padding:40px">Lade News…</div>
</div></div>`;
$("#news-refresh").addEventListener("click", load);
load();
}
function card(n) {
const date = n.ts ? ago(n.ts) : "";
const rel = n.relevant ? ' · <span class="text-accent">für deine Hardware</span>' : "";
return `<a class="li" href="${esc(n.link)}" target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
<div class="li-main">
<div style="font-size:14px;line-height:1.4">${esc(n.title)}</div>
<div class="li-sub">${esc(n.source)}${rel}</div>
</div>
<div class="li-right"><div class="li-time">${esc(date)}</div></div>
</a>`;
}
async function load() {
const el = $("#news-list"); if (!el) return;
el.innerHTML = `<div class="empty" style="text-align:center;padding:40px">Lade News… (erstes Laden kann kurz dauern)</div>`;
try {
const d = await api("/api/news");
const items = d.items || [];
el.innerHTML = items.length
? `<div class="list">${items.map(card).join("")}</div>`
: `<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>`;
} catch (e) {
el.innerHTML = `<div class="alert err" style="margin:0">${esc(e.message)}</div>`;
}
}
export default { id: "news", mount };