diff --git a/CHANGELOG.md b/CHANGELOG.md index ae9a893..83772d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **Hardware-aware model suggestions.** The dashboard detects system RAM (GlobalMemoryStatusEx) and GPU VRAM (nvidia-smi, or the display-class registry's qwMemorySize — which also finds Ryzen AI iGPU carve-outs) and computes a per-provider size budget: FastFlowLM scales with installed RAM (32 GB ≈ 4B-class on the NPU, 64 GB ≈ 9B), Ollama with VRAM (e.g. 8 GB ≈ 9B) or conservatively with RAM on CPU-only boxes. The pull card shows the detected budget, suggestions hide models that don't fit (near-misses are marked "tight fit"), and free-typing an oversized model asks for confirmation. New daemon action `model_recommendations`; new module `ffp_hardware`. - **Chat moved into the web dashboard.** Chat is now a Chat tab in the daemon-served dashboard — `Ctrl+Shift+T` and the tray "Open Chat" open it, and `Ctrl+Shift+A` sends the current selection there (prefilled). Threads, history, and the "ground answers in my notes" toggle work as before; the standalone tkinter chat popup is retired. The dashboard also deep-links by URL hash (`/#chat`). New module `ffp_chat`; new daemon actions `chat_threads_list` / `chat_thread_get` / `chat_send` / `chat_thread_delete` / `chat_stage_selection` / `chat_take_staged`. - **Read, re-file, and delete notes from the dashboard.** The Notes tab is now an organizer: click a note to read its full body and source link, move it to a different bucket (the LLM's pick is no longer final), or delete it. New daemon actions `note_get` / `note_move` / `note_delete` — all vault-contained and path-traversal guarded. +- **Notification settings + a notifications feed.** A new Notifications card in Config controls desktop toasts: a master on/off, per-event toggles (action results, clipboard suggestions, settings changes, updates, diagnostics, app lifecycle, errors), a configurable dedupe window, Do-Not-Disturb, and quiet hours — errors and warnings always come through while DND / quiet hours are on. Every notification (shown *or* muted) is recorded and surfaced as a feed in the Telemetry tab. All policy lives in the daemon (`ffp_notifications`), which classifies each toast by message pattern, so the ~45 existing notification call sites were left untouched; the AHK front-end consults the daemon and fails *open* (shows the toast) when the daemon is unreachable. New config block `notifications`, new module `ffp_notifications`, new daemon actions `notify_gate` (decide + log) and `notifications_log` (read). ### Fixed diff --git a/config/grammar_hotkey.config.example.json b/config/grammar_hotkey.config.example.json index fd4f095..aca4634 100644 --- a/config/grammar_hotkey.config.example.json +++ b/config/grammar_hotkey.config.example.json @@ -30,6 +30,22 @@ "dictionary": { "protected_words": [] }, + "notifications": { + "enabled": true, + "dedupe_seconds": 5, + "dnd": false, + "log_enabled": true, + "quiet_hours": { "enabled": false, "start": "22:00", "end": "07:00" }, + "categories": { + "errors": { "enabled": true }, + "clipboard_suggestions": { "enabled": true }, + "updates": { "enabled": true }, + "diagnostics": { "enabled": true }, + "settings": { "enabled": true }, + "lifecycle": { "enabled": true }, + "action_result": { "enabled": true } + } + }, "hotkeys": { "grammar_fix": "^+g", "open_chat": "^!c", diff --git a/scripts/ffp_config.py b/scripts/ffp_config.py index 4f351a8..1534eed 100644 --- a/scripts/ffp_config.py +++ b/scripts/ffp_config.py @@ -77,6 +77,22 @@ "dictionary": { "protected_words": [], }, + "notifications": { + "enabled": True, + "dedupe_seconds": 5, + "dnd": False, + "log_enabled": True, + "quiet_hours": {"enabled": False, "start": "22:00", "end": "07:00"}, + "categories": { + "errors": {"enabled": True}, + "clipboard_suggestions": {"enabled": True}, + "updates": {"enabled": True}, + "diagnostics": {"enabled": True}, + "settings": {"enabled": True}, + "lifecycle": {"enabled": True}, + "action_result": {"enabled": True}, + }, + }, "modes": { "grammar": { "label": "Grammar fix", @@ -313,6 +329,46 @@ def normalize_llm_config(cfg: dict, *, prefer_legacy: bool = False) -> dict: _PATCH_HOTKEYS_KEYS = frozenset({"ask_chat", "capture_note", "grammar_fix", "open_chat"}) _PATCH_TONE_KEYS = frozenset({"preset"}) _PATCH_PROVIDER_PROFILE_KEYS = frozenset({"base_url", "model", "auth_bearer", "timeout_seconds", "auto_start"}) +# Notification categories accepted in a patch. Keep in sync with +# ffp_notifications.CATEGORIES (a test guards against drift). +_PATCH_NOTIF_CATEGORIES = frozenset({ + "errors", "clipboard_suggestions", "updates", "diagnostics", + "settings", "lifecycle", "action_result", +}) +_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):[0-5]\d$") + + +def _filter_notifications_patch(value: dict) -> dict: + """Whitelist + type-coerce the notifications settings block.""" + out: dict = {} + for flag in ("enabled", "dnd", "log_enabled"): + if flag in value: + out[flag] = bool(value[flag]) + if "dedupe_seconds" in value: + try: + out["dedupe_seconds"] = max(0.0, min(float(value["dedupe_seconds"]), 3600.0)) + except (TypeError, ValueError): + pass + quiet = value.get("quiet_hours") + if isinstance(quiet, dict): + filtered_quiet: dict = {} + if "enabled" in quiet: + filtered_quiet["enabled"] = bool(quiet["enabled"]) + for time_key in ("start", "end"): + tv = quiet.get(time_key) + if isinstance(tv, str) and _HHMM_RE.match(tv): + filtered_quiet[time_key] = tv + if filtered_quiet: + out["quiet_hours"] = filtered_quiet + cats = value.get("categories") + if isinstance(cats, dict): + filtered_cats: dict = {} + for cat_id, cat_val in cats.items(): + if cat_id in _PATCH_NOTIF_CATEGORIES and isinstance(cat_val, dict) and "enabled" in cat_val: + filtered_cats[cat_id] = {"enabled": bool(cat_val["enabled"])} + if filtered_cats: + out["categories"] = filtered_cats + return out # Built-in mode prompts stay locked against patching (system-prompt injection # guard); only tone.preset is patchable among them. User-defined modes accept @@ -392,6 +448,10 @@ def filter_config_patch(patch: dict) -> dict: filtered = {k: v for k, v in value.items() if k in _PATCH_NOTES_KEYS} if filtered: out[key] = filtered + elif key == "notifications" and isinstance(value, dict): + filtered = _filter_notifications_patch(value) + if filtered: + out[key] = filtered elif key == "hotkeys" and isinstance(value, dict): filtered = {k: v for k, v in value.items() if k in _PATCH_HOTKEYS_KEYS} if filtered: diff --git a/scripts/ffp_daemon.py b/scripts/ffp_daemon.py index ee725fe..abe1026 100644 --- a/scripts/ffp_daemon.py +++ b/scripts/ffp_daemon.py @@ -41,6 +41,7 @@ sys.path.insert(0, str(HERE)) import ffp_config # noqa: E402 +import ffp_notifications # noqa: E402 import ffp_notify # noqa: E402 import ffp_provider_runtime # noqa: E402 import grammar_fix # noqa: E402 @@ -558,6 +559,25 @@ def _act_notify(args: dict) -> str: return "queued" +def _act_notify_gate(args: dict) -> dict: + """Decide whether the AHK front-end should display a toast, and log the + decision. Returns {show, reason, category}; AHK shows iff show is true (and + fails open — shows anyway — when this daemon is unreachable). All policy + (per-category on/off, dedupe, quiet-hours, DND) lives in ffp_notifications.""" + title = str(args.get("title") or "").strip() or "Flowkey" + message = str(args.get("message") or "") + return ffp_notifications.gate(title, message, config=grammar_fix.load_config()) + + +def _act_notifications_log(args: dict) -> list[dict]: + """Recent notification decisions (newest first) for the Telemetry feed.""" + try: + limit = int(args.get("limit") or 50) + except (TypeError, ValueError): + limit = 50 + return ffp_notifications.read_log(max(1, min(limit, 500))) + + def _act_open_dashboard(_args: dict) -> str: """Signal the AHK front-end to open the dashboard (marker file).""" try: @@ -659,6 +679,8 @@ def _act_chat_take_staged(_args: dict) -> dict: "pull_start": _act_pull_start, "pull_status": _act_pull_status, "notify": _act_notify, + "notify_gate": _act_notify_gate, + "notifications_log": _act_notifications_log, "save_note": _act_save_note, "chat_threads_list": _act_chat_threads_list, "chat_thread_get": _act_chat_thread_get, @@ -683,6 +705,7 @@ def _act_chat_take_staged(_args: dict) -> dict: "set_autostart", "bench_start", "pull_start", "chat_send", "chat_thread_delete", "chat_stage_selection", "chat_take_staged", "note_move", "note_delete", + "notify_gate", # writes the notifications log + updates dedupe state } _shutdown_event = threading.Event() diff --git a/scripts/ffp_notifications.py b/scripts/ffp_notifications.py new file mode 100644 index 0000000..71f3db8 --- /dev/null +++ b/scripts/ffp_notifications.py @@ -0,0 +1,337 @@ +"""Notification gating + telemetry log (daemon-backed). + +The AHK front-end consults :func:`gate` before showing every toast. We classify +the message into a category, then apply the user's settings — a master on/off, +per-category on/off, a dedupe window, and quiet-hours / Do-Not-Disturb — and +append every decision (shown *or* suppressed) to ``data/notifications.jsonl`` so +the dashboard can render a feed. + +Design notes: +- All policy lives here (not in AHK) so it is unit-testable and there is one + source of truth. AHK just calls ``notify_gate`` and respects the verdict, + failing *open* (showing the toast) when the daemon is unreachable. +- Classification is by message pattern (emoji + keywords) rather than threading + a category argument through the ~45 ``Notify()`` call sites. +- "Critical" categories (errors/warnings) bypass quiet-hours and DND so a real + failure is never silently swallowed while you're away. They are still logged, + and an explicit per-category disable is still honored. +- The master ``enabled`` switch is a true kill switch: off means zero toasts + (including errors) — it is the user's explicit "mute everything" choice. + +Stdlib only. +""" + +from __future__ import annotations + +import datetime +import json +import logging +import threading +import time +from pathlib import Path + +import paths as _paths + +log = logging.getLogger("ffp.notifications") + +NOTIFICATIONS_LOG: Path = _paths.DATA_DIR / "notifications.jsonl" + +# Category ids, in classify() precedence order. Keep in sync with the +# "notifications.categories" defaults in ffp_config.DEFAULT_CONFIG (a test +# guards against drift). +CATEGORIES: tuple[str, ...] = ( + "errors", + "clipboard_suggestions", + "updates", + "diagnostics", + "settings", + "lifecycle", + "action_result", +) + +# Categories that always show regardless of quiet-hours / DND (still logged, +# still honor an explicit per-category disable, still subject to dedupe). +CRITICAL_CATEGORIES = frozenset({"errors"}) + +# Human labels shared with the dashboard via build_config_snapshot(). +CATEGORY_LABELS: dict[str, str] = { + "errors": "Errors & warnings", + "clipboard_suggestions": "Clipboard suggestions", + "updates": "Update checks", + "diagnostics": "Diagnostics", + "settings": "Settings changes", + "lifecycle": "App lifecycle", + "action_result": "Action results", +} + +# Failure / can't-do-that feedback. Anything matching is "errors" (critical): +# direct feedback to a user action must never be hidden by quiet-hours/DND. +_ERROR_KEYWORDS = ( + "failed", "error", "unavailable", "unreachable", "not found", "rejected", + "could not", "busy", "no selected text", "no text left", "nothing to", + "no text returned", +) +_SETTINGS_KEYWORDS = ( + "performance:", "tone:", "history text:", "start with windows", "server:", +) + +# How big notifications.jsonl may grow before append() trims it to the most +# recent _LOG_KEEP_LINES. Toasts are low-frequency, so this rarely fires. +_LOG_MAX_BYTES = 1_000_000 +_LOG_KEEP_LINES = 1500 + +_DEFAULTS: dict = { + "enabled": True, + "dedupe_seconds": 5, + "dnd": False, + "log_enabled": True, + "quiet_hours": {"enabled": False, "start": "22:00", "end": "07:00"}, + "categories": {c: {"enabled": True} for c in CATEGORIES}, +} + +# In-memory dedupe state: "title|message" -> last-shown monotonic seconds. +# Guarded because the daemon is a ThreadingHTTPServer. +_dedupe_lock = threading.Lock() +_last_shown: dict[str, float] = {} + + +# ---------- Classification ----------------------------------------------------- + + +def classify(title: str, message: str) -> str: + """Map a toast to a notification category. First matching rule wins.""" + t = str(title or "") + m = str(message or "") + blob = (t + " " + m).lower() + + if "⚠" in t or "⚠" in m: + return "errors" + if any(kw in blob for kw in _ERROR_KEYWORDS): + return "errors" + if "📋" in t or "📋" in m or "clipboard watcher" in blob or "detected" in blob: + return "clipboard_suggestions" + if "update" in blob or "up to date" in blob: + return "updates" + if "diagnostic" in blob: + return "diagnostics" + if any(kw in blob for kw in _SETTINGS_KEYWORDS): + return "settings" + if "app ready" in blob or "reloaded" in blob: + return "lifecycle" + return "action_result" + + +# ---------- Quiet-hours helpers ------------------------------------------------ + + +def _parse_hhmm(value: object, default_minutes: int) -> int: + """\"HH:MM\" -> minutes since midnight; default on any malformed input.""" + try: + hh, mm = str(value).split(":") + h, m = int(hh), int(mm) + if 0 <= h <= 23 and 0 <= m <= 59: + return h * 60 + m + except (ValueError, AttributeError): + pass + return default_minutes + + +def _in_quiet_window(now_minutes: int, start_minutes: int, end_minutes: int) -> bool: + """True if now is inside [start, end). A window that wraps midnight + (start > end, e.g. 22:00->07:00) is handled. start == end = no window.""" + if start_minutes == end_minutes: + return False + if start_minutes < end_minutes: + return start_minutes <= now_minutes < end_minutes + return now_minutes >= start_minutes or now_minutes < end_minutes + + +def _now_minutes() -> int: + now = datetime.datetime.now() + return now.hour * 60 + now.minute + + +# ---------- Config resolution -------------------------------------------------- + + +def _coerce_float(value: object, default: float) -> float: + try: + return float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + + +def snapshot(notifications_cfg: object) -> dict: + """Normalized settings (defaults merged) for the dashboard to render. + + Always returns every category so the settings panel can list them even if + the user's config file predates this feature. + """ + cfg = notifications_cfg if isinstance(notifications_cfg, dict) else {} + qh = cfg.get("quiet_hours") if isinstance(cfg.get("quiet_hours"), dict) else {} + cats_in = cfg.get("categories") if isinstance(cfg.get("categories"), dict) else {} + categories = {} + for cid in CATEGORIES: + entry = cats_in.get(cid) if isinstance(cats_in.get(cid), dict) else {} + categories[cid] = { + "enabled": bool(entry.get("enabled", True)), + "label": CATEGORY_LABELS[cid], + "critical": cid in CRITICAL_CATEGORIES, + } + return { + "enabled": bool(cfg.get("enabled", True)), + "dedupe_seconds": _coerce_float(cfg.get("dedupe_seconds", 5), 5.0), + "dnd": bool(cfg.get("dnd", False)), + "log_enabled": bool(cfg.get("log_enabled", True)), + "quiet_hours": { + "enabled": bool(qh.get("enabled", False)), + "start": str(qh.get("start") or "22:00"), + "end": str(qh.get("end") or "07:00"), + }, + "categories": categories, + } + + +# ---------- Gate --------------------------------------------------------------- + + +def gate( + title: str, + message: str, + *, + config: object = None, + now_seconds: float | None = None, + now_minutes: int | None = None, + log_path: Path | None = None, +) -> dict: + """Decide whether AHK should display this toast, logging the decision. + + Returns ``{"show": bool, "reason": str, "category": str}``. ``reason`` is + ``"shown"`` or one of ``all_disabled`` / ``category_disabled`` / ``dnd`` / + ``quiet_hours`` / ``deduped``. ``config`` is the full app config dict (we + read its ``notifications`` block); all timing is injectable for tests. + """ + cfg = config if isinstance(config, dict) else {} + notif = cfg.get("notifications") if isinstance(cfg.get("notifications"), dict) else {} + + category = classify(title, message) + critical = category in CRITICAL_CATEGORIES + + enabled = notif.get("enabled", True) + cats = notif.get("categories") if isinstance(notif.get("categories"), dict) else {} + cat_cfg = cats.get(category) if isinstance(cats.get(category), dict) else {} + cat_enabled = cat_cfg.get("enabled", True) + dedupe_seconds = _coerce_float(notif.get("dedupe_seconds", 5), 5.0) + dnd = bool(notif.get("dnd", False)) + log_enabled = notif.get("log_enabled", True) + qh = notif.get("quiet_hours") if isinstance(notif.get("quiet_hours"), dict) else {} + qh_enabled = bool(qh.get("enabled", False)) + + show = True + reason = "shown" + + if not enabled: + show, reason = False, "all_disabled" + elif not cat_enabled: + show, reason = False, "category_disabled" + elif not critical and dnd: + show, reason = False, "dnd" + elif not critical and qh_enabled: + nm = now_minutes if now_minutes is not None else _now_minutes() + start = _parse_hhmm(qh.get("start"), 22 * 60) + end = _parse_hhmm(qh.get("end"), 7 * 60) + if _in_quiet_window(nm, start, end): + show, reason = False, "quiet_hours" + + # Dedupe is evaluated last so a policy-suppressed toast doesn't consume the + # dedupe slot (otherwise an identical toast right after quiet-hours ends + # could be wrongly deduped against the suppressed one). + if show and dedupe_seconds > 0: + ts = now_seconds if now_seconds is not None else time.monotonic() + key = f"{title}|{message}" + with _dedupe_lock: + last = _last_shown.get(key) + if last is not None and (ts - last) < dedupe_seconds: + show, reason = False, "deduped" + else: + _last_shown[key] = ts + + if log_enabled: + _append_log(title, message, category, show, reason, log_path=log_path) + + return {"show": show, "reason": reason, "category": category} + + +# ---------- Telemetry log ------------------------------------------------------ + + +def _timestamp() -> str: + return datetime.datetime.now().isoformat(timespec="seconds") + + +def _append_log( + title: str, + message: str, + category: str, + shown: bool, + reason: str, + *, + log_path: Path | None = None, +) -> None: + path = Path(log_path) if log_path is not None else NOTIFICATIONS_LOG + entry = { + "ts": _timestamp(), + "title": str(title or ""), + "message": str(message or "")[:500], + "category": category, + "shown": bool(shown), + "reason": reason, + } + try: + path.parent.mkdir(parents=True, exist_ok=True) + _maybe_trim(path) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError as exc: + log.warning("notifications log append failed (%s): %s", path, exc) + + +def _maybe_trim(path: Path) -> None: + """Keep the log from growing unbounded: once it exceeds _LOG_MAX_BYTES, + rewrite it down to the most recent _LOG_KEEP_LINES.""" + try: + if not path.exists() or path.stat().st_size <= _LOG_MAX_BYTES: + return + with path.open("r", encoding="utf-8", errors="replace") as handle: + lines = handle.readlines() + if len(lines) <= _LOG_KEEP_LINES: + return + with path.open("w", encoding="utf-8") as handle: + handle.writelines(lines[-_LOG_KEEP_LINES:]) + except OSError as exc: + log.warning("notifications log trim failed (%s): %s", path, exc) + + +def read_log(limit: int = 50, *, log_path: Path | None = None) -> list[dict]: + """Most recent log entries, newest first (mirrors ffp_telemetry).""" + path = Path(log_path) if log_path is not None else NOTIFICATIONS_LOG + entries: list[dict] = [] + if not path.exists(): + return entries + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + lines = handle.readlines() + except OSError as exc: + log.warning("notifications log read failed (%s): %s", path, exc) + return entries + for raw in reversed(lines): + raw = raw.strip() + if not raw: + continue + try: + entries.append(json.loads(raw)) + except json.JSONDecodeError: + continue + if len(entries) >= limit: + break + return entries diff --git a/scripts/grammar_fix.py b/scripts/grammar_fix.py index ed6829f..5acbd65 100644 --- a/scripts/grammar_fix.py +++ b/scripts/grammar_fix.py @@ -21,6 +21,7 @@ import ffp_config import ffp_flm_server import ffp_llm_client +import ffp_notifications import ffp_provider_runtime import ffp_provider_status import ffp_telemetry @@ -826,6 +827,7 @@ def build_config_snapshot() -> dict: "capture_note": str(hotkeys_cfg.get("capture_note") or "^!n"), "ask_chat": str(hotkeys_cfg.get("ask_chat") or "^+a"), }, + "notifications": ffp_notifications.snapshot(cfg.get("notifications")), } diff --git a/scripts/ui/notifications.ahk b/scripts/ui/notifications.ahk index 1ddc645..52d7833 100644 --- a/scripts/ui/notifications.ahk +++ b/scripts/ui/notifications.ahk @@ -1,12 +1,25 @@ Notify_Impl(title, message) { global lastNotifications - key := title "|" message - now := A_TickCount - if lastNotifications.Has(key) { - if (now - lastNotifications[key] < 5000) + ; The daemon owns all notification policy (per-event on/off, dedupe window, + ; quiet hours, DND) and the telemetry log — see ffp_notifications.gate. Ask + ; it whether to show this toast. The verdict is a small JSON object + ; {"show": true/false, ...}; the daemon has already logged the decision. + verdict := NotifyGate_Impl(title, message) + if InStr(verdict, '"show"') { + ; A real verdict came back. Show only if the daemon said so; it already + ; applied dedupe/quiet-hours/DND server-side, so no local dedupe here. + if !InStr(verdict, '"show": true') return + } else { + ; Daemon unreachable (or an unexpected reply) — fail OPEN so toasts are + ; never silently lost, but keep a 5s local dedupe so a burst of + ; identical toasts can't spam while the daemon is down. + key := title "|" message + now := A_TickCount + if (lastNotifications.Has(key) && (now - lastNotifications[key] < 5000)) + return + lastNotifications[key] := now } - lastNotifications[key] := now try TrayTip() try TrayTip(message, title) @@ -15,6 +28,15 @@ Notify_Impl(title, message) { } } +; Ask the daemon for a show/suppress verdict (and let it log the decision + +; apply policy). Single-shot POST via _DaemonPostOnce — it never tries to spawn +; the daemon, so a toast can't block on a cold start. Returns the raw JSON +; verdict, or "" when the daemon is unreachable (caller then fails open). +NotifyGate_Impl(title, message) { + body := '{"args":{"title":"' EscapeJson(title) '","message":"' EscapeJson(message) '"}}' + return _DaemonPostOnce_Impl("notify_gate", body) +} + ShowWindowsToast_Impl(title, message) { if (ShowToastViaDaemon_Impl(title, message)) return diff --git a/scripts/ui/web/app.js b/scripts/ui/web/app.js index f105908..06bf2db 100644 --- a/scripts/ui/web/app.js +++ b/scripts/ui/web/app.js @@ -293,6 +293,38 @@ async function loadTelemetry() { } catch (e) { fillTable("stats-body", [[`Stats unavailable: ${e.message}`, ""]]); } + loadNotificationsLog(); +} + +// Category id -> human label. Mirrors ffp_notifications.CATEGORY_LABELS. +const NOTIF_CATEGORY_LABELS = { + errors: "Errors & warnings", + clipboard_suggestions: "Clipboard suggestions", + updates: "Update checks", + diagnostics: "Diagnostics", + settings: "Settings changes", + lifecycle: "App lifecycle", + action_result: "Action results", +}; +// Per-event toggle ids (must match the ntf-cat-* checkbox ids in index.html and +// ffp_notifications.CATEGORIES). +const NOTIF_CATEGORY_IDS = Object.keys(NOTIF_CATEGORY_LABELS); + +async function loadNotificationsLog() { + try { + const entries = await action("notifications_log", { limit: 50 }); + const rows = entries.map((e) => [ + String(e.ts || "-").slice(0, 19).replace("T", " "), + NOTIF_CATEGORY_LABELS[e.category] || e.category || "?", + e.shown ? "shown" : `muted (${e.reason || "?"})`, + e.message || "", + ]); + const n = fillTable("notif-log-body", rows); + $("notif-log-empty").hidden = n > 0; + } catch (e) { + fillTable("notif-log-body", [[`Notifications log unavailable: ${e.message}`, "", "", ""]]); + $("notif-log-empty").hidden = true; + } } function renderHours(buckets) { @@ -601,6 +633,7 @@ async function loadConfig() { $("cfg-min-chunk").value = routing.min_chunk_chars ?? 700; const tone = (cfg.tone || {}).preset || "formal"; document.querySelectorAll('input[name="tone"]').forEach((r) => (r.checked = r.value === tone)); + populateNotifications(cfg.notifications || {}); setStatus("config-status", ""); } catch (e) { setStatus("config-status", `Load failed: ${e.message}`, false); @@ -611,6 +644,46 @@ async function loadConfig() { if (($("cfg-provider").value || "fastflowlm") === "fastflowlm") loadFlmVersion(false); } +// Notifications settings <-> the Config tab inputs. The snapshot from +// build_config_snapshot() always carries every category (defaults merged), so +// older config files render fine. +function populateNotifications(ntf) { + $("ntf-enabled").checked = ntf.enabled !== false; + $("ntf-dnd").checked = !!ntf.dnd; + $("ntf-log").checked = ntf.log_enabled !== false; + $("ntf-dedupe").value = ntf.dedupe_seconds ?? 5; + const qh = ntf.quiet_hours || {}; + $("ntf-qh-enabled").checked = !!qh.enabled; + $("ntf-qh-start").value = qh.start || "22:00"; + $("ntf-qh-end").value = qh.end || "07:00"; + const cats = ntf.categories || {}; + for (const id of NOTIF_CATEGORY_IDS) { + const el = $(`ntf-cat-${id}`); + if (el) el.checked = (cats[id] || {}).enabled !== false; + } +} + +function notificationsPatch() { + const categories = {}; + for (const id of NOTIF_CATEGORY_IDS) { + const el = $(`ntf-cat-${id}`); + if (el) categories[id] = { enabled: el.checked }; + } + const dedupe = Number($("ntf-dedupe").value); + return { + enabled: $("ntf-enabled").checked, + dnd: $("ntf-dnd").checked, + log_enabled: $("ntf-log").checked, + dedupe_seconds: Number.isFinite(dedupe) && dedupe >= 0 ? dedupe : 5, + quiet_hours: { + enabled: $("ntf-qh-enabled").checked, + start: $("ntf-qh-start").value || "22:00", + end: $("ntf-qh-end").value || "07:00", + }, + categories, + }; +} + async function loadServerStatus() { try { const raw = await action("status"); @@ -769,6 +842,7 @@ async function saveConfig() { }, modes: { tone: { preset: tone ? tone.value : "formal" } }, hotkeys, + notifications: notificationsPatch(), }; try { await action("apply_config_patch", { patch }); diff --git a/scripts/ui/web/index.html b/scripts/ui/web/index.html index fd335b1..9a21a60 100644 --- a/scripts/ui/web/index.html +++ b/scripts/ui/web/index.html @@ -108,6 +108,14 @@
| When | Category | Status | Message |
|---|
No notifications recorded yet.
+Choose which desktop toasts appear, mute duplicates, and set quiet hours. Errors & warnings always come through while Do Not Disturb or quiet hours are on. Every notification — shown or muted — is recorded in the Telemetry tab's feed.
+ + + +