diff --git a/.gitignore b/.gitignore index 7115173..66a11cd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ logs/ *.log *.wav *.mp3 +!platform/openwebui/theme/tool-status/*.mp3 *.png *.jpg *.jpeg diff --git a/README.md b/README.md index c5591cc..720922d 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ Dark-Theme namens **Midnight Aurora**. CSS und Start-Loader liegen unter `platform/openwebui/theme/` und werden schreibgeschützt in den Container eingebunden. Der Hintergrund bewegt sich bewusst langsam; Browser mit aktivierter Option „Bewegung reduzieren“ erhalten automatisch eine unbewegte Variante. +Kurze Werkzeugbestätigungen werden ebenfalls lokal aus statischen Clips +abgespielt. Sie laufen nur bei aktivierter automatischer Sprachausgabe, kosten +keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten. ## Dokumentation diff --git a/compose.yaml b/compose.yaml index 47650dd..e1528b9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -710,6 +710,7 @@ services: - ./platform/openwebui/theme/custom.css:/app/build/static/custom.css:ro - ./platform/openwebui/theme/loader.js:/app/build/static/loader.js:ro - ./platform/openwebui/theme/midnight-aurora.svg:/app/build/static/midnight-aurora.svg:ro + - ./platform/openwebui/theme/tool-status:/app/build/static/tool-status:ro environment: WEBUI_SECRET_KEY: "${WEBUI_SECRET_KEY:?WEBUI_SECRET_KEY is required}" DEFAULT_MODELS: mikeai-medium diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 3a2f679..c44f3cb 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -138,6 +138,13 @@ dem internen Router und dessen aktuellem Schlüssel. Das ist erforderlich, weil persistente Providerwerte nach einer Schlüsselrotation Vorrang vor den Container-Umgebungsvariablen haben. +Der Filterinstaller richtet außerdem die lokalen gesprochenen +Werkzeugbestätigungen ein. Die vorgerenderten Ansagen werden über +`/static/tool-status/` read-only ausgeliefert und nur bei aktivierter +automatischer Sprachausgabe abgespielt. Zur Abnahme eine Websuche und eine +Unraid-Abfrage auslösen: Vor längeren Aufrufen muss genau eine kurze passende +Ansage kommen; bei ausgeschalteter automatischer Sprachausgabe bleibt sie aus. + Der Modellinstaller setzt außerdem für den ermittelten OpenWebUI-Benutzer das native Chat-Hintergrundbild `/static/midnight-aurora.svg`. Das Bild wird durch Compose read-only eingebunden. `custom.css` verändert bewusst nicht mehr die diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 78bb94a..c9b8656 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -16,7 +16,15 @@ Reihenfolge ist absichtlich festgelegt: Passwörter, JWTs und private Schlüssel aus Tool-Ergebnissen, bevor sie das Modell erreichen, sowie aus fertigen Modellantworten. Nutzereingaben und Authentifizierungswege werden nicht verändert. -5. `MikeAI Local Performance Metrics`, Priorität 90: erfasst nach Abschluss +5. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten + Werkzeugaufruf eines Antwortlaufs und löst im Browser genau eine kurze, + passende Ansage für Web, Unraid, Home Assistant, Medienverwaltung oder + sonstige Werkzeuge aus. Die fünf MP3-Clips sind vorgerendert und liegen + unter `platform/openwebui/theme/tool-status/`; dadurch blockiert die Ansage + weder XTTS noch das Werkzeug. Sie wird nur abgespielt, wenn der Benutzer in + OpenWebUI die automatische Sprachausgabe aktiviert hat. Endet ein sehr + schneller Lauf innerhalb der kurzen Wartezeit, wird die Ansage verworfen. +6. `MikeAI Local Performance Metrics`, Priorität 90: erfasst nach Abschluss ausschließlich technische Zahlen wie Laufzeit, Tokenzähler, Token/s und Tool-Anzahl. Nutzer-, Chat- und Nachrichten-IDs sowie sämtliche Textinhalte werden weder geschrieben noch gehasht gespeichert. @@ -70,6 +78,12 @@ Die rotierende, inhaltsfreie Metrikdatei liegt im persistenten OpenWebUI-Volume unter `mike-ai-request-metrics.jsonl` (maximal 5 MiB plus eine Rotation). Sie darf für Benchmarks ausgewertet werden, ohne Chats auszulesen. +Die Werkzeugansagen enthalten weder Prompt- noch Ergebnistext und werden nicht +in die Unterhaltung oder den Modellkontext geschrieben. Der lokale +OpenWebUI-Browser-Event prüft die persönliche Einstellung +`responseAutoPlayback` über denselben Browser-Login. Ein Fehler, eine +Browser-Autoplay-Sperre oder ein fehlender Clip bleibt folgenlos für den Chat. + ## Aktionen an Modellantworten Die globale Action `MikeAI Quick Actions` ergänzt die Nachrichtenleiste um: diff --git a/platform/openwebui/filters/spoken_tool_status.py b/platform/openwebui/filters/spoken_tool_status.py new file mode 100644 index 0000000..c6b72d1 --- /dev/null +++ b/platform/openwebui/filters/spoken_tool_status.py @@ -0,0 +1,182 @@ +""" +title: MikeAI Spoken Tool Status +author: MikeAI +version: 1.0.0 +description: Plays one short, local acknowledgement when a real tool call starts. +""" + +from __future__ import annotations + +import json +import threading +from pydantic import BaseModel + + +class Filter: + class Valves(BaseModel): + priority: int = 80 + enabled: bool = True + + _announced: set[str] = set() + _lock = threading.Lock() + + def __init__(self): + self.valves = self.Valves() + self.toggle = False + + @staticmethod + def _key(metadata: dict | None) -> str: + if isinstance(metadata, dict): + value = metadata.get("message_id") or metadata.get("id") or metadata.get("chat_id") + if value: + return str(value) + return "current" + + @staticmethod + def _tool_names(event: dict) -> list[str]: + names: list[str] = [] + candidates = [] + + direct = event.get("tool_calls") + if direct: + candidates.append(direct) + + for choice in event.get("choices") or []: + if not isinstance(choice, dict): + continue + for container_name in ("delta", "message"): + container = choice.get(container_name) + if isinstance(container, dict) and container.get("tool_calls"): + candidates.append(container["tool_calls"]) + + for candidate in candidates: + calls = candidate if isinstance(candidate, list) else [candidate] + for call in calls: + if not isinstance(call, dict): + continue + function = call.get("function") + if isinstance(function, dict) and function.get("name"): + names.append(str(function["name"])) + elif call.get("name"): + names.append(str(call["name"])) + return names + + @staticmethod + def _category(names: list[str]) -> str: + joined = " ".join(names).lower().replace("-", "_") + if any(token in joined for token in ("local_web", "web_search", "web_compare", "web_research")): + return "web" + if any(token in joined for token in ("unraid", "mua_")): + return "unraid" + if any(token in joined for token in ("homeassistant", "home_assistant", "hass_")): + return "homeassistant" + if any(token in joined for token in ("sonarr", "radarr", "arr_")): + return "media" + return "generic" + + async def inlet(self, body: dict, __metadata__: dict = None, **kwargs) -> dict: + if self.valves.enabled: + key = self._key(__metadata__) + with self._lock: + self._announced.discard(key) + return body + + async def stream( + self, + event: dict, + __metadata__: dict = None, + __event_emitter__=None, + **kwargs, + ) -> dict: + if not self.valves.enabled or __event_emitter__ is None or not isinstance(event, dict): + return event + + names = self._tool_names(event) + if not names: + return event + + key = self._key(__metadata__) + with self._lock: + if key in self._announced: + return event + self._announced.add(key) + if len(self._announced) > 1024: + self._announced.clear() + self._announced.add(key) + + category = self._category(names) + javascript = f""" +(() => {{ + const category = {json.dumps(category)}; + const key = {json.dumps(key)}; + const allowed = ['web', 'unraid', 'homeassistant', 'media', 'generic']; + const safeCategory = allowed.includes(category) ? category : 'generic'; + const timers = window.__mikeAIToolStatusTimers ||= new Map(); + if (timers.has(key)) return; + const timer = window.setTimeout(async () => {{ + timers.delete(key); + let enabled = false; + try {{ + const local = JSON.parse(localStorage.getItem('settings') || '{{}}'); + enabled = (local.ui || local).responseAutoPlayback === true; + const token = localStorage.getItem('token'); + if (token) {{ + const response = await fetch('/api/v1/users/user/settings', {{ + headers: {{ Authorization: `Bearer ${{token}}` }}, + cache: 'no-store' + }}); + if (response.ok) {{ + const settings = await response.json(); + enabled = (settings.ui || settings).responseAutoPlayback === true; + }} + }} + }} catch (_) {{}} + if (!enabled) return; + try {{ + const audio = new Audio(`/static/tool-status/${{safeCategory}}.mp3?status=v1`); + audio.preload = 'auto'; + audio.volume = 0.92; + await audio.play(); + }} catch (_) {{}} + }}, 850); + timers.set(key, timer); +}})(); +""".strip() + try: + await __event_emitter__( + {"type": "execute", "data": {"code": javascript}} + ) + except Exception: + pass + return event + + async def outlet( + self, + body: dict, + __metadata__: dict = None, + __event_emitter__=None, + **kwargs, + ) -> dict: + key = self._key(__metadata__) + with self._lock: + self._announced.discard(key) + + if __event_emitter__ is not None: + javascript = f""" +(() => {{ + const key = {json.dumps(key)}; + const timers = window.__mikeAIToolStatusTimers; + const timer = timers && timers.get(key); + if (timer !== undefined) {{ + window.clearTimeout(timer); + timers.delete(key); + }} +}})(); +""".strip() + try: + await __event_emitter__( + {"type": "execute", "data": {"code": javascript}} + ) + except Exception: + pass + return body diff --git a/platform/openwebui/install-filters.sh b/platform/openwebui/install-filters.sh index f74a1f3..bdd9d2c 100755 --- a/platform/openwebui/install-filters.sh +++ b/platform/openwebui/install-filters.sh @@ -9,7 +9,7 @@ ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/actions" && pwd) die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Bitte als root ausführen." -for file in reasoning_default_off.py thinking.py stability_guard.py secret_redaction.py local_performance_metrics.py; do +for file in reasoning_default_off.py thinking.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do [[ -s $FILTER_DIR/$file ]] || die "Filterdatei fehlt: $file" done [[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt: quick_actions.py" @@ -58,10 +58,11 @@ if not {"key", "value", "updated_at"} <= config_columns: owner = requested_owner if not owner: existing = con.execute( - "select user_id from function where id in (?, ?, ?, ?, ?, ?) order by id limit 1", + "select user_id from function where id in (?, ?, ?, ?, ?, ?, ?) order by id limit 1", ( "reasoning_default_off", "thinking", "stability_guard", - "secret_redaction", "local_performance_metrics", "quick_actions", + "secret_redaction", "spoken_tool_status", "local_performance_metrics", + "quick_actions", ), ).fetchone() if existing: @@ -80,6 +81,11 @@ functions = [ ("thinking", "Thinking", "filter", 20, filter_dir, ""), ("stability_guard", "MikeAI Stability Guard", "filter", 30, filter_dir, ""), ("secret_redaction", "MikeAI Secret Redaction", "filter", 40, filter_dir, ""), + ( + "spoken_tool_status", "MikeAI Spoken Tool Status", "filter", 80, filter_dir, + "Spielt bei aktiver automatischer Sprachausgabe einmalig eine kurze lokale Ansage, " + "sobald ein echtes Werkzeug gestartet wird.", + ), ("local_performance_metrics", "MikeAI Local Performance Metrics", "filter", 90, filter_dir, ""), ( "quick_actions", "MikeAI Quick Actions", "action", 100, action_dir, @@ -222,7 +228,7 @@ with con: ) print( "OpenWebUI konfiguriert: Default Off=10, Thinking=20, " - "Stability Guard=30, Secret Redaction=40, Local Metrics=90, " + "Stability Guard=30, Secret Redaction=40, Spoken Tool Status=80, Local Metrics=90, " "Quick Actions=100, Folgefragen=aus, Piper-TTS=aktiv, Werkzeugwahl=optimiert" ) PY diff --git a/platform/openwebui/theme/tool-status/generic.mp3 b/platform/openwebui/theme/tool-status/generic.mp3 new file mode 100644 index 0000000..ee1cec8 Binary files /dev/null and b/platform/openwebui/theme/tool-status/generic.mp3 differ diff --git a/platform/openwebui/theme/tool-status/homeassistant.mp3 b/platform/openwebui/theme/tool-status/homeassistant.mp3 new file mode 100644 index 0000000..e2d8113 Binary files /dev/null and b/platform/openwebui/theme/tool-status/homeassistant.mp3 differ diff --git a/platform/openwebui/theme/tool-status/media.mp3 b/platform/openwebui/theme/tool-status/media.mp3 new file mode 100644 index 0000000..00455ac Binary files /dev/null and b/platform/openwebui/theme/tool-status/media.mp3 differ diff --git a/platform/openwebui/theme/tool-status/unraid.mp3 b/platform/openwebui/theme/tool-status/unraid.mp3 new file mode 100644 index 0000000..33834d4 Binary files /dev/null and b/platform/openwebui/theme/tool-status/unraid.mp3 differ diff --git a/platform/openwebui/theme/tool-status/web.mp3 b/platform/openwebui/theme/tool-status/web.mp3 new file mode 100644 index 0000000..d82df7e Binary files /dev/null and b/platform/openwebui/theme/tool-status/web.mp3 differ