feat: show RSS headlines as clickable links in the transcript

read_news bisher zeigte nur Klartext-Titel in der Karte an — man
konnte im Chat selbst nicht auf den Artikel klicken (nur das Modell
kannte den Link, für ein mögliches read_article danach). Jetzt trägt
der tool_result-Event zusätzlich strukturierte 'items' (source, title,
link), das Frontend rendert daraus eine Liste mit echten <a>-Links
statt reinem Text. read_article bleibt unverändert bei reinem Text
(items bleibt leer, kein Link zum Verlinken vorhanden).

Verifiziert: die Rendering-Logik direkt per simuliertem Datenkanal-
Event getestet (unabhängig von der Modell-Zuverlässigkeit, die bei
read_news aktuell bei ~15-20% liegt) — zwei Links korrekt mit href,
target=_blank, rel=noopener und Quelle gerendert.

NICHT gepusht — weiterhin nur lokal, wie angewiesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
This commit is contained in:
Jeuner 2026-09-07 18:36:09 +02:00
parent 0d2e826dd3
commit b0282900d2
4 changed files with 46 additions and 9 deletions

View file

@ -47,8 +47,15 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
notify({"type": "tool_error", "text": str(exc)}) notify({"type": "tool_error", "text": str(exc)})
await params.result_callback({"error": str(exc)}) await params.result_callback({"error": str(exc)})
return return
headlines = "\n".join(f"- ({entry.source}) {entry.title}" for entry in entries) notify(
notify({"type": "tool_result", "text": headlines}) {
"type": "tool_result",
"text": "\n".join(f"- ({entry.source}) {entry.title}" for entry in entries),
"items": [
{"source": e.source, "title": e.title, "link": e.link} for e in entries
],
}
)
await params.result_callback( await params.result_callback(
{ {
"status": "ok", "status": "ok",

View file

@ -98,6 +98,10 @@ async def test_read_news_tool_reports_headlines(monkeypatch):
assert [n["type"] for n in notifications] == ["activity", "tool_start", "tool_result"] assert [n["type"] for n in notifications] == ["activity", "tool_start", "tool_result"]
assert "KI-Durchbruch" in notifications[2]["text"] assert "KI-Durchbruch" in notifications[2]["text"]
assert "Neuer Chip" in notifications[2]["text"] assert "Neuer Chip" in notifications[2]["text"]
assert notifications[2]["items"] == [
{"source": "heise online", "title": "KI-Durchbruch", "link": "https://x"},
{"source": "Golem.de", "title": "Neuer Chip", "link": "https://y"},
]
assert params.results[0]["status"] == "ok" assert params.results[0]["status"] == "ok"
assert len(params.results[0]["headlines"]) == 2 assert len(params.results[0]["headlines"]) == 2

View file

@ -62,7 +62,7 @@ function addToolError(text) {
} }
showError(text); showError(text);
} }
function addToolResult(text) { function addToolResult(text, items) {
const pending = document.getElementById("pending-tool"); const pending = document.getElementById("pending-tool");
const article = pending || document.createElement("article"); const article = pending || document.createElement("article");
if (pending) { if (pending) {
@ -76,10 +76,36 @@ function addToolResult(text) {
const speaker = document.createElement("span"); const speaker = document.createElement("span");
speaker.className = "speaker"; speaker.className = "speaker";
speaker.textContent = "Astra"; speaker.textContent = "Astra";
const body = document.createElement("p"); article.append(speaker);
body.className = "tool-result"; if (items?.length) {
body.textContent = text; const list = document.createElement("ul");
article.append(speaker, body); list.className = "tool-result-list";
for (const item of items) {
const li = document.createElement("li");
if (item.link) {
const link = document.createElement("a");
link.href = item.link;
link.target = "_blank";
link.rel = "noopener";
link.textContent = item.title;
li.append(link);
} else {
li.textContent = item.title;
}
if (item.source) {
const source = document.createElement("small");
source.textContent = ` (${item.source})`;
li.append(source);
}
list.append(li);
}
article.append(list);
} else {
const body = document.createElement("p");
body.className = "tool-result";
body.textContent = text;
article.append(body);
}
if (!pending) $("messages").append(article); if (!pending) $("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight; $("messages").scrollTop = $("messages").scrollHeight;
} }
@ -148,7 +174,7 @@ function receive(event) {
if (message.type === "activity" && !muted) $("status").textContent = message.text; if (message.type === "activity" && !muted) $("status").textContent = message.text;
if (message.type === "tool_start") addToolStart(message.text); if (message.type === "tool_start") addToolStart(message.text);
if (message.type === "tool_error") addToolError(message.text); if (message.type === "tool_error") addToolError(message.text);
if (message.type === "tool_result") addToolResult(message.text); if (message.type === "tool_result") addToolResult(message.text, message.items);
if (message.type === "partial") $("partial").textContent = message.text; if (message.type === "partial") $("partial").textContent = message.text;
if (message.type === "transcript") addMessage(message); if (message.type === "transcript") addMessage(message);
if (message.type === "image") addImage(message.url); if (message.type === "image") addImage(message.url);

File diff suppressed because one or more lines are too long