feat(1b): Automatische Notfallerkennung raus → manueller Notruf-Knopf
Die unzuverlässige/intransparente Auto-Erkennung (Stichwörter + LLM) ist
ersatzlos entfernt: emergency.py neu (nur manueller, vom Nutzer bestätigter
Notruf), llm_classifier.py gelöscht, Aufrufe in chat.py/ws.py raus,
emergency_llm_* aus der Config.
Neu: prominenter "🆘 Hilfe rufen"-Knopf (Bestätigung) → POST /api/emergency
→ protokolliert + liefert Hinweis in Nutzersprache. Eskalation (Benachrichtigung
an Angehörige) folgt später; vorerst Platzhalter "ACHTUNG: Es ist noch keine
Benachrichtigung eingebaut." (10 Sprachen). Admin-Tab "Notfälle" → "Notrufe".
Tests: test_emergency_llm.py entfernt, test_quota_safety.py auf Notruf-Endpunkt
umgeschrieben. 208 passed. Live verifiziert.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
be5206c7ed
commit
3389f75af0
10 changed files with 112 additions and 433 deletions
|
|
@ -17,8 +17,8 @@ from app.dependencies import (
|
|||
)
|
||||
from app.core.memory_extractor import maybe_schedule_extraction
|
||||
from app.quota import enforce_quota, record_usage, QuotaExceededError
|
||||
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
|
||||
from app.schemas import ChatRequest
|
||||
from app.safety.emergency import record_manual_emergency
|
||||
from app.schemas import ChatRequest, EmergencyRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -84,16 +84,10 @@ async def chat(
|
|||
if user_context_parts:
|
||||
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
|
||||
|
||||
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
|
||||
emergency = handle_emergency(user, payload.text, store)
|
||||
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
|
||||
schedule_llm_emergency_check(user, payload.text, store, emergency)
|
||||
|
||||
if emergency is None:
|
||||
try:
|
||||
enforce_quota(user, store)
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc))
|
||||
try:
|
||||
enforce_quota(user, store)
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc))
|
||||
|
||||
# Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache.
|
||||
voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender)
|
||||
|
|
@ -126,7 +120,6 @@ async def chat(
|
|||
"route": route.as_dict(),
|
||||
"history_len": len(conversation),
|
||||
"memories_len": len(memories),
|
||||
"emergency": emergency,
|
||||
"trace": {
|
||||
"raw_transcript": trace.raw_transcript,
|
||||
"cleaned_transcript": trace.cleaned_transcript,
|
||||
|
|
@ -148,3 +141,13 @@ async def chat(
|
|||
if emergency:
|
||||
headers["X-Emergency"] = emergency["category"]
|
||||
return StreamingResponse(BytesIO(audio), media_type="audio/pcm", headers=headers)
|
||||
|
||||
|
||||
@router.post("/emergency")
|
||||
async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(require_user)):
|
||||
"""Vom Nutzer bestätigter Notruf. Protokolliert + liefert den Hinweis (in Nutzersprache).
|
||||
|
||||
Die eigentliche Benachrichtigung von Angehörigen folgt später.
|
||||
"""
|
||||
store = get_store()
|
||||
return record_manual_emergency(user, store, payload.language)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ from app.audio.vad import EnergyVAD
|
|||
from app.core.memory_extractor import maybe_schedule_extraction
|
||||
from app.quota import enforce_quota, record_usage, QuotaExceededError
|
||||
from app.auth import authenticate
|
||||
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -86,30 +85,11 @@ async def _run_turn(
|
|||
if user_context_parts:
|
||||
llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context
|
||||
|
||||
# Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit).
|
||||
emergency = handle_emergency(user, text, store)
|
||||
|
||||
async def _on_llm_emergency(category):
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{"type": "emergency", "category": category, "source": "llm"}
|
||||
)
|
||||
except Exception: # Socket evtl. geschlossen -> ignorieren
|
||||
pass
|
||||
|
||||
# Stufe 2: LLM-Klassifikation als Hintergrund-Task (nur wenn Stichwoerter nichts fanden).
|
||||
schedule_llm_emergency_check(
|
||||
user, text, store, emergency, on_emergency=_on_llm_emergency
|
||||
)
|
||||
|
||||
if emergency:
|
||||
await websocket.send_json({"type": "emergency", "category": emergency["category"]})
|
||||
else:
|
||||
try:
|
||||
enforce_quota(user, store)
|
||||
except QuotaExceededError as exc:
|
||||
await websocket.send_json({"type": "error", "status": 429, "detail": str(exc)})
|
||||
return
|
||||
try:
|
||||
enforce_quota(user, store)
|
||||
except QuotaExceededError as exc:
|
||||
await websocket.send_json({"type": "error", "status": 429, "detail": str(exc)})
|
||||
return
|
||||
|
||||
await websocket.send_json({"type": "ack", "route": route.as_dict()})
|
||||
|
||||
|
|
|
|||
|
|
@ -192,9 +192,6 @@ class Settings(BaseSettings):
|
|||
# LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen).
|
||||
# Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine
|
||||
# zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider.
|
||||
emergency_llm_enabled: bool = True
|
||||
emergency_llm_provider: str = ""
|
||||
emergency_llm_min_confidence: float = 0.6
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,140 +1,41 @@
|
|||
"""Notfall-Erkennung und Eskalation (Senioren-Kontext).
|
||||
"""Manuell ausgelöster Notruf — vom Nutzer bewusst bestätigt.
|
||||
|
||||
Zweistufig:
|
||||
1. Schnelle Stichwort-Heuristik (`detect` / `handle_emergency`) im Hot-Path -> 0 Latenz.
|
||||
2. LLM-Klassifikation (`schedule_llm_emergency_check`) als Hintergrund-Task, der NUR
|
||||
laeuft, wenn die Heuristik nichts fand -> faengt verpasste Formulierungen, ohne die
|
||||
Antwortlatenz zu erhoehen.
|
||||
Es gibt KEINE automatische Notfallerkennung (keine Stichwörter, kein LLM): die
|
||||
wäre unzuverlässig (Fehlalarme wie verpasste Notfälle) und intransparent. Ein
|
||||
Notruf entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
|
||||
|
||||
Die erkannten Textauszuege sind hochsensibel und werden bewusst protokolliert
|
||||
(DSGVO beachten: Einwilligung, Aufbewahrung, Zugriff).
|
||||
Die tatsächliche Benachrichtigung von Angehörigen (SMS/Anruf/Webhook) ist noch
|
||||
NICHT implementiert. Bis dahin wird der Notruf protokolliert und der Nutzer
|
||||
erhält einen klaren Hinweis in seiner Sprache.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings, Settings
|
||||
from app.metrics import metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Referenzen auf laufende Hintergrund-Tasks halten (sonst GC-gefaehrdet).
|
||||
_pending: set[asyncio.Task] = set()
|
||||
|
||||
# Phrasen je Kategorie (de/en), bewusst eher spezifisch gegen Fehlalarme.
|
||||
_PATTERNS: dict[str, list[str]] = {
|
||||
"medical": [
|
||||
"brustschmerz", "schmerzen in der brust", "kann nicht atmen", "keine luft",
|
||||
"atemnot", "herzinfarkt", "schlaganfall", "bewusstlos", "gestuerzt", "gestürzt",
|
||||
"gefallen und komme nicht hoch", "starke blutung",
|
||||
"chest pain", "can't breathe", "cannot breathe", "heart attack", "stroke",
|
||||
"i fell and can't", "bleeding badly",
|
||||
],
|
||||
"self_harm": [
|
||||
"nicht mehr leben", "mich umbringen", "selbstmord", "suizid", "will sterben",
|
||||
"kill myself", "end my life", "suicide", "want to die",
|
||||
],
|
||||
"help": [
|
||||
"notruf", "notarzt", "krankenwagen", "ruf einen arzt", "es brennt",
|
||||
"call an ambulance", "call 911", "call 112",
|
||||
],
|
||||
# Platzhalter-Hinweis je Sprache, solange die Eskalation noch nicht gebaut ist.
|
||||
_NO_NOTIFY_NOTICE: dict[str, str] = {
|
||||
"de": "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.",
|
||||
"en": "ATTENTION: No notification has been set up yet.",
|
||||
"fr": "ATTENTION : aucune notification n'est encore configurée.",
|
||||
"es": "ATENCIÓN: todavía no se ha configurado ninguna notificación.",
|
||||
"it": "ATTENZIONE: non è ancora stata configurata alcuna notifica.",
|
||||
"pt": "ATENÇÃO: ainda não há nenhuma notificação configurada.",
|
||||
"pl": "UWAGA: Nie skonfigurowano jeszcze żadnego powiadomienia.",
|
||||
"ar": "تنبيه: لم يتم إعداد أي إشعار بعد.",
|
||||
"ru": "ВНИМАНИЕ: Уведомление пока не настроено.",
|
||||
"zh": "注意:尚未设置任何通知。",
|
||||
}
|
||||
|
||||
|
||||
def detect(text: str):
|
||||
"""Liefert (category, matched_phrase) oder None."""
|
||||
if not text:
|
||||
return None
|
||||
low = text.lower()
|
||||
for category, phrases in _PATTERNS.items():
|
||||
for phrase in phrases:
|
||||
if phrase in low:
|
||||
return category, phrase
|
||||
return None
|
||||
def notify_notice(language: str | None) -> str:
|
||||
"""Liefert den Platzhalter-Hinweis in der Nutzersprache (Fallback Deutsch)."""
|
||||
return _NO_NOTIFY_NOTICE.get((language or "de").lower(), _NO_NOTIFY_NOTICE["de"])
|
||||
|
||||
|
||||
async def _fire_webhook(url: str, user, category: str, snippet: str) -> None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
await client.post(
|
||||
url,
|
||||
json={
|
||||
"user_id": user.id,
|
||||
"display_name": user.display_name,
|
||||
"category": category,
|
||||
"text": snippet,
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - best effort, darf den Chat nicht brechen
|
||||
metrics.inc("emergency_webhook_error_total")
|
||||
def record_manual_emergency(user, store, language: str | None = None) -> dict:
|
||||
"""Protokolliert einen vom Nutzer bestätigten Notruf und liefert den Hinweis.
|
||||
|
||||
|
||||
def _escalate(user, category: str, snippet: str, store, cfg: Settings, source: str) -> None:
|
||||
"""Protokolliert + eskaliert einen erkannten Notfall (Log, Metrik, Webhook)."""
|
||||
store.log_emergency(user.id, category, snippet)
|
||||
metrics.inc("emergency_total", {"category": category, "source": source})
|
||||
if cfg.emergency_webhook_url:
|
||||
try:
|
||||
asyncio.get_running_loop().create_task(
|
||||
_fire_webhook(cfg.emergency_webhook_url, user, category, snippet)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # kein laufender Event-Loop (z. B. im Test) -> Webhook ueberspringen
|
||||
|
||||
|
||||
def handle_emergency(user, text: str, store, cfg: Settings = settings):
|
||||
"""Stufe 1: Stichwort-Heuristik. Erkennt, protokolliert und eskaliert sofort.
|
||||
|
||||
Gibt {"category", "matched"} zurueck, wenn etwas erkannt wurde, sonst None.
|
||||
Der Webhook (falls konfiguriert) wird nicht-blockierend ausgeloest.
|
||||
Eskalation (Benachrichtigung) folgt später; hier wird nur protokolliert.
|
||||
"""
|
||||
match = detect(text)
|
||||
if not match:
|
||||
return None
|
||||
category, phrase = match
|
||||
_escalate(user, category, text[:500], store, cfg, source="keyword")
|
||||
return {"category": category, "matched": phrase}
|
||||
|
||||
|
||||
async def _llm_check(user, text: str, store, cfg: Settings, on_emergency) -> None:
|
||||
"""Hintergrund: LLM-Klassifikation + Eskalation (best-effort)."""
|
||||
try:
|
||||
from app.safety.llm_classifier import classify_emergency
|
||||
|
||||
result = await classify_emergency(text, cfg)
|
||||
if not result:
|
||||
return
|
||||
category = result["category"]
|
||||
_escalate(user, category, text[:500], store, cfg, source="llm")
|
||||
logger.info(
|
||||
"llm-emergency: %s (conf=%.2f) fuer %s", category, result["confidence"], user.id
|
||||
)
|
||||
if on_emergency is not None:
|
||||
await on_emergency(category)
|
||||
except Exception: # best-effort: darf den Turn nie brechen
|
||||
logger.exception("llm-emergency-check fehlgeschlagen (ignoriert)")
|
||||
|
||||
|
||||
def schedule_llm_emergency_check(user, text: str, store, keyword_hit,
|
||||
cfg: Settings = settings, on_emergency=None):
|
||||
"""Stufe 2: plant die LLM-Klassifikation als Hintergrund-Task.
|
||||
|
||||
Laeuft NUR, wenn die Stichwort-Heuristik nichts fand (`keyword_hit` ist None) und
|
||||
die LLM-Stufe aktiviert ist. `on_emergency(category)` (async) wird bei Treffer
|
||||
aufgerufen (z. B. WS-Event). Gibt den Task zurueck oder None - blockiert nie.
|
||||
"""
|
||||
if keyword_hit is not None or not cfg.emergency_llm_enabled:
|
||||
return None
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(
|
||||
_llm_check(user, text, store, cfg, on_emergency)
|
||||
)
|
||||
except RuntimeError:
|
||||
return None # kein laufender Event-Loop (z. B. Test) -> ueberspringen
|
||||
_pending.add(task)
|
||||
task.add_done_callback(_pending.discard)
|
||||
return task
|
||||
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
|
||||
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
|
||||
return {"category": "manual", "notice": notify_notice(language)}
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
"""LLM-Notfall-Klassifikation (zweite Stufe der Notfall-Erkennung).
|
||||
|
||||
Ergaenzt die schnelle Stichwort-Heuristik (`app.safety.emergency.detect`) um einen
|
||||
LLM-Klassifikator, der Formulierungen erkennt, die keine Stichwoerter treffen.
|
||||
|
||||
Bewusst **best-effort** und mit Konfidenz-Schwelle (sensibler Senioren-Kontext):
|
||||
Ein LLM-Fehler oder kaputtes JSON fuehrt nie zu einem Alarm und nie zu einem Fehler
|
||||
im Antwort-Turn.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.config import Settings, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gueltige Notfall-Kategorien (deckungsgleich mit der Stichwort-Heuristik).
|
||||
VALID_CATEGORIES = {"medical", "self_harm", "help"}
|
||||
|
||||
_SYSTEM_PROMPT = (
|
||||
"Du bist ein Sicherheits-Klassifikator fuer einen Senioren-Sprachassistenten. "
|
||||
"Beurteile, ob die Nutzeraeusserung einen akuten Notfall beschreibt. Kategorien: "
|
||||
"'medical' (akute medizinische Notlage, z. B. Brustschmerz, Atemnot, Sturz, "
|
||||
"Schlaganfall), 'self_harm' (Suizidalitaet/Selbstgefaehrdung), 'help' (akuter "
|
||||
"Hilferuf, z. B. Feuer, Notruf), 'none' (kein Notfall). Antworte AUSSCHLIESSLICH "
|
||||
"mit JSON: {\"category\": \"medical|self_harm|help|none\", \"confidence\": 0.0-1.0, "
|
||||
"\"reason\": \"kurze Begruendung\"}. Sei zurueckhaltend: nur echte, akute Notlagen "
|
||||
"sind ein Notfall, keine beilaeufigen Erwaehnungen oder Vergangenes."
|
||||
)
|
||||
|
||||
|
||||
def _build_classifier_llm(cfg: Settings):
|
||||
"""Baut eine eigene LLM-Instanz fuer die Klassifikation (eigener JSON-Prompt)."""
|
||||
provider = cfg.emergency_llm_provider or cfg.default_llm_provider
|
||||
if provider == "local-openai-compatible":
|
||||
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
|
||||
|
||||
return LocalOpenAICompatibleLLM(
|
||||
cfg.local_llm_base_url,
|
||||
cfg.local_llm_api_key,
|
||||
cfg.local_llm_model,
|
||||
system_prompt=_SYSTEM_PROMPT,
|
||||
disable_reasoning=True,
|
||||
max_tokens=128,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
from app.dependencies import get_llm_provider
|
||||
|
||||
return get_llm_provider(provider, cfg)
|
||||
|
||||
|
||||
def parse_classification(raw: str) -> dict | None:
|
||||
"""Liest {category, confidence, reason} aus der (evtl. verrauschten) LLM-Antwort."""
|
||||
if not raw:
|
||||
return None
|
||||
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
category = data.get("category")
|
||||
if category not in VALID_CATEGORIES:
|
||||
return None
|
||||
try:
|
||||
confidence = float(data.get("confidence", 0.0))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.0
|
||||
return {
|
||||
"category": category,
|
||||
"confidence": confidence,
|
||||
"reason": str(data.get("reason", "")),
|
||||
}
|
||||
|
||||
|
||||
async def classify_emergency(text: str, cfg: Settings = settings) -> dict | None:
|
||||
"""Klassifiziert eine Aeusserung. Liefert {category, confidence, reason} oder None.
|
||||
|
||||
None bedeutet: kein Notfall (bzw. unter der Konfidenz-Schwelle / nicht parsebar).
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
llm = _build_classifier_llm(cfg)
|
||||
raw = await llm.complete(text)
|
||||
result = parse_classification(raw)
|
||||
if result is None:
|
||||
return None
|
||||
if result["confidence"] < cfg.emergency_llm_min_confidence:
|
||||
return None
|
||||
return result
|
||||
|
|
@ -103,3 +103,8 @@ class MemoryOut(BaseModel):
|
|||
content: str
|
||||
created_at: str
|
||||
|
||||
|
||||
|
||||
class EmergencyRequest(BaseModel):
|
||||
"""Vom Nutzer ausgelöster Notruf (Knopf). language für den Hinweistext."""
|
||||
language: str | None = None
|
||||
|
|
|
|||
|
|
@ -577,8 +577,6 @@ function runTurn(path, onopen) {
|
|||
}
|
||||
scrollToBottom();
|
||||
break;
|
||||
case "emergency":
|
||||
addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")");
|
||||
break;
|
||||
case "done":
|
||||
sampleRate = msg.sample_rate || 24000;
|
||||
|
|
@ -852,7 +850,7 @@ $("#admin-close").addEventListener("click", () => $("#admin-panel").classList.ad
|
|||
const ADMIN_TABS = {
|
||||
overview: [["overview", "Übersicht"]],
|
||||
users: [["users", "Verwalten"], ["chat", "Gespräche"]],
|
||||
emergency: [["emergency", "Notfälle"]],
|
||||
emergency: [["emergency", "Notrufe"]],
|
||||
system: [["status", "Status"], ["metrics", "Metriken"], ["log", "Log"]],
|
||||
config: [["settings", "Einstellungen"], ["words", "Wörterbuch"]],
|
||||
};
|
||||
|
|
@ -1903,3 +1901,31 @@ function buildSettingRow(item) {
|
|||
}
|
||||
|
||||
$("#load-settings").addEventListener("click", loadSettings);
|
||||
|
||||
|
||||
// === NOTRUF-KNOPF =========================================================
|
||||
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
|
||||
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
|
||||
const emergencyBtn = $("#emergency-btn");
|
||||
if (emergencyBtn) {
|
||||
emergencyBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Wirklich Hilfe rufen?")) return;
|
||||
const lang = (langSel && langSel.value) || "de";
|
||||
emergencyBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch("/api/emergency", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const notice = (data && data.notice) ||
|
||||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.";
|
||||
addMessage("emergency", "🆘 " + notice);
|
||||
} catch (e) {
|
||||
addMessage("emergency", "🆘 Fehler beim Auslösen des Notrufs.");
|
||||
} finally {
|
||||
emergencyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<nav class="shrink-0 flex border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 overflow-x-auto">
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="overview">📊 Übersicht</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="users">👥 Nutzer</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notfälle</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="emergency">🚨 Notrufe</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="system">🖥 System</button>
|
||||
<button class="admin-tab px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors" data-tab="config">⚙ Konfiguration</button>
|
||||
</nav>
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
<div id="admin-tab-emergency" class="hidden h-full overflow-y-auto p-4">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-base">Notfall-Ereignisse</h3>
|
||||
<h3 class="font-semibold text-base">Notrufe (vom Nutzer ausgelöst)</h3>
|
||||
<button id="load-emergency"
|
||||
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
|
||||
</div>
|
||||
|
|
@ -339,6 +339,12 @@
|
|||
<main id="messages" class="flex-1 overflow-y-auto px-4 py-4 space-y-3 w-full max-w-3xl mx-auto"></main>
|
||||
|
||||
<footer class="shrink-0 border-t border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.6rem)]">
|
||||
<div class="w-full max-w-3xl mx-auto mb-2">
|
||||
<button type="button" id="emergency-btn"
|
||||
class="w-full rounded-xl bg-red-600 hover:bg-red-700 active:bg-red-800 text-white font-semibold py-3 text-base flex items-center justify-center gap-2 transition-colors">
|
||||
🆘 Hilfe rufen
|
||||
</button>
|
||||
</div>
|
||||
<form id="prompt-form" class="flex items-center gap-2 w-full max-w-3xl mx-auto">
|
||||
<button type="button" id="mic" title="Mikrofon" aria-label="Mikrofon"
|
||||
class="shrink-0 h-11 w-11 grid place-items-center rounded-full bg-emerald-600 text-white text-lg">🎤</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue