feat: show the ComfyUI job itself in the transcript, not just a status line

Ein Statustext über dem Orb war zu unauffällig. Ein Tool-Aufruf legt
jetzt eine eigene Karte im Gesprächsverlauf an, mit Spinner und dem
tatsächlichen Prompt, den das LLM an ComfyUI schickt ("Anfrage an
ComfyUI: ..."). Sobald das Bild fertig ist, wird dieselbe Karte
in-place durch das Ergebnis ersetzt statt eine zweite anzuhängen;
schlägt ComfyUI fehl, zeigt sie stattdessen die Fehlermeldung.

Der Prompt-Text landet über sichere DOM-Erstellung (createTextNode),
nicht per innerHTML, weil er vom LLM erzeugt und damit indirekt
nutzerbeeinflusst ist.

Per Browser-Test mit echter synthetisierter Sprache verifiziert: Karte
mit Spinner + Prompt erscheint sofort, wird sauber (kein Leerlauf-id im
DOM) durch das Bild ersetzt, gefolgt von Astras Textantwort.

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 16:48:17 +02:00
parent 6854e80703
commit 76a5d4e499
4 changed files with 53 additions and 7 deletions

View file

@ -18,10 +18,12 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
async def handle_generate_image(params):
prompt = params.arguments.get("prompt", "")
notify({"type": "activity", "text": "Astra erzeugt ein Bild …"})
notify({"type": "tool_start", "text": f'Anfrage an ComfyUI: "{prompt}"'})
try:
image = await generate_image(config.comfyui_url, prompt)
except ComfyUIError as exc:
notify({"type": "activity", "text": "Bilderzeugung fehlgeschlagen."})
notify({"type": "tool_error", "text": f"ComfyUI-Anfrage fehlgeschlagen: {exc}"})
await params.result_callback({"error": str(exc)})
return
image_id = uuid.uuid4().hex

View file

@ -37,8 +37,9 @@ async def test_generate_image_tool_stores_media_and_notifies(monkeypatch):
assert len(media_store) == 1
image_id, image_bytes = next(iter(media_store.items()))
assert image_bytes == b"png-bytes"
assert notifications[0]["type"] == "activity"
assert notifications[1] == {"type": "image", "url": f"/api/media/{image_id}"}
assert [n["type"] for n in notifications] == ["activity", "tool_start", "image"]
assert "a cat" in notifications[1]["text"]
assert notifications[2] == {"type": "image", "url": f"/api/media/{image_id}"}
assert params.results[0]["status"] == "ok"
@ -60,4 +61,9 @@ async def test_generate_image_tool_reports_comfyui_errors_without_storing_media(
assert media_store == {}
assert "error" in params.results[0]
assert [n["type"] for n in notifications] == ["activity", "activity"]
assert [n["type"] for n in notifications] == [
"activity",
"tool_start",
"activity",
"tool_error",
]

View file

@ -33,10 +33,46 @@ function addMessage(event) {
while ($("messages").children.length > 80) $("messages").firstElementChild.remove();
$("messages").scrollTop = $("messages").scrollHeight;
}
function addImage(url) {
function addToolStart(text) {
$("messages").querySelector(".empty")?.remove();
document.getElementById("pending-tool")?.remove();
const article = document.createElement("article");
article.className = "message assistant";
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 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";
@ -45,7 +81,7 @@ function addImage(url) {
img.src = url;
img.alt = "Von Astra erzeugtes Bild";
article.append(speaker, img);
$("messages").append(article);
if (!pending) $("messages").append(article);
$("messages").scrollTop = $("messages").scrollHeight;
}
function addUploadNote(filename) {
@ -89,6 +125,8 @@ function receive(event) {
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 === "partial") $("partial").textContent = message.text;
if (message.type === "transcript") addMessage(message);
if (message.type === "image") addImage(message.url);

File diff suppressed because one or more lines are too long