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:
@@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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")
|
app = FastAPI(title="Mission Control")
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ app.include_router(maintenance.router)
|
|||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(cookbook.router)
|
app.include_router(cookbook.router)
|
||||||
app.include_router(integration.router)
|
app.include_router(integration.router)
|
||||||
|
app.include_router(news.router)
|
||||||
|
|
||||||
_STATIC = Path(__file__).parent / "static"
|
_STATIC = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
||||||
@@ -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="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="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="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="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="side-foot">
|
<div class="side-foot">
|
||||||
@@ -77,6 +78,10 @@
|
|||||||
<!-- Wird von connect.js gerendert -->
|
<!-- Wird von connect.js gerendert -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="view" data-view="news" hidden>
|
||||||
|
<!-- Wird von news.js gerendert -->
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="view" data-view="guides" hidden>
|
<section class="view" data-view="guides" hidden>
|
||||||
<!-- Wird von guides.js gerendert -->
|
<!-- Wird von guides.js gerendert -->
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+2
-1
@@ -11,9 +11,10 @@ import server from "./panels/server.js";
|
|||||||
import jobs from "./panels/jobs.js";
|
import jobs from "./panels/jobs.js";
|
||||||
import cookbook from "./panels/cookbook.js";
|
import cookbook from "./panels/cookbook.js";
|
||||||
import connect from "./panels/connect.js";
|
import connect from "./panels/connect.js";
|
||||||
|
import news from "./panels/news.js";
|
||||||
import guides from "./panels/guides.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 lastJobs = [];
|
||||||
let lastSystem = null;
|
let lastSystem = null;
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user