// Personal Assistant — sidebar entry, settings modal, and chat-header extras. // // The Assistant is just a specially-flagged CrewMember whose pinned Session // lives alongside normal chats. The sidebar button resolves the per-user // singleton via /api/assistant/session and hands it to selectSession() so we // reuse the full existing chat render path. import uiModule from './ui.js'; import { selectSession } from './sessions.js'; import { sortModelIds } from './modelSort.js'; const API = '/api/assistant'; let _cachedSettings = null; // most recent GET /api/assistant/settings payload let _modalEl = null; async function _fetchJSON(url, opts = {}) { const res = await fetch(url, { credentials: 'same-origin', ...opts }); if (!res.ok) throw new Error(`${url} → ${res.status}`); return res.json(); } export async function openAssistantChat() { try { const info = await _fetchJSON(`${API}/session`); if (!info?.session_id) { uiModule.showToast('Assistant session unavailable'); return; } await selectSession(info.session_id); // Refresh settings cache so the header buttons / gear act on fresh data. _cachedSettings = null; } catch (e) { console.error('openAssistantChat failed:', e); uiModule.showToast('Could not open assistant'); } } async function _getSettings(force = false) { if (!force && _cachedSettings) return _cachedSettings; _cachedSettings = await _fetchJSON(`${API}/settings`); return _cachedSettings; } async function _saveSettings(payload) { const res = await fetch(`${API}/settings`, { method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error(`PATCH ${API}/settings → ${res.status}`); _cachedSettings = await res.json(); return _cachedSettings; } async function _listTimezones() { try { const { timezones } = await _fetchJSON(`${API}/available-timezones`); return timezones || ['UTC']; } catch { return ['UTC']; } } async function _runCheckInNow(taskId) { try { await fetch(`${API}/run/${encodeURIComponent(taskId)}`, { method: 'POST', credentials: 'same-origin', }); uiModule.showToast('Check-in running…'); } catch (e) { console.error(e); uiModule.showToast('Could not run check-in'); } } // ── Settings modal ───────────────────────────────────────────────────────── function _closeModal() { if (_modalEl) { _modalEl.classList.add('hidden'); _modalEl.style.display = ''; } } function _ensureModalEl() { if (_modalEl) return _modalEl; const modal = document.createElement('div'); modal.id = 'assistant-settings-modal'; modal.className = 'modal hidden'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.querySelector('#assistant-settings-close').addEventListener('click', _closeModal); modal.addEventListener('click', (e) => { if (e.target === modal) _closeModal(); }); _modalEl = modal; return modal; } function _esc(s) { return (s || '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } // Tool groups for the tool selector UI const TOOL_GROUPS = { 'Email': ['list_emails', 'read_email', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'], 'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'], 'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'], 'Code': ['bash', 'python', 'write_file'], 'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'], 'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'], 'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'], }; async function _fetchEndpoints() { try { const eps = await _fetchJSON('/api/model-endpoints'); return Array.isArray(eps) ? eps : []; } catch { return []; } } function _renderSettingsBody(body, data, tzList) { const crew = data.crew || {}; const checkIns = data.check_ins || []; const enabledTools = new Set(crew.enabled_tools || []); const tzOptions = tzList.map((z) => `` ).join(''); const checkInsHTML = checkIns.map((c) => `
${c.next_run ? `next run: ${_esc(c.next_run)}` : ''} ${c.last_run ? ` · last run: ${_esc(c.last_run)}` : ''} ${typeof c.run_count === 'number' ? ` · ${c.run_count} runs` : ''}
`).join(''); // Tool selector grouped by category let toolsHTML = ''; for (const [group, tools] of Object.entries(TOOL_GROUPS)) { toolsHTML += `
${_esc(group)}`; for (const t of tools) { const checked = enabledTools.has(t) ? ' checked' : ''; const label = t.replace(/_/g, ' '); toolsHTML += ``; } toolsHTML += '
'; } body.innerHTML = `
Personality
Tools
${toolsHTML}
Daily check-ins
${checkInsHTML || '
No check-ins configured.
'}
`; // ── Populate model/endpoint dropdowns ── const epSelect = body.querySelector('#assistant-endpoint'); const modelSelect = body.querySelector('#assistant-model'); _fetchEndpoints().then(endpoints => { let epHTML = ''; for (const ep of endpoints) { if (!ep.is_enabled) continue; const url = ep.base_url || ''; const name = ep.name || url; const sel = (crew.endpoint_url && url.includes(crew.endpoint_url.replace('/v1', '').replace(/\/$/, ''))) ? ' selected' : ''; epHTML += ``; } epSelect.innerHTML = epHTML; // When endpoint changes, load its models epSelect.addEventListener('change', async () => { const url = epSelect.value; if (!url) { modelSelect.innerHTML = ''; return; } const ep = endpoints.find(e => e.base_url === url); if (!ep) return; modelSelect.innerHTML = ''; try { const models = await _fetchJSON(`/api/model-endpoints/${ep.id}/models`); let mHTML = ''; const modelIds = (models.models || models || []).map(m => typeof m === 'string' ? m : (m.id || m.name || '')).filter(Boolean); for (const mid of sortModelIds(modelIds)) { const sel = mid === crew.model ? ' selected' : ''; mHTML += ``; } modelSelect.innerHTML = mHTML || ''; } catch { modelSelect.innerHTML = ''; } }); // Trigger initial model load if endpoint is pre-selected if (epSelect.value) epSelect.dispatchEvent(new Event('change')); }); // ── Tool toggle buttons ── body.querySelector('#assistant-tools-all')?.addEventListener('click', () => { body.querySelectorAll('.assistant-tool-cb').forEach(cb => { cb.checked = true; }); }); body.querySelector('#assistant-tools-none')?.addEventListener('click', () => { body.querySelectorAll('.assistant-tool-cb').forEach(cb => { cb.checked = false; }); }); // ── Character picker — populate from presets + templates ── const charPick = body.querySelector('#assistant-character-pick'); const personalityEl = body.querySelector('#assistant-personality'); if (charPick && personalityEl) { (async () => { try { const [presetsRaw, templates] = await Promise.all([ _fetchJSON('/api/presets').catch(() => ({})), _fetchJSON('/api/presets/templates').catch(() => []), ]); // Presets API returns a dict keyed by preset ID, not an array const allPresets = []; if (presetsRaw && typeof presetsRaw === 'object' && !Array.isArray(presetsRaw)) { for (const [key, val] of Object.entries(presetsRaw)) { if (val && typeof val === 'object' && val.system_prompt) { allPresets.push({ ...val, _key: key }); } } } else if (Array.isArray(presetsRaw)) { allPresets.push(...presetsRaw); } const allTemplates = Array.isArray(templates) ? templates : []; let opts = ''; if (allPresets.length) { opts += ''; for (const p of allPresets) { if (!p.system_prompt) continue; const name = p.character_name || p.name || p._key || 'Unnamed'; opts += ``; } opts += ''; } if (allTemplates.length) { opts += ''; for (const t of allTemplates) { if (!t.system_prompt && !t.personality) continue; const name = t.character_name || t.name || 'Unnamed'; opts += ``; } opts += ''; } charPick.innerHTML = opts; charPick._presets = allPresets; charPick._templates = allTemplates; } catch {} })(); charPick.addEventListener('change', () => { const val = charPick.value; if (!val) return; const [type, id] = val.split(':', 2); let prompt = ''; let name = ''; if (type === 'preset') { const p = (charPick._presets || []).find(x => (x._key || x.name || x.id) === id); if (p) { prompt = p.system_prompt || p.personality || ''; name = p.character_name || p.name || p._key || ''; } } else if (type === 'template') { const t = (charPick._templates || []).find(x => (x.id || x.name) === id); if (t) { prompt = t.system_prompt || t.personality || ''; name = t.character_name || t.name || ''; } } if (prompt) personalityEl.value = prompt; const nameEl = body.querySelector('#assistant-name'); if (name && nameEl) nameEl.value = name; charPick.selectedIndex = 0; }); } // ── Event wiring ── body.querySelector('#assistant-settings-cancel').addEventListener('click', _closeModal); body.querySelector('#assistant-settings-save').addEventListener('click', async () => { const selectedTools = []; body.querySelectorAll('.assistant-tool-cb:checked').forEach(cb => selectedTools.push(cb.value)); const payload = { name: body.querySelector('#assistant-name').value.trim(), personality: body.querySelector('#assistant-personality').value, timezone: body.querySelector('#assistant-timezone').value || null, model: body.querySelector('#assistant-model').value || null, endpoint_url: body.querySelector('#assistant-endpoint').value || null, enabled_tools: selectedTools, check_ins: Array.from(body.querySelectorAll('.assistant-checkin-row')).map((row) => ({ id: row.dataset.taskId, name: row.querySelector('.assistant-checkin-name').value.trim(), scheduled_time: row.querySelector('.assistant-checkin-time').value, prompt: row.querySelector('.assistant-checkin-prompt').value, enabled: row.querySelector('.assistant-checkin-enabled').checked, })), }; try { await _saveSettings(payload); uiModule.showToast('Assistant settings saved'); _closeModal(); } catch (e) { console.error(e); uiModule.showToast('Save failed'); } }); body.querySelectorAll('.assistant-checkin-run').forEach((btn) => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const row = btn.closest('.assistant-checkin-row'); if (!row?.dataset.taskId) return; const taskId = row.dataset.taskId; btn.disabled = true; btn.textContent = 'Running...'; await _runCheckInNow(taskId); _closeModal(); // Poll until done, then navigate to assistant chat const sid = _cachedSettings?.crew?.session_id; const _poll = setInterval(async () => { try { const res = await fetch(`${API}/run-status/${encodeURIComponent(taskId)}`, { credentials: 'same-origin' }); if (!res.ok) return; const data = await res.json(); if (data.status === 'done' || data.status === 'error') { clearInterval(_poll); // Hard navigate to force full reload of the session if (sid) { window.location.href = window.location.pathname + '#' + sid; window.location.reload(); } } } catch {} }, 2000); setTimeout(() => clearInterval(_poll), 90000); }); }); } export async function openAssistantSettings() { const modal = _ensureModalEl(); modal.classList.remove('hidden'); modal.style.display = 'flex'; const body = modal.querySelector('#assistant-settings-body'); body.innerHTML = '
Loading…
'; try { const [data, tzList] = await Promise.all([_getSettings(true), _listTimezones()]); _renderSettingsBody(body, data, tzList); } catch (e) { console.error(e); body.innerHTML = '
Could not load assistant settings.
'; } } // Sidebar wiring removed — Assistant chat + settings now live as // Activity / Settings tabs inside the Tasks modal (see tasks.js). The // exports below are still used by tasks.js to surface those views. // ── Chat-header affordances when the assistant session is active ─────────── async function _ensureHeaderAffordances(sessionId) { try { const settings = await _getSettings(); if (settings?.crew?.session_id !== sessionId) return; } catch { return; } const headerRight = document.querySelector('.chat-header-right, #chat-header .actions, .chat-header'); if (!headerRight) return; if (headerRight.querySelector('#assistant-header-gear')) return; const gear = document.createElement('button'); gear.id = 'assistant-header-gear'; gear.type = 'button'; gear.title = 'Assistant settings'; gear.className = 'chat-header-btn'; gear.innerHTML = ''; gear.addEventListener('click', openAssistantSettings); headerRight.appendChild(gear); } // Run a short polling check after session loads so we can add the gear button // once the chat header DOM is in place. Fire-and-forget. function _watchForAssistantActivation() { let retries = 0; const interval = setInterval(async () => { retries += 1; const activeSessionId = window.sessionModule?.getActiveSession?.()?.id || document.body.dataset.activeSessionId || null; if (activeSessionId) { await _ensureHeaderAffordances(activeSessionId); } if (retries > 120) clearInterval(interval); // ~2 minutes }, 1000); } // ── Boot ─────────────────────────────────────────────────────────────────── function _boot() { _watchForAssistantActivation(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', _boot); } else { _boot(); } const assistantModule = { openAssistantChat, openAssistantSettings, }; export default assistantModule;