Compare commits
No commits in common. "d738921d25a033bf47c862dd98e35a66c9c38f0b" and "15d87141050431260a2027c4ff4423b5b4968b51" have entirely different histories.
d738921d25
...
15d8714105
10 changed files with 5 additions and 220 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -3,25 +3,6 @@
|
|||
Alle nennenswerten Änderungen an **Alexis**. Format lose nach [Keep a Changelog](https://keepachangelog.com/de/);
|
||||
Versionierung nach [SemVer](https://semver.org/lang/de/) (prä-1.0: `0.x`).
|
||||
|
||||
## [0.3.1] — 2026-06-30
|
||||
|
||||
### Hinzugefügt
|
||||
- **Admin: „Verlauf löschen" pro Nutzer** — löscht Sessions + Nachrichten eines
|
||||
Nutzers, ohne Konto/Erinnerungen/Nutzungsdaten (Button + `DELETE
|
||||
/api/admin/users/{id}/history`, audit-geloggt).
|
||||
- **Web-Suche pro Nutzer ein-/ausschaltbar** (Admin-Checkbox). `web_search_enabled`
|
||||
fehlte im `AdminUserPrefsUpdate`-Schema und wurde still verworfen — jetzt wirksam.
|
||||
- **Web-Such-Budget pro Nutzer/Tag** (`web_search_daily_limit`, 0 = unbegrenzt;
|
||||
Pref > global): Tageszähler (`web_search_usage`); ist das Kontingent aufgebraucht,
|
||||
wird die Web-Suche für den Turn ausgesetzt. Admin-Eingabefeld.
|
||||
- **Freundliche Hinweise:** Ist die Web-Suche bei einer klar tagesaktuellen Frage
|
||||
nicht verfügbar (abgeschaltet oder Kontingent aufgebraucht), erscheint eine kleine,
|
||||
lokalisierte System-Notiz in der Unterhaltung.
|
||||
|
||||
### Behoben
|
||||
- Veraltete Dev-Datenbank (`data/voice-assistant.db`) entfernt; Produktiv-DB liegt
|
||||
unter `/var/lib/voice-assistant/` (Doku-Klarstellung der Prod/Dev-Trennung).
|
||||
|
||||
## [0.3.0] — 2026-06-30
|
||||
|
||||
### Hinzugefügt
|
||||
|
|
|
|||
|
|
@ -131,18 +131,6 @@ async def delete_user(user_id: str):
|
|||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@router.delete("/admin/users/{user_id}/history", dependencies=[Depends(require_admin)])
|
||||
async def delete_user_history(user_id: str, request: Request):
|
||||
"""Loescht NUR den Gespraechsverlauf (Sessions + Nachrichten) eines Nutzers;
|
||||
Konto, Erinnerungen und Nutzungsdaten bleiben erhalten."""
|
||||
store = get_store()
|
||||
if store.get_user(user_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||
deleted = store.delete_user_history(user_id)
|
||||
log_admin_action(request, "user_history_deleted", user_id=user_id)
|
||||
return {"user_id": user_id, "deleted_messages": deleted}
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||
async def reset_token(user_id: str):
|
||||
"""Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig.
|
||||
|
|
@ -237,8 +225,6 @@ async def list_users():
|
|||
"emergency_contacts", "emergency_phones", "emergency_cooldown_minutes",
|
||||
)},
|
||||
"location_consent": bool((u.prefs or {}).get("location_consent", False)),
|
||||
"web_search_enabled": bool((u.prefs or {}).get("web_search_enabled", runtime_settings.web_search_enabled)),
|
||||
"web_search_daily_limit": (u.prefs or {}).get("web_search_daily_limit", ""),
|
||||
"is_admin": is_admin_user(u),
|
||||
}
|
||||
for u in get_store().list_users()
|
||||
|
|
|
|||
|
|
@ -32,33 +32,7 @@ from app.dependencies import (
|
|||
from app.store import ANONYMOUS_USER_ID, SessionOwnershipError
|
||||
from app.audio.vad import EnergyVAD
|
||||
from app.core.memory_extractor import maybe_schedule_extraction
|
||||
from app.quota import (enforce_quota, record_usage, QuotaExceededError,
|
||||
web_search_within_budget, record_web_search)
|
||||
from app.pipeline.search_backstop import should_force_search
|
||||
|
||||
# Freundliche Hinweise (Senioren), wenn die Web-Suche bei einer offensichtlich
|
||||
# tagesaktuellen Frage nicht verfuegbar ist. Fallback: Deutsch.
|
||||
_WS_OFF_NOTICE = {
|
||||
"de": "Hinweis: Die Web-Suche ist gerade nicht eingeschaltet — diese Antwort stammt aus meinem Wissen und ist vielleicht nicht ganz aktuell.",
|
||||
"en": "Note: Web search is currently turned off — this answer comes from my own knowledge and may not be fully up to date.",
|
||||
"nl": "Let op: webzoeken staat nu uit — dit antwoord komt uit mijn eigen kennis en is misschien niet helemaal actueel.",
|
||||
"fr": "Remarque : la recherche web est désactivée — cette réponse vient de mes connaissances et n'est peut-être pas à jour.",
|
||||
"es": "Nota: la búsqueda web está desactivada — esta respuesta procede de mis conocimientos y puede no estar actualizada.",
|
||||
"it": "Nota: la ricerca web è disattivata — questa risposta viene dalle mie conoscenze e potrebbe non essere aggiornata.",
|
||||
}
|
||||
_WS_BUDGET_NOTICE = {
|
||||
"de": "Hinweis: Das Web-Such-Kontingent für heute ist aufgebraucht — diese Antwort stammt aus meinem Wissen und ist vielleicht nicht ganz aktuell.",
|
||||
"en": "Note: Today's web search allowance is used up — this answer comes from my own knowledge and may not be fully up to date.",
|
||||
"nl": "Let op: het webzoek-tegoed voor vandaag is op — dit antwoord komt uit mijn eigen kennis en is misschien niet helemaal actueel.",
|
||||
"fr": "Remarque : votre quota de recherche web pour aujourd'hui est épuisé — cette réponse vient de mes connaissances et n'est peut-être pas à jour.",
|
||||
"es": "Nota: su cupo de búsqueda web de hoy se ha agotado — esta respuesta procede de mis conocimientos y puede no estar actualizada.",
|
||||
"it": "Nota: il credito di ricerca web per oggi è esaurito — questa risposta viene dalle mie conoscenze e potrebbe non essere aggiornata.",
|
||||
}
|
||||
|
||||
|
||||
def _ws_notice(language: str | None, kind: str) -> str:
|
||||
table = _WS_BUDGET_NOTICE if kind == "budget" else _WS_OFF_NOTICE
|
||||
return table.get((language or "de").lower(), table["de"])
|
||||
from app.quota import enforce_quota, record_usage, QuotaExceededError
|
||||
from app.auth import authenticate, _cookie_value, CAPABILITY_COOKIE
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -89,12 +63,9 @@ def _authenticate(websocket: WebSocket, token: str | None):
|
|||
return authenticate(websocket.headers, client_host, _capability_token(websocket, token))
|
||||
|
||||
|
||||
async def _resolve(user, session_id, options, store=None):
|
||||
async def _resolve(user, session_id, options):
|
||||
"""Loest Route + Orchestrator + Output-Endpunkt auf (kann RoutingError/Ownership werfen)."""
|
||||
overrides = {key: options.get(key) for key in _OVERRIDE_KEYS}
|
||||
# Web-Such-Tagesbudget aufgebraucht -> Web-Suche fuer diesen Turn aus.
|
||||
if store is not None and not web_search_within_budget(user, store):
|
||||
overrides["web_search_enabled"] = False
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
output = await resolve_output_endpoint(route)
|
||||
|
|
@ -198,7 +169,6 @@ async def _run_turn(
|
|||
return
|
||||
|
||||
record_usage(user, store, len(text) + len(trace.semantic_response or ""))
|
||||
record_web_search(user, store, trace.web_searches)
|
||||
|
||||
if session_id:
|
||||
store.append_message(session_id, user.id, "user", text)
|
||||
|
|
@ -209,11 +179,6 @@ async def _run_turn(
|
|||
{"type": "semantic", "text": trace.semantic_response,
|
||||
"spoken": trace.spoken_response, "citations": trace.citations}
|
||||
)
|
||||
# Freundlicher Hinweis, wenn die Web-Suche bei einer offensichtlich aktuellen
|
||||
# Frage nicht verfuegbar war (abgeschaltet oder Tagesbudget aufgebraucht).
|
||||
if not route.web_search_enabled and should_force_search(text):
|
||||
kind = "budget" if not web_search_within_budget(user, store) else "off"
|
||||
await websocket.send_json({"type": "notice", "text": _ws_notice(route.language, kind)})
|
||||
# Im text_only-Modus kommt kein Audio (Gerät spricht selbst).
|
||||
if not audio_stream and not text_only:
|
||||
await websocket.send_bytes(audio)
|
||||
|
|
@ -234,7 +199,7 @@ async def _cancel_active(task, websocket) -> None:
|
|||
|
||||
async def _chat_turn(websocket, store, user, session_id, text, options):
|
||||
try:
|
||||
route, orchestrator, output = await _resolve(user, session_id, options, store)
|
||||
route, orchestrator, output = await _resolve(user, session_id, options)
|
||||
except SessionOwnershipError as exc:
|
||||
await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)})
|
||||
return
|
||||
|
|
@ -246,7 +211,7 @@ async def _chat_turn(websocket, store, user, session_id, text, options):
|
|||
|
||||
async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
|
||||
try:
|
||||
route, orchestrator, output = await _resolve(user, session_id, options, store)
|
||||
route, orchestrator, output = await _resolve(user, session_id, options)
|
||||
except SessionOwnershipError as exc:
|
||||
await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)})
|
||||
return
|
||||
|
|
|
|||
|
|
@ -189,7 +189,6 @@ class Settings(BaseSettings):
|
|||
memory_extraction_provider: str = ""
|
||||
audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten)
|
||||
web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar
|
||||
web_search_daily_limit: int = 0 # max. Web-Suchen pro Nutzer/Tag (0 = unbegrenzt); pro Nutzer überschreibbar
|
||||
# TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light.
|
||||
tts_normalize_level: str = "auto"
|
||||
stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette)
|
||||
|
|
|
|||
|
|
@ -293,10 +293,8 @@ class Orchestrator:
|
|||
pass
|
||||
|
||||
collected_citations: list[str] = []
|
||||
search_count = [0]
|
||||
|
||||
async def _on_citations(urls) -> None:
|
||||
search_count[0] += 1 # eine (erfolgreiche) Web-Suche je on_citations-Aufruf
|
||||
for u in (urls or []):
|
||||
if u not in collected_citations:
|
||||
collected_citations.append(u)
|
||||
|
|
@ -334,7 +332,6 @@ class Orchestrator:
|
|||
if not trace.semantic_response:
|
||||
raise RuntimeError("LLM returned an empty response")
|
||||
trace.citations = collected_citations
|
||||
trace.web_searches = search_count[0]
|
||||
|
||||
trace.spoken_response = await self.spoken_adapter.run(
|
||||
trace.semantic_response,
|
||||
|
|
|
|||
28
app/quota.py
28
app/quota.py
|
|
@ -40,31 +40,3 @@ def enforce_quota(user, store, cfg=None) -> None:
|
|||
def record_usage(user, store, units: int = 0) -> None:
|
||||
store.add_usage(user.id, units)
|
||||
metrics.inc("turns_total")
|
||||
|
||||
|
||||
def web_search_limit(user, cfg=None) -> int:
|
||||
"""Max. Web-Suchen pro Tag fuer diesen Nutzer (Pref > global; 0 = unbegrenzt)."""
|
||||
if cfg is None:
|
||||
from app.runtime_config import runtime_settings
|
||||
cfg = runtime_settings
|
||||
pref = user.prefs.get("web_search_daily_limit") if user and user.prefs else None
|
||||
if pref is not None:
|
||||
try:
|
||||
return int(pref)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return cfg.web_search_daily_limit
|
||||
|
||||
|
||||
def web_search_within_budget(user, store, cfg=None) -> bool:
|
||||
"""Darf der Nutzer heute noch eine Web-Suche ausloesen? (Limit 0 = unbegrenzt)."""
|
||||
limit = web_search_limit(user, cfg)
|
||||
if not limit or limit <= 0:
|
||||
return True
|
||||
return store.web_search_count(user.id) < limit
|
||||
|
||||
|
||||
def record_web_search(user, store, n: int = 1) -> None:
|
||||
"""Zaehlt n tatsaechlich ausgefuehrte Web-Suchen fuer den Nutzer."""
|
||||
if n and n > 0 and user is not None:
|
||||
store.add_web_search_usage(user.id, n)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ class PipelineTrace(BaseModel):
|
|||
spoken_response: str | None = None
|
||||
tts_ready_text: str | None = None
|
||||
citations: list[str] = [] # Quellen-URLs der Web-Suche (für die UI-Bubble)
|
||||
web_searches: int = 0 # Anzahl tatsächlich ausgeführter Web-Suchen in diesem Turn
|
||||
|
||||
|
||||
class SpeakRequest(BaseModel):
|
||||
|
|
@ -165,8 +164,6 @@ class AdminUserPrefsUpdate(BaseModel):
|
|||
Format geprüft (serverseitige Härtung zusätzlich zur Frontend-Validierung).
|
||||
"""
|
||||
allowed_languages: str | None = None
|
||||
web_search_enabled: bool | None = None # Web-Suche (Weg 2) pro Nutzer an/aus
|
||||
web_search_daily_limit: int | None = None # max. Web-Suchen/Tag (0 = unbegrenzt)
|
||||
# Notfall-Profil (in user.prefs gespeichert, fließt in die Notruf-Meldungen ein)
|
||||
full_name: str | None = None
|
||||
street: str | None = None
|
||||
|
|
|
|||
54
app/store.py
54
app/store.py
|
|
@ -120,25 +120,12 @@ class Store(ABC):
|
|||
def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int:
|
||||
"""Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl."""
|
||||
|
||||
@abstractmethod
|
||||
def web_search_count(self, user_id: str, day: str | None = None) -> int:
|
||||
"""Anzahl Web-Suchen des Nutzers am Tag (Default: heute, UTC)."""
|
||||
|
||||
@abstractmethod
|
||||
def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int:
|
||||
"""Zaehlt n Web-Suchen dazu und liefert die neue Tages-Zahl."""
|
||||
|
||||
@abstractmethod
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
|
||||
Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden.
|
||||
Liefert True, wenn der Nutzer existierte und geloescht wurde."""
|
||||
|
||||
@abstractmethod
|
||||
def delete_user_history(self, user_id: str) -> int:
|
||||
"""Loescht NUR den Gespraechsverlauf (Sessions + Nachrichten) eines Nutzers;
|
||||
Konto, Erinnerungen und Nutzungsdaten bleiben. Liefert die Zahl geloeschter Nachrichten."""
|
||||
|
||||
@abstractmethod
|
||||
def reset_token(self, user_id: str) -> tuple[User, str] | None:
|
||||
"""Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig.
|
||||
|
|
@ -237,12 +224,6 @@ class SQLiteStore(Store):
|
|||
units INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, day)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS web_search_usage (
|
||||
user_id TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, day)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS emergency_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
|
|
@ -494,29 +475,6 @@ class SQLiteStore(Store):
|
|||
).fetchone()
|
||||
return int(row["requests"])
|
||||
|
||||
def web_search_count(self, user_id: str, day: str | None = None) -> int:
|
||||
day = day or self._today()
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT count FROM web_search_usage WHERE user_id = ? AND day = ?",
|
||||
(user_id, day),
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int:
|
||||
day = day or self._today()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO web_search_usage (user_id, day, count) VALUES (?, ?, ?)"
|
||||
" ON CONFLICT(user_id, day) DO UPDATE SET count = count + excluded.count",
|
||||
(user_id, day, int(n)),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT count FROM web_search_usage WHERE user_id = ? AND day = ?",
|
||||
(user_id, day),
|
||||
).fetchone()
|
||||
return int(row["count"])
|
||||
|
||||
def delete_user(self, user_id: str) -> bool:
|
||||
if user_id == ANONYMOUS_USER_ID:
|
||||
raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.")
|
||||
|
|
@ -531,21 +489,9 @@ class SQLiteStore(Store):
|
|||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM web_search_usage WHERE user_id = ?", (user_id,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
return True
|
||||
|
||||
def delete_user_history(self, user_id: str) -> int:
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM messages WHERE session_id IN"
|
||||
" (SELECT id FROM sessions WHERE user_id = ?)",
|
||||
(user_id,),
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
return n
|
||||
|
||||
def reset_token(self, user_id: str) -> tuple[User, str] | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
|
|
|
|||
|
|
@ -757,10 +757,6 @@ function runTurn(path, onopen) {
|
|||
scrollToBottom();
|
||||
ws.close();
|
||||
break;
|
||||
case "notice":
|
||||
// Kleine System-Notiz (z. B. Web-Suche aus / Kontingent aufgebraucht).
|
||||
if (msg.text) addMessage("system", msg.text, { lang: turnLang() });
|
||||
break;
|
||||
case "error":
|
||||
addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt"));
|
||||
ws.close();
|
||||
|
|
@ -1197,7 +1193,6 @@ function buildUserCard(u) {
|
|||
<button class="btn-rename text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Umbenennen</button>
|
||||
<button class="btn-admin text-xs rounded-lg border px-2.5 py-1.5 transition-colors"></button>
|
||||
<button class="btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Link erneuern</button>
|
||||
<button class="btn-history text-xs rounded-lg border border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300 px-2.5 py-1.5 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors">Verlauf löschen</button>
|
||||
<button class="btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1224,19 +1219,6 @@ function buildUserCard(u) {
|
|||
<div class="lang-allow flex flex-wrap gap-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Web-Suche (Weg 2) -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<label class="text-xs text-slate-600 dark:text-slate-300 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" class="ws-enabled" /> Web-Suche für aktuelle Fragen erlauben
|
||||
<span class="ws-status text-slate-400"></span>
|
||||
</label>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">max. Suchen pro Tag:</span>
|
||||
<input type="number" min="0" class="ws-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
|
||||
<span class="ws-limit-status text-xs text-slate-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notfall-Profil -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<button class="btn-prof text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
|
||||
|
|
@ -1325,39 +1307,6 @@ function buildUserCard(u) {
|
|||
});
|
||||
const consentEl = card.querySelector(".prof-consent");
|
||||
if (consentEl) consentEl.checked = !!u.location_consent;
|
||||
|
||||
// Web-Suche pro Nutzer: sofort speichern bei Änderung
|
||||
const wsEl = card.querySelector(".ws-enabled");
|
||||
const wsStatus = card.querySelector(".ws-status");
|
||||
if (wsEl) {
|
||||
wsEl.checked = !!u.web_search_enabled;
|
||||
wsEl.addEventListener("change", async () => {
|
||||
if (wsStatus) wsStatus.textContent = "speichere …";
|
||||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { web_search_enabled: wsEl.checked });
|
||||
if (wsStatus) {
|
||||
wsStatus.textContent = res ? "✓" : "Fehler";
|
||||
setTimeout(() => { wsStatus.textContent = ""; }, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Web-Such-Tagesbudget (leer/0 = unbegrenzt) — sofort speichern
|
||||
const wsLimit = card.querySelector(".ws-limit");
|
||||
const wsLimitStatus = card.querySelector(".ws-limit-status");
|
||||
if (wsLimit) {
|
||||
const lim = Number(u.web_search_daily_limit);
|
||||
wsLimit.value = Number.isFinite(lim) && lim > 0 ? lim : "";
|
||||
wsLimit.addEventListener("change", async () => {
|
||||
const n = parseInt(wsLimit.value, 10);
|
||||
const val = Number.isFinite(n) && n > 0 ? n : 0; // leer/0 = unbegrenzt
|
||||
if (wsLimitStatus) wsLimitStatus.textContent = "speichere …";
|
||||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { web_search_daily_limit: val });
|
||||
if (wsLimitStatus) {
|
||||
wsLimitStatus.textContent = res ? "✓" : "Fehler";
|
||||
setTimeout(() => { wsLimitStatus.textContent = ""; }, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
const profBtn = card.querySelector(".btn-prof");
|
||||
const profSection = card.querySelector(".prof-section");
|
||||
const profArrow = card.querySelector(".prof-arrow");
|
||||
|
|
@ -1460,13 +1409,6 @@ function buildUserCard(u) {
|
|||
}
|
||||
});
|
||||
|
||||
// Verlauf löschen (Konto + Erinnerungen bleiben)
|
||||
card.querySelector(".btn-history").addEventListener("click", async () => {
|
||||
if (!confirm(`Gesprächsverlauf von „${u.display_name}" löschen?\nKonto und Erinnerungen bleiben erhalten.`)) return;
|
||||
const res = await adminFetch(`/api/admin/users/${uid}/history`, "DELETE");
|
||||
if (res) alert(`Verlauf gelöscht (${res.deleted_messages ?? 0} Nachrichten).`);
|
||||
});
|
||||
|
||||
// Löschen
|
||||
card.querySelector(".btn-delete").addEventListener("click", async () => {
|
||||
if (!confirm(`Nutzer „${u.display_name}" und alle Daten unwiderruflich löschen?`)) return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "voice-assistant-gateway"
|
||||
version = "0.3.1"
|
||||
version = "0.3.0"
|
||||
description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue