feat(webchat): add personal assistant mode controls in settings

This commit is contained in:
William Valentin
2026-02-18 12:04:37 -08:00
parent a8bb9f23ac
commit 43b9324c14
5 changed files with 257 additions and 2 deletions
+14
View File
@@ -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": {
+46
View File
@@ -163,6 +163,52 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => 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) {
+37 -2
View File
@@ -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);
});
+114
View File
@@ -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 = `
<h1 class="page-title">Settings</h1>
<h2 class="section-title">Personal Assistant Mode</h2>
<div class="settings-section">
<div class="assistant-mode-grid">
<label class="assistant-toggle">
<input id="assist-delivery-announce" type="checkbox" ${deliveryMode === 'announce' ? 'checked' : ''} />
<span>Automation announce delivery mode</span>
</label>
<label class="assistant-toggle">
<input id="assist-daily-briefing" type="checkbox" ${dailyBriefingEnabled ? 'checked' : ''} />
<span>Daily briefing enabled</span>
</label>
<label class="assistant-toggle">
<input id="assist-memory-daily" type="checkbox" ${dailyMemoryEnabled ? 'checked' : ''} />
<span>Daily memory logging</span>
</label>
<label class="assistant-toggle">
<input id="assist-memory-proactive" type="checkbox" ${proactiveExtractEnabled ? 'checked' : ''} />
<span>Proactive memory extraction</span>
</label>
<label class="assistant-field">
<span>Proactive extract tool-call threshold</span>
<input id="assist-memory-min-tools" type="number" min="0" max="50" value="${Number.isFinite(proactiveMinToolCalls) ? proactiveMinToolCalls : 1}" />
</label>
<label class="assistant-toggle">
<input id="assist-tts-enabled" type="checkbox" ${ttsEnabled ? 'checked' : ''} />
<span>TTS voice replies enabled</span>
</label>
<label class="assistant-field">
<span>TTS channels (comma-separated, blank = all)</span>
<input id="assist-tts-channels" type="text" value="${escapeHtml(ttsChannelText)}" placeholder="telegram,discord,whatsapp" />
</label>
</div>
<div class="assistant-actions">
<button id="assistant-mode-save" class="btn btn-primary">Save Assistant Mode</button>
<span id="assistant-mode-status" class="text-sm text-muted"></span>
</div>
</div>
<h2 class="section-title">WebChat Push Notifications</h2>
<div class="settings-section">
${isPushSupported() ? '' : '<div class="text-sm text-muted">This browser does not support PushManager APIs.</div>'}
@@ -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;
},
};
+46
View File
@@ -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);