feat(citations): Web-Such-Quellen unter der Antwort-Bubble anzeigen

Sonar liefert Citation-URLs; bisher verworfen, jetzt bis in die UI gereicht.

- ToolCallingLLM: _run_tool/_inject_forced_search geben Citations zurueck;
  stream() sammelt sie ueber einen on_citations-Callback.
- Orchestrator: chat_stream reicht on_citations durch (via _stream_supports)
  und legt die gesammelten URLs in PipelineTrace.citations.
- schemas: PipelineTrace.citations.
- ws.py: Citations im semantic-Event.
- Frontend (app.js): renderCitations() zeigt bis zu 4 Quellen-Links
  (Hostname, neuer Tab) unter der Antwort-Bubble.

Nur ueber den Streaming-Pfad (= Web-UI). Live verifiziert: "Wer ist
Bundeskanzler?" -> 15 Quellen gesammelt (Bundeskanzler.de, Wikipedia, ...).
Tests: 312 gruen; JS-Syntax geprueft. Doc §8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 00:11:24 +02:00
commit e753788a23
6 changed files with 65 additions and 15 deletions

View file

@ -513,6 +513,32 @@ function addMessage(role, text, meta = {}) {
return div;
}
// Quellen-Links der Web-Suche unter die Antwort-Bubble (max. 4, idempotent).
function renderCitations(bubble, urls) {
if (!bubble || !Array.isArray(urls) || !urls.length) return;
let box = bubble._citationsEl;
if (!box) {
box = document.createElement("div");
box.className = "citations mt-1 text-xs text-slate-500 dark:text-slate-400 "
+ "flex flex-wrap gap-x-2 gap-y-0.5";
bubble._citationsEl = box;
bubble.appendChild(box);
}
box.innerHTML = "";
const label = document.createElement("span");
label.textContent = "Quellen:";
box.appendChild(label);
urls.slice(0, 4).forEach((u) => {
let host;
try { host = new URL(u).hostname.replace(/^www\./, ""); } catch { host = u; }
const a = document.createElement("a");
a.href = u; a.target = "_blank"; a.rel = "noopener noreferrer";
a.textContent = host;
a.className = "underline hover:text-slate-700 dark:hover:text-white";
box.appendChild(a);
});
}
// ---------- Vorlesen / Replay ----------
function setReplayPlaying(btn, on) {
if (!btn) return;
@ -704,6 +730,7 @@ function runTurn(path, onopen) {
case "semantic":
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
if (msg.text) answerEl._textEl.textContent = msg.text;
if (msg.citations) renderCitations(answerEl, msg.citations);
// Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen.
// Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen
// bleibt der Originaltext.