From 1656d26ea605c2cb02f9036e5c118624cffc8098 Mon Sep 17 00:00:00 2001
From: Jeuner <62662523+Jeuners@users.noreply.github.com>
Date: Mon, 7 Sep 2026 18:56:20 +0200
Subject: [PATCH] feat: numbered headlines + article-reference trigger
("Artikel 2")
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Schlagzeilen aus read_news sind jetzt nummeriert (1., 2., 3. ...) statt
einer reinen Aufzählung, per
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
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
---
README.md | 19 +++++++++++++++----
astra/server.py | 14 ++++++++++++--
astra/tools.py | 16 ++++++++++++----
astra/triggers.py | 43 ++++++++++++++++++++++++++++++++++++++++--
tests/test_tools.py | 4 ++--
tests/test_triggers.py | 18 +++++++++++++++++-
web/app.js | 2 +-
web/style.css | 2 +-
8 files changed, 101 insertions(+), 17 deletions(-)
diff --git a/README.md b/README.md
index 1a2a86d..4fdba4b 100644
--- a/README.md
+++ b/README.md
@@ -87,10 +87,21 @@ den `read_news`-Handler direkt auf — derselbe Handler, dieselbe UI,
nur ohne die unzuverlässige LLM-Entscheidung dazwischen. Das Ergebnis
landet als Tool-Roundtrip im Kontext, damit die nächste LLM-Antwort es
kennt. Live getestet: 2/2 zuverlässig, wo die reine LLM-Entscheidung nur
-~15–20 % erreichte. `generate_image` und `read_article` bleiben bewusst
-reine LLM-Tools, weil sie keine feste Trigger-Phrase haben (Bildwunsch
-und "erzähl mehr" sind zu variabel für ein Keyword-Muster) und
-`generate_image` ohnehin zuverlässig funktioniert.
+~15–20 % erreichte.
+
+Schlagzeilen werden nummeriert angezeigt (1., 2., 3. …), und derselbe
+Mechanismus kennt einen zweiten Trigger: `detect_article_reference()`
+erkennt Formulierungen wie "Artikel 2", "Artikel Nummer drei" oder
+"zweiter Artikel" im Nutzertext, löst die Nummer gegen die zuletzt
+gezeigte Liste auf und ruft `read_article` direkt mit dem passenden Link
+auf — ganz ohne dass das Modell selbst den richtigen Link kennen oder
+sich für das Werkzeug entscheiden muss. Live getestet über einen echten
+Zwei-Turn-Dialog ("News Hilden" → "Hole mir Detail zu Artikel zwei"):
+korrekt aufgelöst, echter Artikeltext abgerufen.
+
+`generate_image` bleibt bewusst ein reines LLM-Tool, weil ein Bildwunsch
+zu variabel für ein Keyword-Muster ist und ohnehin zuverlässig
+funktioniert.
## Starten
diff --git a/astra/server.py b/astra/server.py
index c706ddb..c34845e 100644
--- a/astra/server.py
+++ b/astra/server.py
@@ -28,7 +28,7 @@ from astra.core import (
)
from astra.documents import extract_pdf_text
from astra.inference import Models, on_executor
-from astra.triggers import detect_news_topic, trigger_tool
+from astra.triggers import detect_article_reference, detect_news_topic, trigger_tool
ROOT = Path(__file__).resolve().parent.parent
MAX_UPLOAD_BYTES = 15 * 1024 * 1024
@@ -152,13 +152,23 @@ async def run_voice(connection, models, config, voice_state, voice_name, context
async def disconnected(transport, client):
await worker.cancel()
+ last_headlines: list[dict] = []
+
@aggregators.user().event_handler("on_user_turn_stopped")
async def user_turn(aggregator, strategy, message):
if message.content:
notify({"type": "transcript", "role": "user", "text": message.content})
topic = detect_news_topic(message.content)
if topic:
- await trigger_tool(context, "read_news", {"topic": topic})
+ result = await trigger_tool(context, "read_news", {"topic": topic})
+ if result and result.get("status") == "ok":
+ last_headlines[:] = result.get("headlines", [])
+ return
+ number = detect_article_reference(message.content)
+ if number is not None:
+ match = next((h for h in last_headlines if h.get("number") == number), None)
+ if match:
+ await trigger_tool(context, "read_article", {"url": match["link"]})
@aggregators.assistant().event_handler("on_assistant_turn_stopped")
async def assistant_turn(aggregator, message):
diff --git a/astra/tools.py b/astra/tools.py
index bf36a2d..4116c72 100644
--- a/astra/tools.py
+++ b/astra/tools.py
@@ -47,12 +47,14 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
notify({"type": "tool_error", "text": str(exc)})
await params.result_callback({"error": str(exc)})
return
+ numbered = list(enumerate(entries, start=1))
notify(
{
"type": "tool_result",
- "text": "\n".join(f"- ({entry.source}) {entry.title}" for entry in entries),
+ "text": "\n".join(f"{i}. ({e.source}) {e.title}" for i, e in numbered),
"items": [
- {"source": e.source, "title": e.title, "link": e.link} for e in entries
+ {"number": i, "source": e.source, "title": e.title, "link": e.link}
+ for i, e in numbered
],
}
)
@@ -60,8 +62,14 @@ def build_tools(config: Settings, notify: Callable[[dict], None], media_store: d
{
"status": "ok",
"headlines": [
- {"source": e.source, "title": e.title, "summary": e.summary, "link": e.link}
- for e in entries
+ {
+ "number": i,
+ "source": e.source,
+ "title": e.title,
+ "summary": e.summary,
+ "link": e.link,
+ }
+ for i, e in numbered
],
}
)
diff --git a/astra/triggers.py b/astra/triggers.py
index cfead19..a3ee442 100644
--- a/astra/triggers.py
+++ b/astra/triggers.py
@@ -6,6 +6,7 @@ its attention — a simple keyword match is ~100% reliable for the same job.
"""
import json
+import re
import uuid
from astra.feeds import TOPICS
@@ -19,6 +20,20 @@ _TOPIC_ALIASES = {
"wirtschaft": "wirtschaft",
}
+_ARTICLE_WORDS = ("artikel", "schlagzeile")
+_NUMBER_WORDS = {
+ "eins": 1, "eine": 1, "einen": 1, "erste": 1, "ersten": 1, "erster": 1,
+ "zwei": 2, "zweite": 2, "zweiten": 2, "zweiter": 2,
+ "drei": 3, "dritte": 3, "dritten": 3, "dritter": 3,
+ "vier": 4, "vierte": 4, "vierten": 4, "vierter": 4,
+ "fünf": 5, "fünfte": 5, "fünften": 5, "fünfter": 5,
+ "sechs": 6, "sechste": 6, "sechsten": 6, "sechster": 6,
+ "sieben": 7, "siebte": 7, "siebten": 7, "siebter": 7,
+ "acht": 8, "achte": 8, "achten": 8, "achter": 8,
+ "neun": 9, "neunte": 9, "neunten": 9, "neunter": 9,
+ "zehn": 10, "zehnte": 10, "zehnten": 10, "zehnter": 10,
+} # fmt: skip
+
def detect_news_topic(text: str) -> str | None:
"""Return a configured feed topic if `text` looks like a news request."""
@@ -31,17 +46,39 @@ def detect_news_topic(text: str) -> str | None:
return "nachrichten" if "nachrichten" in TOPICS else None
-async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
+def detect_article_reference(text: str) -> int | None:
+ """Return the 1-based article number if `text` asks for one, e.g.
+ "Artikel 2", "Artikel Nummer drei", "zweiter Artikel", "Detail zu
+ Schlagzeile 4". Resolution against the last shown headlines is the
+ caller's job — this only extracts the number.
+ """
+ lowered = text.lower()
+ if not any(word in lowered for word in _ARTICLE_WORDS):
+ return None
+ match = re.search(r"(?:artikel|schlagzeile)\D{0,15}(\d+)", lowered)
+ if match:
+ return int(match.group(1))
+ for word, number in _NUMBER_WORDS.items():
+ if re.search(rf"(?:artikel|schlagzeile)\D{{0,15}}{word}\b", lowered):
+ return number
+ if re.search(rf"\b{word}\D{{0,15}}(?:artikel|schlagzeile)", lowered):
+ return number
+ return None
+
+
+async def trigger_tool(context, tool_name: str, arguments: dict) -> dict | None:
"""Call an already-registered tool's handler directly, then inject the
round-trip into `context` so the LLM's next completion already sees it
as answered instead of having to decide to call the tool itself.
+ Returns the tool's result dict (or None if the tool wasn't found).
"""
tools = getattr(context.tools, "standard_tools", None) or []
schema = next((t for t in tools if t.name == tool_name), None)
if schema is None:
- return
+ return None
call_id = f"trigger_{uuid.uuid4().hex[:8]}"
+ captured: dict = {}
class _DirectParams:
pass
@@ -50,6 +87,7 @@ async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
params.arguments = arguments
async def result_callback(result, *, properties=None):
+ captured["result"] = result
context.add_messages(
[
{
@@ -73,3 +111,4 @@ async def trigger_tool(context, tool_name: str, arguments: dict) -> None:
params.result_callback = result_callback
await schema.handler(params)
+ return captured.get("result")
diff --git a/tests/test_tools.py b/tests/test_tools.py
index f36b54d..3d1980f 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -99,8 +99,8 @@ async def test_read_news_tool_reports_headlines(monkeypatch):
assert "KI-Durchbruch" 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"},
+ {"number": 1, "source": "heise online", "title": "KI-Durchbruch", "link": "https://x"},
+ {"number": 2, "source": "Golem.de", "title": "Neuer Chip", "link": "https://y"},
]
assert params.results[0]["status"] == "ok"
assert len(params.results[0]["headlines"]) == 2
diff --git a/tests/test_triggers.py b/tests/test_triggers.py
index 3752f4d..7f1e250 100644
--- a/tests/test_triggers.py
+++ b/tests/test_triggers.py
@@ -1,6 +1,22 @@
import pytest
-from astra.triggers import detect_news_topic, trigger_tool
+from astra.triggers import detect_article_reference, detect_news_topic, trigger_tool
+
+
+def test_detect_article_reference_matches_digit():
+ assert detect_article_reference("Hole mir Detail zu Artikel 2") == 2
+ assert detect_article_reference("Artikel Nummer 4 bitte") == 4
+
+
+def test_detect_article_reference_matches_number_words():
+ assert detect_article_reference("Erzähl mir mehr zu Artikel zwei") == 2
+ assert detect_article_reference("zweiter Artikel bitte") == 2
+ assert detect_article_reference("die dritte Schlagzeile interessiert mich") == 3
+
+
+def test_detect_article_reference_returns_none_without_article_word():
+ assert detect_article_reference("Wie ist das Wetter?") is None
+ assert detect_article_reference("Erzähl mir die Nachrichten.") is None
def test_detect_news_topic_matches_hilden():
diff --git a/web/app.js b/web/app.js
index bf56d22..de1c501 100644
--- a/web/app.js
+++ b/web/app.js
@@ -78,7 +78,7 @@ function addToolResult(text, items) {
speaker.textContent = "Astra";
article.append(speaker);
if (items?.length) {
- const list = document.createElement("ul");
+ const list = document.createElement("ol");
list.className = "tool-result-list";
for (const item of items) {
const li = document.createElement("li");
diff --git a/web/style.css b/web/style.css
index 4470a6f..97859c5 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1,3 +1,3 @@
@font-face{font-family:system;src:local("Avenir Next")}
:root{color-scheme:dark;--bg:#111820;--panel:#19222b;--text:#eef2ef;--muted:#a3b1b8;--accent:#b4e5cc;--border:#303c44}
-*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% 25%,#1c2d34 0%,var(--bg) 58%);color:var(--text);font-family:system,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}main{max-width:1060px;margin:auto;padding:36px 48px 25px}header{display:flex;justify-content:space-between;align-items:center}.brand{text-decoration:none;color:var(--text);font-size:30px;letter-spacing:-1.5px;font-weight:600}.star{color:var(--accent);font-size:35px;vertical-align:middle;margin-right:7px}.local{display:flex;align-items:center;gap:9px;font-size:12px;color:#c3d3ce;border:1px solid var(--border);padding:9px 13px;border-radius:30px}.local i{height:6px;width:6px;background:var(--accent);border-radius:50%}.conversation{text-align:center;padding:60px 0 34px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:2.8px;color:var(--accent);margin-bottom:17px}h1{font-size:clamp(32px,5vw,49px);font-weight:500;letter-spacing:-1.8px;margin:0 0 13px}.description{font-size:14px;color:var(--muted);margin:0}.orb-wrap{position:relative;width:210px;height:210px;display:grid;place-items:center;margin:31px auto 15px}.orb{width:145px;height:145px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,#a3e1cf77,#4e938a22 55%,#172b3544);border:1px solid #a3d3c35c;box-shadow:inset 0 0 38px #92e1ce19,0 0 65px #84c5b215}.orbit{position:absolute;inset:6px;border:1px solid #9acdb01b;border-radius:50%;box-shadow:0 0 0 16px #98cdb104}.wave{height:40px;display:flex;align-items:center;gap:5px}.wave i{display:block;width:4px;height:13px;border-radius:5px;background:#d0f3e4;transition:height .3s}.wave i:nth-child(2),.wave i:nth-child(6){height:22px}.wave i:nth-child(3),.wave i:nth-child(5){height:32px}.wave i:nth-child(4){height:41px}body[data-state="speaking"] .wave i,body[data-state="listening"] .wave i,body[data-state="responding"] .wave i{animation:voice 1.2s ease-in-out infinite alternate}.wave i:nth-child(2n){animation-delay:-.8s!important}.wave i:nth-child(3n){animation-delay:-.4s!important}@keyframes voice{to{height:8px;opacity:.6}}.live-status{display:flex;justify-content:center;align-items:center;gap:8px;height:27px}.live-status p{font-size:14px;margin:0}#state-dot{height:6px;width:6px;border-radius:50%;background:var(--muted)}body[data-state="listening"] #state-dot,body[data-state="speaking"] #state-dot,body[data-state="responding"] #state-dot{background:var(--accent)}.partial{color:var(--muted);font-size:13px;min-height:22px;max-width:640px;margin:13px auto 17px;overflow-wrap:anywhere}.voice-picker{display:flex;justify-content:center;align-items:center;gap:10px;margin:0 0 18px}.voice-picker label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px}.voice-picker select{font:inherit;font-size:13px;background:var(--panel);color:var(--text);border:1px solid #576b68;border-radius:9px;padding:8px 12px;cursor:pointer}.voice-picker select:disabled{opacity:.5;cursor:wait}.voice-picker select:focus-visible{outline:3px solid #d5f3e5;outline-offset:3px}.controls{display:flex;justify-content:center;gap:12px;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:0;transition:background .2s,transform .2s}button:active{transform:translateY(1px)}button:focus-visible,a:focus-visible{outline:3px solid #d5f3e5;outline-offset:5px}.primary{background:var(--accent);color:#162c25;border-radius:9px;font-size:14px;font-weight:600;padding:15px 24px;min-width:211px}.primary:hover{background:#cef2df}.primary span{margin-right:9px}.primary:disabled{background:#43594f;color:#bfcbc4;cursor:wait}.secondary{border:1px solid #576b68;border-radius:9px;padding:14px 18px;background:transparent;color:var(--text);font-size:13px}.secondary[aria-pressed="true"]{background:#553a38;border-color:#c2887c}.hint{font-size:11px;color:var(--muted);margin:16px 0 0}.error{color:#ffd5cd;background:#4a2c2a;border:1px solid #8e564f;border-radius:9px;padding:12px;max-width:640px;margin:15px auto;font-size:13px;overflow-wrap:anywhere}.transcript{border:1px solid var(--border);border-radius:14px;background:#19222b9c;padding:22px 26px}.section-top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);padding-bottom:16px}h2{margin:0;font-size:13px;font-weight:500}.text-button{color:var(--muted);background:none;font-size:11px;padding:7px}.text-button:hover{color:var(--text)}#messages{max-height:330px;overflow:auto;scroll-behavior:smooth;padding-top:5px}.empty{font-size:12px;color:var(--muted);line-height:1.9;text-align:center;padding:18px 0 12px}.message{padding:15px 0 7px}.message .speaker{font-size:10px;letter-spacing:1px;font-weight:600;text-transform:uppercase;color:var(--accent)}.message.user .speaker{color:#b1bdcb}.message p{font-size:14px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.message small{color:var(--muted);font-size:11px}.generated-image{display:block;max-width:100%;border-radius:9px;margin:8px 0;border:1px solid var(--border)}.pending-tool .pending-text{color:var(--muted);display:flex;align-items:center;gap:8px}.tool-result-list{list-style:none;margin:6px 0;padding:0;display:flex;flex-direction:column;gap:8px}.tool-result-list li{font-size:14px;line-height:1.5}.tool-result-list a{color:var(--accent);text-decoration:none}.tool-result-list a:hover{text-decoration:underline}.tool-result-list small{color:var(--muted);font-size:11px}.spinner{display:inline-block;width:11px;height:11px;border-radius:50%;border:2px solid var(--border);border-top-color:var(--accent);animation:spin .7s linear infinite;flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}footer{display:flex;justify-content:space-between;gap:15px;margin-top:21px;color:var(--muted);font-size:10px}footer b{padding:0 6px;font-weight:400}.credits{display:flex;align-items:center}footer a{color:var(--muted);text-decoration:none}footer a:hover{color:var(--accent)}[hidden]{display:none!important}@media(max-width:620px){main{padding:23px 20px}.conversation{padding-top:44px}.description{max-width:270px;margin:auto;line-height:1.6}.local{font-size:10px;padding:8px 10px}.transcript{padding:18px}footer{flex-direction:column;gap:8px}.orb-wrap{margin-top:21px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 50% 25%,#1c2d34 0%,var(--bg) 58%);color:var(--text);font-family:system,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}main{max-width:1060px;margin:auto;padding:36px 48px 25px}header{display:flex;justify-content:space-between;align-items:center}.brand{text-decoration:none;color:var(--text);font-size:30px;letter-spacing:-1.5px;font-weight:600}.star{color:var(--accent);font-size:35px;vertical-align:middle;margin-right:7px}.local{display:flex;align-items:center;gap:9px;font-size:12px;color:#c3d3ce;border:1px solid var(--border);padding:9px 13px;border-radius:30px}.local i{height:6px;width:6px;background:var(--accent);border-radius:50%}.conversation{text-align:center;padding:60px 0 34px}.eyebrow{font-size:10px;font-weight:600;letter-spacing:2.8px;color:var(--accent);margin-bottom:17px}h1{font-size:clamp(32px,5vw,49px);font-weight:500;letter-spacing:-1.8px;margin:0 0 13px}.description{font-size:14px;color:var(--muted);margin:0}.orb-wrap{position:relative;width:210px;height:210px;display:grid;place-items:center;margin:31px auto 15px}.orb{width:145px;height:145px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,#a3e1cf77,#4e938a22 55%,#172b3544);border:1px solid #a3d3c35c;box-shadow:inset 0 0 38px #92e1ce19,0 0 65px #84c5b215}.orbit{position:absolute;inset:6px;border:1px solid #9acdb01b;border-radius:50%;box-shadow:0 0 0 16px #98cdb104}.wave{height:40px;display:flex;align-items:center;gap:5px}.wave i{display:block;width:4px;height:13px;border-radius:5px;background:#d0f3e4;transition:height .3s}.wave i:nth-child(2),.wave i:nth-child(6){height:22px}.wave i:nth-child(3),.wave i:nth-child(5){height:32px}.wave i:nth-child(4){height:41px}body[data-state="speaking"] .wave i,body[data-state="listening"] .wave i,body[data-state="responding"] .wave i{animation:voice 1.2s ease-in-out infinite alternate}.wave i:nth-child(2n){animation-delay:-.8s!important}.wave i:nth-child(3n){animation-delay:-.4s!important}@keyframes voice{to{height:8px;opacity:.6}}.live-status{display:flex;justify-content:center;align-items:center;gap:8px;height:27px}.live-status p{font-size:14px;margin:0}#state-dot{height:6px;width:6px;border-radius:50%;background:var(--muted)}body[data-state="listening"] #state-dot,body[data-state="speaking"] #state-dot,body[data-state="responding"] #state-dot{background:var(--accent)}.partial{color:var(--muted);font-size:13px;min-height:22px;max-width:640px;margin:13px auto 17px;overflow-wrap:anywhere}.voice-picker{display:flex;justify-content:center;align-items:center;gap:10px;margin:0 0 18px}.voice-picker label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:1px}.voice-picker select{font:inherit;font-size:13px;background:var(--panel);color:var(--text);border:1px solid #576b68;border-radius:9px;padding:8px 12px;cursor:pointer}.voice-picker select:disabled{opacity:.5;cursor:wait}.voice-picker select:focus-visible{outline:3px solid #d5f3e5;outline-offset:3px}.controls{display:flex;justify-content:center;gap:12px;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:0;transition:background .2s,transform .2s}button:active{transform:translateY(1px)}button:focus-visible,a:focus-visible{outline:3px solid #d5f3e5;outline-offset:5px}.primary{background:var(--accent);color:#162c25;border-radius:9px;font-size:14px;font-weight:600;padding:15px 24px;min-width:211px}.primary:hover{background:#cef2df}.primary span{margin-right:9px}.primary:disabled{background:#43594f;color:#bfcbc4;cursor:wait}.secondary{border:1px solid #576b68;border-radius:9px;padding:14px 18px;background:transparent;color:var(--text);font-size:13px}.secondary[aria-pressed="true"]{background:#553a38;border-color:#c2887c}.hint{font-size:11px;color:var(--muted);margin:16px 0 0}.error{color:#ffd5cd;background:#4a2c2a;border:1px solid #8e564f;border-radius:9px;padding:12px;max-width:640px;margin:15px auto;font-size:13px;overflow-wrap:anywhere}.transcript{border:1px solid var(--border);border-radius:14px;background:#19222b9c;padding:22px 26px}.section-top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border);padding-bottom:16px}h2{margin:0;font-size:13px;font-weight:500}.text-button{color:var(--muted);background:none;font-size:11px;padding:7px}.text-button:hover{color:var(--text)}#messages{max-height:330px;overflow:auto;scroll-behavior:smooth;padding-top:5px}.empty{font-size:12px;color:var(--muted);line-height:1.9;text-align:center;padding:18px 0 12px}.message{padding:15px 0 7px}.message .speaker{font-size:10px;letter-spacing:1px;font-weight:600;text-transform:uppercase;color:var(--accent)}.message.user .speaker{color:#b1bdcb}.message p{font-size:14px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.message small{color:var(--muted);font-size:11px}.generated-image{display:block;max-width:100%;border-radius:9px;margin:8px 0;border:1px solid var(--border)}.pending-tool .pending-text{color:var(--muted);display:flex;align-items:center;gap:8px}.tool-result-list{list-style:decimal;margin:6px 0;padding-left:20px;display:flex;flex-direction:column;gap:8px}.tool-result-list li{font-size:14px;line-height:1.5}.tool-result-list a{color:var(--accent);text-decoration:none}.tool-result-list a:hover{text-decoration:underline}.tool-result-list small{color:var(--muted);font-size:11px}.spinner{display:inline-block;width:11px;height:11px;border-radius:50%;border:2px solid var(--border);border-top-color:var(--accent);animation:spin .7s linear infinite;flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}footer{display:flex;justify-content:space-between;gap:15px;margin-top:21px;color:var(--muted);font-size:10px}footer b{padding:0 6px;font-weight:400}.credits{display:flex;align-items:center}footer a{color:var(--muted);text-decoration:none}footer a:hover{color:var(--accent)}[hidden]{display:none!important}@media(max-width:620px){main{padding:23px 20px}.conversation{padding-top:44px}.description{max-width:270px;margin:auto;line-height:1.6}.local{font-size:10px;padding:8px 10px}.transcript{padding:18px}footer{flex-direction:column;gap:8px}.orb-wrap{margin-top:21px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}