feat(webchat): add personal assistant mode controls in settings
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user