astra-vision/web/app.js
Jeuner 1656d26ea6 feat: numbered headlines + article-reference trigger ("Artikel 2")
Schlagzeilen aus read_news sind jetzt nummeriert (1., 2., 3. ...) statt
einer reinen Aufzählung, per <ol> im Frontend. astra/triggers.py bekommt
einen zweiten deterministischen Trigger, detect_article_reference():
erkennt "Artikel 2" / "Artikel Nummer drei" / "zweiter Artikel" /
"Detail zu Schlagzeile 4" (Ziffern und deutsche Zahlwörter 1-10) und
löst die Nummer gegen die zuletzt in dieser Session gezeigte
Schlagzeilenliste auf.

trigger_tool() gibt jetzt das Tool-Ergebnis zurück statt es nur in den
Kontext zu injizieren, damit server.py die read_news-Headlines (mit
Nummer + Link) für den Session-lokalen last_headlines-Zustand behalten
kann. Trifft der Artikel-Trigger, wird derselbe Mechanismus wie beim
News-Trigger genutzt, um read_article direkt mit dem passenden Link
aufzurufen.

Live per echtem Zwei-Turn-Dialog mit synthetisierter Sprache verifiziert
("Gib mir bitte die aktuellen News Hilden." → "Hole mir Detail zu
Artikel zwei."): vier nummerierte, klickbare Hilden-Schlagzeilen,
danach korrekt zu Artikel 2 aufgelöst und der echte volle Artikeltext
(Pedelec-Kurs-Details, Helmpflicht, Termine) abgerufen und angezeigt —
kein einziger erfundener Inhalt.

NICHT gepusht — weiterhin nur lokal, wie angewiesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
2026-09-07 18:56:20 +02:00

368 lines
15 KiB
JavaScript

"use strict";
const $ = (id) => document.getElementById(id);
const labels = {listening: "Ich höre dir zu.", responding: "Einen Moment …", speaking: "Astra spricht. Du kannst jederzeit unterbrechen."};
let peer = null, stream = null, channel = null, pcId = null, heartbeat = null;
let connecting = false, disconnecting = false, muted = false, ready = false;
const output = new Audio();
output.autoplay = true;
function state(value) {
document.body.dataset.state = value;
$("status").textContent = muted ? "Mikrofon pausiert" : (labels[value] || value);
}
function showError(message) {
$("error").textContent = message;
$("error").hidden = false;
}
function addMessage(event) {
$("messages").querySelector(".empty")?.remove();
const article = document.createElement("article");
article.className = `message ${event.role === "user" ? "user" : "assistant"}`;
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = event.role === "user" ? "Du" : "Astra";
const text = document.createElement("p");
text.textContent = event.text;
article.append(speaker, text);
if (event.interrupted) {
const note = document.createElement("small");
note.textContent = "Unterbrochen";
article.append(note);
}
$("messages").append(article);
while ($("messages").children.length > 80) $("messages").firstElementChild.remove();
$("messages").scrollTop = $("messages").scrollHeight;
}
function addToolStart(text) {
$("messages").querySelector(".empty")?.remove();
document.getElementById("pending-tool")?.remove();
const article = document.createElement("article");
article.className = "message assistant pending-tool";
article.id = "pending-tool";
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = "Astra";
const body = document.createElement("p");
body.className = "pending-text";
const spinner = document.createElement("i");
spinner.className = "spinner";
body.append(spinner, document.createTextNode(` ${text}`));
article.append(speaker, body);
$("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight;
}
function addToolError(text) {
const pending = document.getElementById("pending-tool");
if (pending) {
pending.removeAttribute("id");
pending.classList.remove("pending-tool");
pending.querySelector(".pending-text").textContent = text;
$("messages").scrollTop = $("messages").scrollHeight;
return;
}
showError(text);
}
function addToolResult(text, items) {
const pending = document.getElementById("pending-tool");
const article = pending || document.createElement("article");
if (pending) {
pending.removeAttribute("id");
pending.className = "message assistant";
pending.replaceChildren();
} else {
$("messages").querySelector(".empty")?.remove();
article.className = "message assistant";
}
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = "Astra";
article.append(speaker);
if (items?.length) {
const list = document.createElement("ol");
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);
$("messages").scrollTop = $("messages").scrollHeight;
}
function addImage(url) {
const pending = document.getElementById("pending-tool");
const article = pending || document.createElement("article");
if (pending) {
pending.removeAttribute("id");
pending.className = "message assistant";
pending.replaceChildren();
} else {
$("messages").querySelector(".empty")?.remove();
article.className = "message assistant";
}
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = "Astra";
const img = document.createElement("img");
img.className = "generated-image";
img.src = url;
img.alt = "Von Astra erzeugtes Bild";
article.append(speaker, img);
if (!pending) $("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight;
}
function addUploadNote(filename) {
$("messages").querySelector(".empty")?.remove();
const article = document.createElement("article");
article.className = "message user";
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = "Du";
const text = document.createElement("p");
text.textContent = `Hochgeladen: ${filename}`;
article.append(speaker, text);
$("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight;
}
function addUploadedImage(dataUrl, filename) {
$("messages").querySelector(".empty")?.remove();
const article = document.createElement("article");
article.className = "message user";
const speaker = document.createElement("span");
speaker.className = "speaker";
speaker.textContent = "Du";
const img = document.createElement("img");
img.className = "generated-image";
img.src = dataUrl;
img.alt = filename;
article.append(speaker, img);
$("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight;
}
function fileToDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function receive(event) {
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "state") state(message.state);
if (message.type === "activity" && !muted) $("status").textContent = message.text;
if (message.type === "tool_start") addToolStart(message.text);
if (message.type === "tool_error") addToolError(message.text);
if (message.type === "tool_result") addToolResult(message.text, message.items);
if (message.type === "partial") $("partial").textContent = message.text;
if (message.type === "transcript") addMessage(message);
if (message.type === "image") addImage(message.url);
if (message.type === "upload") addUploadNote(message.filename);
if (message.type === "error") showError(message.message);
if (message.type === "metric" && message.name === "llm_ms") {
$("latency").textContent = `Erstes Antwortwort · ${(message.value / 1000).toFixed(2)} s`;
}
}
async function uploadFile(file) {
if (!pcId) return;
if (file.type.startsWith("image/")) {
try { addUploadedImage(await fileToDataURL(file), file.name); }
catch { /* preview failed to render; upload still proceeds below */ }
}
const body = new FormData();
body.append("pc_id", pcId);
body.append("file", file);
try {
const response = await fetch("/api/upload", {method: "POST", body, signal: AbortSignal.timeout(30000)});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : "Upload fehlgeschlagen.");
} catch (error) {
showError(error.message || "Upload fehlgeschlagen.");
}
}
async function request(path, body, timeout = 20000) {
const response = await fetch(path, {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify(body), signal: AbortSignal.timeout(timeout)});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === "string" ? data.detail : "Verbindung fehlgeschlagen.");
return data;
}
async function waitIce(pc) {
if (pc.iceGatheringState === "complete") return;
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error("Lokaler Verbindungsaufbau dauert zu lange.")); }, 10000);
function cleanup() { clearTimeout(timeout); pc.removeEventListener("icegatheringstatechange", check); }
function check() { if (pc.iceGatheringState === "complete") { cleanup(); resolve(); } }
pc.addEventListener("icegatheringstatechange", check);
check();
});
}
async function loadVoices() {
try {
const response = await fetch("/api/voices", {signal: AbortSignal.timeout(5000)});
const data = await response.json();
const groups = {weiblich: document.createElement("optgroup"), männlich: document.createElement("optgroup")};
groups.weiblich.label = "Weiblich";
groups.männlich.label = "Männlich";
for (const voice of data.voices) {
const option = document.createElement("option");
option.value = voice.name;
option.textContent = voice.display_name;
groups[voice.gender]?.append(option);
}
$("voice").replaceChildren(groups.weiblich, groups.männlich);
const saved = localStorage.getItem("astra-voice");
$("voice").value = data.voices.some(v => v.name === saved) ? saved : data.default;
} catch { showError("Stimmenliste konnte nicht geladen werden."); }
}
$("voice").addEventListener("change", () => {
try { localStorage.setItem("astra-voice", $("voice").value); } catch { /* ignore */ }
});
async function connect() {
if (connecting || peer) return;
connecting = true;
$("error").hidden = true;
$("connect").disabled = true;
$("voice").disabled = true;
state("Mikrofon wird verbunden …");
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error("Bitte diese Seite unter http://localhost:7860 öffnen.");
stream = await navigator.mediaDevices.getUserMedia({audio: {
echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1,
}, video: false});
const pc = new RTCPeerConnection({iceServers: []});
peer = pc;
stream.getTracks().forEach(track => pc.addTrack(track, stream));
channel = pc.createDataChannel("astra");
channel.onmessage = receive;
channel.onopen = () => {
heartbeat = setInterval(() => { if (channel?.readyState === "open") channel.send("ping"); }, 1000);
};
pc.ontrack = (event) => {
output.srcObject = event.streams[0] || new MediaStream([event.track]);
output.play().catch(() => showError("Audioausgabe blockiert. Bitte die Audiowiedergabe im Browser erlauben und neu starten."));
};
pc.onconnectionstatechange = () => {
if (peer !== pc) return;
if (pc.connectionState === "connected") state("listening");
if (["failed", "disconnected", "closed"].includes(pc.connectionState) && !disconnecting) {
showError("Verbindung beendet. Du kannst das Gespräch erneut starten.");
void disconnect();
}
};
await pc.setLocalDescription(await pc.createOffer());
await waitIce(pc);
const answer = await request("/api/offer", {sdp: pc.localDescription.sdp, type: "offer", voice: $("voice").value});
pcId = answer.pc_id;
await pc.setRemoteDescription({sdp: answer.sdp, type: answer.type});
$("connect").textContent = "Gespräch beenden";
$("mute").hidden = false;
$("attach").hidden = false;
$("clear").textContent = "Neues Gespräch";
$("hint").textContent = "Sprich frei. Beim Dazwischenreden hält Astra an.";
} catch (error) {
await disconnect();
const messages = {NotAllowedError: "Mikrofonzugriff nicht erlaubt. Bitte in den Browser-Einstellungen freigeben.",
NotFoundError: "Kein Mikrofon gefunden. Bitte ein Mikrofon anschließen.",
NotReadableError: "Das Mikrofon ist gerade nicht verfügbar."};
showError(messages[error.name] || error.message);
} finally {
connecting = false;
$("connect").disabled = !ready;
}
}
async function disconnect() {
if (disconnecting) return;
disconnecting = true;
const id = pcId;
pcId = null;
clearInterval(heartbeat);
heartbeat = null;
stream?.getTracks().forEach(track => track.stop());
stream = null;
const oldPeer = peer;
peer = null;
channel?.close();
channel = null;
oldPeer?.close();
output.pause();
output.srcObject = null;
muted = false;
$("voice").disabled = !ready;
$("mute").hidden = true;
$("mute").setAttribute("aria-pressed", "false");
$("mute").textContent = "Mikrofon pausieren";
$("attach").hidden = true;
$("connect").textContent = "Gespräch starten";
$("clear").textContent = "Verlauf leeren";
$("partial").textContent = "";
$("hint").textContent = "Mikrofon ist aus. Beim nächsten Start beginnt ein neues Gespräch.";
state("Bereit, wenn du es bist.");
try { if (id) await request("/api/disconnect", {pc_id: id}, 10000); }
catch { showError("Der Server hat das Beenden nicht bestätigt. Das Mikrofon ist aus; bitte kurz warten, bevor du neu startest."); }
finally { disconnecting = false; }
}
$("connect").addEventListener("click", () => { if (peer) void disconnect(); else void connect(); });
$("mute").addEventListener("click", () => {
muted = !muted;
stream?.getAudioTracks().forEach(track => { track.enabled = !muted; });
$("mute").setAttribute("aria-pressed", String(muted));
$("mute").textContent = muted ? "Mikrofon aktivieren" : "Mikrofon pausieren";
state("listening");
});
$("attach").addEventListener("click", () => $("file-input").click());
$("file-input").addEventListener("change", async () => {
const file = $("file-input").files[0];
$("file-input").value = "";
if (file) await uploadFile(file);
});
$("clear").addEventListener("click", async () => {
const reconnect = Boolean(peer);
if (reconnect) await disconnect();
$("messages").replaceChildren();
if (reconnect) await connect();
});
window.addEventListener("pagehide", () => {
stream?.getTracks().forEach(track => track.stop());
peer?.close();
});
async function poll() {
try {
const response = await fetch("/api/status", {signal: AbortSignal.timeout(5000)});
if (!response.ok) throw new Error("Status nicht verfügbar");
const status = await response.json();
ready = status.ready;
if (!peer && !connecting) {
$("connect").disabled = !ready;
$("voice").disabled = !ready;
state(ready ? "Bereit, wenn du es bist." : status.stage);
if (status.error) showError(status.error);
}
} catch {
ready = false;
if (!peer) { $("connect").disabled = true; $("voice").disabled = true; state("Lokaler Server nicht erreichbar."); }
} finally { setTimeout(poll, ready ? 5000 : 1500); }
}
void poll();
void loadVoices();