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}}