From 43b9324c147e1046e4df300acf208129523fce36 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 18 Feb 2026 12:04:37 -0800 Subject: [PATCH] feat(webchat): add personal assistant mode controls in settings --- docs/plans/state.json | 14 ++++ src/gateway/handlers/config.ts | 46 +++++++++++ src/gateway/handlers/handlers.test.ts | 39 ++++++++- src/gateway/ui/pages/settings.js | 114 ++++++++++++++++++++++++++ src/gateway/ui/style.css | 46 +++++++++++ 5 files changed, 257 insertions(+), 2 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index e5922bd..4a3bea2 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5453,6 +5453,20 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/frontends/tui/minimal.test.ts src/frontends/tui/commands.test.ts + pnpm typecheck passing" + }, + "webchat-settings-personal-assistant-mode-controls": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Productized the dashboard/settings surface with a new Personal Assistant Mode control block and runtime-safe patching for assistant-feel toggles (announce delivery, daily briefing, memory daily/proactive extraction cadence, and TTS channel gating). Extended config.patch allowlist and handler tests accordingly.", + "files_modified": [ + "src/gateway/ui/pages/settings.js", + "src/gateway/ui/style.css", + "src/gateway/handlers/config.ts", + "src/gateway/handlers/handlers.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/gateway/handlers/config.ts b/src/gateway/handlers/config.ts index eb23756..181627f 100644 --- a/src/gateway/handlers/config.ts +++ b/src/gateway/handlers/config.ts @@ -163,6 +163,52 @@ const PATCHABLE_KEYS: Record boolean config.server.nodes.push.enabled = value; return true; }, + 'automation.delivery_mode': (config, value) => { + if (value !== 'shared_session' && value !== 'isolated_job' && value !== 'announce') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.delivery_mode = value; + return true; + }, + 'automation.daily_briefing.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.daily_briefing ??= {} as Config['automation']['daily_briefing']; + config.automation.daily_briefing.enabled = value; + return true; + }, + 'memory.daily_log.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.memory ??= {} as Config['memory']; + config.memory.daily_log ??= {} as Config['memory']['daily_log']; + config.memory.daily_log.enabled = value; + return true; + }, + 'memory.proactive_extract.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.memory ??= {} as Config['memory']; + config.memory.proactive_extract ??= {} as Config['memory']['proactive_extract']; + config.memory.proactive_extract.enabled = value; + return true; + }, + 'memory.proactive_extract.min_tool_calls': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 50) {return false;} + config.memory ??= {} as Config['memory']; + config.memory.proactive_extract ??= {} as Config['memory']['proactive_extract']; + config.memory.proactive_extract.min_tool_calls = Math.floor(value); + return true; + }, + 'tts.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.tts ??= {} as Config['tts']; + config.tts.enabled = value; + return true; + }, + 'tts.enabled_channels': (config, value) => { + if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && v.trim().length > 0)) {return false;} + config.tts ??= {} as Config['tts']; + config.tts.enabled_channels = value as string[]; + return true; + }, }; export function createConfigHandlers(deps: ConfigHandlerDeps) { diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index cd4adbd..e93e252 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -1143,13 +1143,34 @@ describe('config handlers', () => { 'server.queue.debounce_ms': 100, 'server.nodes.location.enabled': true, 'server.nodes.push.enabled': true, + 'automation.delivery_mode': 'announce', + 'automation.daily_briefing.enabled': true, + 'memory.daily_log.enabled': true, + 'memory.proactive_extract.enabled': true, + 'memory.proactive_extract.min_tool_calls': 2, + 'tts.enabled': true, + 'tts.enabled_channels': ['telegram', 'discord'], }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; - expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms', 'server.nodes.location.enabled', 'server.nodes.push.enabled']); + expect(r.applied).toEqual([ + 'hooks.confirm', + 'hooks.log', + 'server.queue.mode', + 'server.queue.debounce_ms', + 'server.nodes.location.enabled', + 'server.nodes.push.enabled', + 'automation.delivery_mode', + 'automation.daily_briefing.enabled', + 'memory.daily_log.enabled', + 'memory.proactive_extract.enabled', + 'memory.proactive_extract.min_tool_calls', + 'tts.enabled', + 'tts.enabled_channels', + ]); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(false); // Verify the config was actually mutated @@ -1159,6 +1180,13 @@ describe('config handlers', () => { expect(config.server.queue.debounce_ms).toBe(100); expect(config.server.nodes.location.enabled).toBe(true); expect(config.server.nodes.push.enabled).toBe(true); + expect(getPath(config, 'automation', 'delivery_mode')).toBe('announce'); + expect(getPath(config, 'automation', 'daily_briefing', 'enabled')).toBe(true); + expect(getPath(config, 'memory', 'daily_log', 'enabled')).toBe(true); + expect(getPath(config, 'memory', 'proactive_extract', 'enabled')).toBe(true); + expect(getPath(config, 'memory', 'proactive_extract', 'min_tool_calls')).toBe(2); + expect(getPath(config, 'tts', 'enabled')).toBe(true); + expect(getPath(config, 'tts', 'enabled_channels')).toEqual(['telegram', 'discord']); }); it('config.patch rejects unknown keys', async () => { @@ -1192,6 +1220,8 @@ describe('config handlers', () => { patches: { 'hooks.confirm': 'not-an-array', 'server.queue.cap': 0, + 'memory.proactive_extract.min_tool_calls': 99, + 'tts.enabled_channels': [1, 2, 3], }, }, }; @@ -1199,7 +1229,12 @@ describe('config handlers', () => { const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([]); - expect(r.rejected).toEqual(['hooks.confirm', 'server.queue.cap']); + expect(r.rejected).toEqual([ + 'hooks.confirm', + 'server.queue.cap', + 'memory.proactive_extract.min_tool_calls', + 'tts.enabled_channels', + ]); expect(r.persisted).toBe(false); }); diff --git a/src/gateway/ui/pages/settings.js b/src/gateway/ui/pages/settings.js index 8bbd9d4..22fd324 100644 --- a/src/gateway/ui/pages/settings.js +++ b/src/gateway/ui/pages/settings.js @@ -19,6 +19,7 @@ function escapeHtml(text) { let _client = null; let _el = null; +let _settingsCache = null; function describePushStatus(status) { if (!status.supported) { @@ -114,6 +115,18 @@ async function loadSettings() { const confirmPatterns = hooks.confirm ?? []; const logPatterns = hooks.log ?? []; const silentPatterns = hooks.silent ?? []; + const automation = config?.automation ?? {}; + const memory = config?.memory ?? {}; + const tts = config?.tts ?? {}; + _settingsCache = config ?? {}; + + const deliveryMode = automation.delivery_mode ?? 'shared_session'; + const dailyBriefingEnabled = Boolean(automation.daily_briefing?.enabled); + const dailyMemoryEnabled = Boolean(memory.daily_log?.enabled); + const proactiveExtractEnabled = Boolean(memory.proactive_extract?.enabled); + const proactiveMinToolCalls = Number(memory.proactive_extract?.min_tool_calls ?? 1); + const ttsEnabled = Boolean(tts.enabled); + const ttsChannelText = Array.isArray(tts.enabled_channels) ? tts.enabled_channels.join(', ') : ''; // Build config view (redacted JSON) const configJson = JSON.stringify(config, null, 2); @@ -127,6 +140,44 @@ async function loadSettings() { _el.innerHTML = `

Settings

+

Personal Assistant Mode

+
+
+ + + + + + + +
+
+ + +
+
+

WebChat Push Notifications

${isPushSupported() ? '' : '
This browser does not support PushManager APIs.
'} @@ -218,11 +269,73 @@ async function loadSettings() { // Bind save hooks _el.querySelector('#hooks-save').addEventListener('click', saveHooks); + _el.querySelector('#assistant-mode-save').addEventListener('click', saveAssistantMode); _el.querySelector('#push-enable').addEventListener('click', onEnablePush); _el.querySelector('#push-disable').addEventListener('click', onDisablePush); await renderPushStatus(); } +async function saveAssistantMode() { + const status = _el.querySelector('#assistant-mode-status'); + status.textContent = 'Saving...'; + status.className = 'text-sm text-muted'; + + const useAnnounce = Boolean(_el.querySelector('#assist-delivery-announce')?.checked); + const dailyBriefing = Boolean(_el.querySelector('#assist-daily-briefing')?.checked); + const memoryDaily = Boolean(_el.querySelector('#assist-memory-daily')?.checked); + const memoryProactive = Boolean(_el.querySelector('#assist-memory-proactive')?.checked); + const ttsEnabled = Boolean(_el.querySelector('#assist-tts-enabled')?.checked); + const minToolsRaw = Number.parseInt(_el.querySelector('#assist-memory-min-tools')?.value ?? '1', 10); + const minTools = Number.isFinite(minToolsRaw) ? Math.min(50, Math.max(0, minToolsRaw)) : 1; + const ttsChannelsRaw = _el.querySelector('#assist-tts-channels')?.value ?? ''; + const ttsChannels = ttsChannelsRaw + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + + const patches = { + 'automation.delivery_mode': useAnnounce ? 'announce' : 'shared_session', + 'automation.daily_briefing.enabled': dailyBriefing, + 'memory.daily_log.enabled': memoryDaily, + 'memory.proactive_extract.enabled': memoryProactive, + 'memory.proactive_extract.min_tool_calls': minTools, + 'tts.enabled': ttsEnabled, + 'tts.enabled_channels': ttsChannels, + }; + + try { + const result = await _client.call('config.patch', { patches }); + const applied = result.applied ?? []; + const rejected = result.rejected ?? []; + const persisted = result.persisted === true; + const persistError = result.persistError; + + if (rejected.length > 0) { + status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`; + status.className = 'text-sm text-error'; + } else if (persistError) { + status.textContent = `Save failed: ${persistError}`; + status.className = 'text-sm text-error'; + } else if (!persisted) { + status.textContent = `Saved in runtime only (${applied.length} updated)`; + status.className = 'text-sm text-muted'; + } else { + status.textContent = `Saved (${applied.length} updated)`; + status.className = 'text-sm text-success'; + if (_settingsCache && _settingsCache.automation) { + _settingsCache.automation.delivery_mode = useAnnounce ? 'announce' : 'shared_session'; + } + } + } catch (err) { + status.textContent = `Error: ${err.message}`; + status.className = 'text-sm text-error'; + } + + setTimeout(() => { + if (status) {status.textContent = '';} + }, 5000); +} + async function saveHooks() { const status = _el.querySelector('#hooks-status'); status.textContent = 'Saving...'; @@ -280,5 +393,6 @@ export const SettingsPage = { teardown() { _client = null; _el = null; + _settingsCache = null; }, }; diff --git a/src/gateway/ui/style.css b/src/gateway/ui/style.css index 904717c..636948c 100644 --- a/src/gateway/ui/style.css +++ b/src/gateway/ui/style.css @@ -1366,6 +1366,52 @@ tr:hover td { border-color: var(--accent); } +.assistant-mode-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.assistant-toggle, +.assistant-field { + display: flex; + align-items: center; + gap: 8px; + background-color: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.assistant-field { + flex-direction: column; + align-items: flex-start; +} + +.assistant-field input[type="number"], +.assistant-field input[type="text"] { + width: 100%; + padding: 8px; + background-color: var(--bg-input); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; +} + +.assistant-field input:focus { + border-color: var(--accent); +} + +.assistant-actions { + display: flex; + align-items: center; + gap: 12px; +} + .btn { padding: 8px 16px; font-family: var(--font-mono);