diff --git a/README.md b/README.md index 16ccf08..88fb836 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ Flynn includes a built-in web control dashboard served by the WebSocket gateway. | **Chat** | Session selector, streaming tool events, markdown rendering with syntax highlighting | | **Sessions** | Browse all sessions, view message history, delete sessions | | **Usage** | Token usage summary cards, per-session breakdown table, auto-refresh | -| **Settings** | Edit hook patterns (confirm/log/silent), view tools, channels, and redacted config | +| **Settings** | Edit hook patterns (confirm/log/silent), Personal Assistant Mode toggles, view tools/services, and redacted config | The dashboard is a vanilla JS SPA with no build step — hash-based routing, ES modules, and the existing WebSocket JSON-RPC protocol. diff --git a/docs/plans/state.json b/docs/plans/state.json index 4a3bea2..33b8151 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5467,6 +5467,19 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" + }, + "dashboard-assistant-health-quick-actions": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Extended Live Ops Dashboard with an Assistant Health section that surfaces product-feel runtime state (announce mode, daily briefing, memory cadence, TTS) and adds one-click quick actions that apply safe runtime config.patch updates and refresh status immediately.", + "files_modified": [ + "src/gateway/ui/pages/dashboard.js", + "src/gateway/ui/style.css", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 9991e25..1eda2e8 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -7,6 +7,7 @@ let _fastTimer = null; let _slowTimer = null; +let _dashboardClient = null; function formatUptime(seconds) { const d = Math.floor(seconds / 86400); @@ -80,6 +81,11 @@ function renderSkeleton(el) {
Loading...
+

Assistant Health

+
+
Loading...
+
+

Event Stream

Loading events...
@@ -411,6 +417,128 @@ function updateContextHealth(contextData) { `; } +async function applyAssistantPatch(patches, statusEl) { + if (!_dashboardClient) {return;} + if (statusEl) { + statusEl.textContent = 'Saving...'; + statusEl.className = 'text-sm text-muted'; + } + try { + const result = await _dashboardClient.call('config.patch', { patches }); + const rejected = result?.rejected ?? []; + const persistError = result?.persistError; + const applied = result?.applied ?? []; + const persisted = result?.persisted === true; + + if (statusEl) { + if (persistError) { + statusEl.textContent = `Save failed: ${persistError}`; + statusEl.className = 'text-sm text-error'; + } else if (rejected.length > 0) { + statusEl.textContent = `Rejected: ${rejected.join(', ')}`; + statusEl.className = 'text-sm text-error'; + } else if (!persisted) { + statusEl.textContent = `Runtime saved (${applied.length} updated)`; + statusEl.className = 'text-sm text-muted'; + } else { + statusEl.textContent = `Saved (${applied.length} updated)`; + statusEl.className = 'text-sm text-success'; + } + } + } catch (error) { + if (statusEl) { + statusEl.textContent = `Save error: ${error instanceof Error ? error.message : String(error)}`; + statusEl.className = 'text-sm text-error'; + } + } +} + +function updateAssistantHealth(configData) { + const el = document.getElementById('ops-assistant-health'); + if (!el) {return;} + + const automation = configData?.automation ?? {}; + const memory = configData?.memory ?? {}; + const tts = configData?.tts ?? {}; + + const deliveryMode = automation.delivery_mode ?? 'shared_session'; + const announce = deliveryMode === 'announce'; + const dailyBriefing = Boolean(automation.daily_briefing?.enabled); + const memoryDaily = Boolean(memory.daily_log?.enabled); + const memoryProactive = Boolean(memory.proactive_extract?.enabled); + const proactiveThreshold = Number(memory.proactive_extract?.min_tool_calls ?? 1); + const ttsEnabled = Boolean(tts.enabled); + + const chip = (label, value) => ` +
+ ${escapeHtml(label)} + ${value ? 'ON' : 'OFF'} +
+ `; + + el.innerHTML = ` +
+ ${chip('Announce Mode', announce)} + ${chip('Daily Briefing', dailyBriefing)} + ${chip('Memory Daily Log', memoryDaily)} + ${chip('Proactive Extract', memoryProactive)} + ${chip('TTS Replies', ttsEnabled)} +
+ Extract Threshold + ${Number.isFinite(proactiveThreshold) ? proactiveThreshold : 1} +
+
+
+ + + + + +
+
+ `; + + const statusEl = el.querySelector('#ops-assistant-status'); + const buttons = el.querySelectorAll('.assistant-action-btn'); + buttons.forEach((button) => { + button.addEventListener('click', async () => { + const action = button.getAttribute('data-action'); + let patches = null; + if (action === 'toggle-announce') { + patches = { 'automation.delivery_mode': announce ? 'shared_session' : 'announce' }; + } else if (action === 'toggle-daily-briefing') { + patches = { 'automation.daily_briefing.enabled': !dailyBriefing }; + } else if (action === 'toggle-memory-daily') { + patches = { 'memory.daily_log.enabled': !memoryDaily }; + } else if (action === 'toggle-memory-proactive') { + patches = { 'memory.proactive_extract.enabled': !memoryProactive }; + } else if (action === 'toggle-tts') { + patches = { 'tts.enabled': !ttsEnabled }; + } + if (!patches) {return;} + await applyAssistantPatch(patches, statusEl); + // Force immediate refresh of slow sections after applying. + const refreshed = await fetchSlow(_dashboardClient); + if (refreshed) { + updateServices(refreshed.services); + updateSessionAnalytics(refreshed.sessionAnalytics); + updateContextHealth(refreshed.contextUsage); + updateAssistantHealth(refreshed.config); + } + }); + }); +} + function _updateChannels(channelsData) { const el = document.getElementById('ops-channels'); if (!el) {return;} @@ -479,13 +607,14 @@ async function fetchFast(client) { async function fetchSlow(client) { try { - const [health, services, sessionAnalytics, contextUsage] = await Promise.all([ + const [health, services, sessionAnalytics, contextUsage, config] = await Promise.all([ client.call('system.health'), client.call('system.services'), client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }), client.call('system.contextUsage'), + client.call('config.get'), ]); - return { health, services, sessionAnalytics, contextUsage }; + return { health, services, sessionAnalytics, contextUsage, config }; } catch { return null; } @@ -497,6 +626,7 @@ let _lastHealth = null; let _lastMetrics = null; async function loadDashboard(el, client) { + _dashboardClient = client; renderSkeleton(el); // Fetch everything initially @@ -518,6 +648,7 @@ async function loadDashboard(el, client) { updateServices(slow.services); updateSessionAnalytics(slow.sessionAnalytics); updateContextHealth(slow.contextUsage); + updateAssistantHealth(slow.config); } // Fast refresh: 3 seconds for metrics, events, requests @@ -541,6 +672,7 @@ async function loadDashboard(el, client) { updateServices(data.services); updateSessionAnalytics(data.sessionAnalytics); updateContextHealth(data.contextUsage); + updateAssistantHealth(data.config); } }, 10000); } @@ -561,5 +693,6 @@ export const DashboardPage = { } _lastHealth = null; _lastMetrics = null; + _dashboardClient = null; }, }; diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index 636948c..4d9fa97 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -1544,6 +1544,33 @@ tr:hover td { color: var(--text-primary); } +.assistant-health-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.assistant-chip { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background-color: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: var(--font-size-sm); +} + +.assistant-chip-label { + color: var(--text-secondary); +} + +.assistant-chip-value { + font-weight: 700; + color: var(--text-primary); +} + /* ── Responsive: Mobile ─────────────────────────────────────── */ @media (max-width: 768px) {