mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
Zwei neue, sauber getrennte Fähigkeiten, jede in ihrem eigenen Modul: - astra/comfyui.py: async HTTP-Client für einen lokalen ComfyUI-Server (z-image-turbo-Workflow), kennt nichts von der Pipeline. - astra/documents.py: PDF-Textextraktion via pypdf, keine Netzwerkzugriffe. - astra/tools.py: verdrahtet generate_image als natives Ollama-Tool-Call — Qwen 3.5 unterstützt Tools und Vision bereits nativ laut `ollama show`. Dafür wurde astra/services.py so erweitert, dass NativeOllamaService Ollamas native tool_calls im Streaming-Response erkennt und als ChatCompletionChunk-Deltas an Pipecats bereits vorhandene, generische Function-Calling-Maschinerie (_process_context/run_function_calls) weiterreicht — die musste dafür nicht angefasst werden. trim_messages in core.py bewahrt jetzt Tool-Roundtrips und Bild-Anhänge vollständig statt sie auf role/content zu reduzieren. Neuer Upload-Button im UI (Bild oder PDF, während eines laufenden Gesprächs): PDFs gehen als Text, Bilder als Base64 über Qwens Vision in den Gesprächskontext ein. Generierte Bilder werden über /api/media/<id> ausgeliefert und per Datenkanal im Transkript angezeigt. Kompletter Function-Calling-Roundtrip end-to-end gegen echtes Ollama und echtes ComfyUI verifiziert (Modell ruft generate_image korrekt auf, Bild wird erzeugt und im media_store abgelegt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
254 lines
11 KiB
JavaScript
254 lines
11 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 addImage(url) {
|
|
$("messages").querySelector(".empty")?.remove();
|
|
const article = document.createElement("article");
|
|
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);
|
|
$("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 receive(event) {
|
|
let message;
|
|
try { message = JSON.parse(event.data); } catch { return; }
|
|
if (message.type === "state") state(message.state);
|
|
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;
|
|
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();
|