1// static/js/settings.js â Settings panel module (ES6) 2// User-facing preferences: AI models, search, appearance 3 4import uiModule from './ui.js'; 5import searchModule from './search.js'; 6import { makeWindowDraggable } from './windowDrag.js'; 7import { clearDockSide } from './modalSnap.js'; 8import { sortModelIds } from './modelSort.js'; 9import { isAltGrEvent } from './platform.js'; 10 11let initialized = false; 12let modalEl = null; 13 14function el(id) { return document.getElementById(id); } 15function esc(s) { return uiModule.esc(s); } 16function safeRasterDataUrl(raw) { 17 const value = String(raw || '').trim(); 18 return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : ''; 19} 20 21/* ââ Tab switching ââ */ 22const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']); 23 24function initTabs() { 25 modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => { 26 btn.addEventListener('click', () => { 27 const tab = btn.dataset.settingsTab; 28 // Lazy-init admin when first clicking an admin tab 29 if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') { 30 window.adminModule.open(tab); 31 return; 32 } 33 modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); 34 modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); 35 // Mark when the Appearance tab is open so the modal can go 36 // semi-transparent â lets the user see the rest of the UI react as 37 // they flip toggles instead of having to close + reopen the modal. 38 document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); 39 syncAppearanceOpacity(tab === 'appearance'); 40 if (tab === 'ai') refreshAiModelEndpoints(); 41 }); 42 }); 43} 44 45/* ââ Dragging ââ */ 46function initDrag() { 47 const header = modalEl.querySelector('.modal-header'); 48 const content = modalEl.querySelector('.settings-modal-content'); 49 if (!header || !content) return; 50 // Skip interactive controls in the header (e.g. the opacity slider) so 51 // grabbing them doesn't start a window-drag. 52 makeWindowDraggable(modalEl, { 53 content, 54 header, 55 skipSelector: 'button, input, select, .theme-opacity-wrap', 56 enableDock: true, 57 }); 58} 59 60function resetWindowPlacement() { 61 const content = modalEl && modalEl.querySelector('.settings-modal-content'); 62 if (!content) return; 63 const hadLeft = modalEl.classList.contains('modal-left-docked'); 64 const hadRight = modalEl.classList.contains('modal-right-docked'); 65 modalEl.classList.remove('modal-left-docked', 'modal-right-docked'); 66 if (hadLeft) clearDockSide('left', modalEl); 67 if (hadRight) clearDockSide('right', modalEl); 68 if (content._leftDockNavObs) { 69 try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {} 70 try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} 71 delete content._leftDockNavObs; 72 } 73 delete content._preDockSnapshot; 74 delete content._dockSide; 75 delete content._dockSuspended; 76 delete content.dataset._tilePreSnap; 77 delete content.dataset._tileZone; 78 [ 79 'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform', 80 'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition', 81 ].forEach(prop => content.style.removeProperty(prop)); 82} 83 84/* ââ Close on backdrop / X ââ */ 85function initClose() { 86 modalEl.querySelector('.close-btn').addEventListener('click', close); 87 modalEl.addEventListener('mousedown', e => { 88 if (uiModule.isTouchInsideModal()) return; 89 if (e.target === modalEl) close(); 90 }); 91 document.addEventListener('keydown', e => { 92 if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return; 93 // If an integration edit/add form is open inside the modal, close 94 // just that â don't dismiss the whole settings modal. (Pressing 95 // ESC mid-edit and losing the modal was a fast-typing footgun.) 96 const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form'); 97 if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) { 98 e.preventDefault(); 99 e.stopPropagation(); 100 innerForm.style.display = 'none'; 101 innerForm.innerHTML = ''; 102 return; 103 } 104 e.preventDefault(); 105 e.stopPropagation(); 106 close(); 107 }); 108} 109 110/* ââ Appearance-tab opacity slider ââ 111 Mirrors the Theme customizer's slider: fades the settings modal's 112 background (and inner cards) via color-mix so the user can watch the 113 rest of the UI react to toggles, while keeping text/controls crisp 114 (no element opacity). Only shown/active on the Appearance tab. */ 115const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on 116function _applySettingsOpacity(on) { 117 const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content'); 118 if (!content) return; 119 const cards = content.querySelectorAll('.admin-card'); 120 if (on) { 121 const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`; 122 const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`; 123 content.style.setProperty('background', bgMix, 'important'); 124 content.style.setProperty('backdrop-filter', 'none', 'important'); 125 content.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); 126 cards.forEach(c => { 127 c.style.setProperty('background', panelMix, 'important'); 128 c.style.setProperty('backdrop-filter', 'none', 'important'); 129 c.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); 130 }); 131 } else { 132 content.style.removeProperty('background'); 133 content.style.removeProperty('backdrop-filter'); 134 content.style.removeProperty('-webkit-backdrop-filter'); 135 cards.forEach(c => { 136 c.style.removeProperty('background'); 137 c.style.removeProperty('backdrop-filter'); 138 c.style.removeProperty('-webkit-backdrop-filter'); 139 }); 140 } 141} 142 143// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade. 144function syncAppearanceOpacity(active) { 145 const toggle = el('settings-opacity-wrap'); 146 if (toggle) toggle.classList.toggle('hidden', !active); 147 if (active) { 148 _applySettingsOpacity(toggle ? toggle.classList.contains('active') : false); 149 } else { 150 _applySettingsOpacity(false); // clear the fade off the Appearance tab 151 } 152} 153 154function initOpacityToggle() { 155 const toggle = el('settings-opacity-wrap'); 156 if (!toggle || toggle.dataset.bound === '1') return; 157 toggle.dataset.bound = '1'; 158 toggle.addEventListener('click', () => { 159 const on = !toggle.classList.contains('active'); 160 toggle.classList.toggle('active', on); 161 toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); 162 _applySettingsOpacity(on); 163 }); 164} 165 166/* âââââââââââââââââââââââââââââââââââââââââââ 167 AI TAB 168 âââââââââââââââââââââââââââââââââââââââââââ */ 169 170const _aiEndpointRefreshers = new Set();
171let _aiEndpointRefreshInFlight = null; 172 173async function _fetchModelEndpoints() { 174 const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); 175 const endpoints = await epRes.json(); 176 return Array.isArray(endpoints) ? endpoints : []; 177} 178 179function _endpointLabel(ep) { 180 return ep.name + (ep.online ? '' : ' (offline)'); 181} 182 183function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) { 184 if (!selectEl) return; 185 const previous = selected !== undefined ? selected : selectEl.value; 186 const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === '' 187 ? selectEl.options[0].textContent 188 : null; 189 while (selectEl.options.length) selectEl.remove(0); 190 if (blankText !== null) { 191 const blank = document.createElement('option'); 192 blank.value = ''; 193 blank.textContent = blankText; 194 selectEl.appendChild(blank); 195 } 196 (endpoints || []).forEach(function(ep) { 197 if (!ep.is_enabled) return; 198 const opt = document.createElement('option'); 199 opt.value = ep.id; 200 opt.textContent = _endpointLabel(ep); 201 selectEl.appendChild(opt); 202 }); 203 if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) { 204 selectEl.value = previous; 205 } else if (blankText !== null) { 206 selectEl.value = ''; 207 } 208} 209 210function _fillModelSelect(selectEl, models, selected, keepBlank) { 211 if (!selectEl) return; 212 const previous = selected !== undefined ? selected : selectEl.value; 213 const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === '' 214 ? selectEl.options[0].textContent 215 : null; 216 while (selectEl.options.length) selectEl.remove(0); 217 if (blankText !== null) { 218 const blank = document.createElement('option'); 219 blank.value = ''; 220 blank.textContent = blankText; 221 selectEl.appendChild(blank); 222 } 223 sortModelIds(models).forEach(function(m) { 224 const opt = document.createElement('option'); 225 opt.value = m; 226 opt.textContent = String(m).split('/').pop(); 227 selectEl.appendChild(opt); 228 }); 229 if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) { 230 selectEl.value = previous; 231 } else if (blankText !== null) { 232 selectEl.value = ''; 233 } 234} 235 236function _registerAiEndpointRefresh(fn) { 237 _aiEndpointRefreshers.add(fn); 238} 239 240export async function refreshAiModelEndpoints() { 241 if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight; 242 _aiEndpointRefreshInFlight = (async function() { 243 try { 244 const endpoints = await _fetchModelEndpoints(); 245 _aiEndpointRefreshers.forEach(function(fn) { 246 try { fn(endpoints); } catch (e) { console.warn('[settings] endpoint refresh handler failed', e); } 247 }); 248 } catch (e) { 249 console.warn('[settings] failed to refresh model endpoints', e); 250 } finally { 251 _aiEndpointRefreshInFlight = null; 252 } 253 })(); 254 return _aiEndpointRefreshInFlight; 255} 256 257/* Shared fallback-chain widget â mirrors the Default Chat Model fallback UI 258 * for other model cards (Utility, Vision, â¦). Pass in the container/button 259 * IDs, the endpoints list, the settings key to persist under, and the 260 * model-filter (for Vision we exclude non-chat-capable models). 261 */ 262function _bindFallbackWidget(opts) { 263 var fbContainer = el(opts.containerId); 264 var addBtn = el(opts.addBtnId); 265 var endpointsRef = opts.endpoints; // mutable list reference 266 var modelsFilter = opts.modelsFilter || function() { return true; }; 267 var settingKey = opts.settingKey; 268 var current = opts.initial || []; // [{endpoint_id, model}] 269 270 if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} }; 271 272 function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); } 273 274 function fillModels(selectEl, epId, selected) { 275 while (selectEl.options.length) selectEl.remove(0); 276 var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; }); 277 if (ep && ep.models) { 278 sortModelIds(ep.models).forEach(function(m) { 279 if (!modelsFilter(m, ep)) return; 280 var o = document.createElement('option'); 281 o.value = m; 282 o.textContent = m.split('/').pop(); 283 selectEl.appendChild(o); 284 }); 285 } 286 if (selected) selectEl.value = selected; 287 } 288 289 async function save() { 290 var clean = current.filter(function(f) { return f.endpoint_id && f.model; }); 291 var body = {}; 292 body[settingKey] = clean; 293 try { 294 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 295 headers: { 'Content-Type': 'application/json' }, 296 body: JSON.stringify(body) 297 }); 298 } catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); } 299 } 300 301 function render() { 302 fbContainer.innerHTML = ''; 303 current.forEach(function(fb, idx) { 304 var row = document.createElement('div'); 305 row.className = 'settings-fallback-row'; 306 307 var num = document.createElement('span'); 308 num.className = 'settings-fallback-num'; 309 num.textContent = (idx + 1) + '.'; 310 311 var epS = document.createElement('select'); 312 epS.className = 'settings-select';
313 enabledEps().forEach(function(ep) { 314 var o = document.createElement('option'); 315 o.value = ep.id; 316 o.textContent = ep.name + (ep.online ? '' : ' (offline)'); 317 epS.appendChild(o); 318 }); 319 var first = enabledEps()[0]; 320 epS.value = fb.endpoint_id || (first ? first.id : ''); 321 322 var mS = document.createElement('select'); 323 mS.className = 'settings-select'; 324 fillModels(mS, epS.value, fb.model); 325 326 fb.endpoint_id = epS.value; 327 fb.model = mS.value; 328 329 epS.addEventListener('change', function() { 330 fb.endpoint_id = epS.value; 331 fillModels(mS, epS.value, ''); 332 fb.model = mS.value; 333 save(); 334 }); 335 mS.addEventListener('change', function() { fb.model = mS.value; save(); }); 336 337 var rm = document.createElement('button'); 338 rm.type = 'button'; 339 rm.className = 'settings-fallback-remove'; 340 rm.title = 'Remove fallback'; 341 rm.innerHTML = '×'; 342 rm.addEventListener('click', function() { 343 current.splice(idx, 1); 344 render(); 345 save(); 346 }); 347 348 row.appendChild(num); 349 row.appendChild(epS); 350 row.appendChild(mS); 351 row.appendChild(rm); 352 fbContainer.appendChild(row); 353 }); 354 } 355 356 addBtn.addEventListener('click', function() { 357 var first = enabledEps()[0]; 358 current.push({ endpoint_id: first ? first.id : '', model: '' }); 359 render(); 360 save(); 361 }); 362 363 render(); 364 365 return { 366 setInitial: function(list) { current = (list || []).slice(); render(); }, 367 refresh: render, 368 }; 369} 370 371/* ââ Default Chat Model ââ */ 372async function initDefaultChat() { 373 var epSel = el('set-defaultEpSelect'); 374 var modelSel = el('set-defaultModelSelect'); 375 var msg = el('set-defaultChatMsg'); 376 var fbContainer = el('set-defaultFallbacks'); 377 var addFbBtn = el('set-defaultAddFallback'); 378 var _endpoints = []; 379 var _fallbacks = []; // [{endpoint_id, model}] â tried in order if primary fails 380 381 function enabledEndpoints() { 382 return _endpoints.filter(function(e) { return e.is_enabled; }); 383 } 384 385 // Fill any <select> with the models for a given endpoint id. 386 function fillModels(selectEl, epId, selected) { 387 var ep = _endpoints.find(function(e) { return e.id === epId; }); 388 _fillModelSelect(selectEl, ep ? ep.models : [], selected, false); 389 } 390 391 try { 392 _endpoints = await _fetchModelEndpoints(); 393 _fillEndpointSelect(epSel, _endpoints, epSel.value, false); 394 } catch (e) { console.warn('Failed to load endpoints for default chat', e); } 395 396 function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); } 397 function refreshEndpointOptions(selectedEndpoint, selectedModel) { 398 _fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false); 399 refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value); 400 renderFallbacks(); 401 } 402 403 // Render the fallback chain. Each row is endpoint + model + remove. 404 function renderFallbacks() { 405 fbContainer.innerHTML = ''; 406 _fallbacks.forEach(function(fb, idx) { 407 var row = document.createElement('div'); 408 row.className = 'settings-fallback-row'; 409 410 var num = document.createElement('span'); 411 num.className = 'settings-fallback-num'; 412 num.textContent = (idx + 1) + '.'; 413 414 var epS = document.createElement('select'); 415 epS.className = 'settings-select';
416 enabledEndpoints().forEach(function(ep) { 417 var o = document.createElement('option'); 418 o.value = ep.id; 419 o.textContent = ep.name + (ep.online ? '' : ' (offline)'); 420 epS.appendChild(o); 421 }); 422 var first = enabledEndpoints()[0]; 423 epS.value = fb.endpoint_id || (first ? first.id : ''); 424 425 var mS = document.createElement('select'); 426 mS.className = 'settings-select'; 427 fillModels(mS, epS.value, fb.model); 428 429 // Keep the model in sync with the values actually shown. 430 fb.endpoint_id = epS.value; 431 fb.model = mS.value; 432 433 epS.addEventListener('change', function() { 434 fb.endpoint_id = epS.value; 435 fillModels(mS, epS.value, ''); 436 fb.model = mS.value; 437 saveDefault(); 438 }); 439 mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); }); 440 441 var rm = document.createElement('button'); 442 rm.type = 'button'; 443 rm.className = 'settings-fallback-remove'; 444 rm.title = 'Remove fallback'; 445 rm.innerHTML = '×'; 446 rm.addEventListener('click', function() { 447 _fallbacks.splice(idx, 1); 448 renderFallbacks(); 449 saveDefault(); 450 }); 451 452 row.appendChild(num); 453 row.appendChild(epS); 454 row.appendChild(mS); 455 row.appendChild(rm); 456 fbContainer.appendChild(row); 457 }); 458 } 459 460 try { 461 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 462 var settings = await res.json(); 463 if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id; 464 refreshModels(settings.default_model || ''); 465 _fallbacks = Array.isArray(settings.default_model_fallbacks) 466 ? settings.default_model_fallbacks.map(function(f) { 467 return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; 468 }) 469 : []; 470 renderFallbacks(); 471 } catch (e) { console.warn('Failed to load default chat settings', e); } 472 473 async function saveDefault() { 474 try { 475 var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; }); 476 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 477 headers: { 'Content-Type': 'application/json' }, 478 body: JSON.stringify({ 479 default_endpoint_id: epSel.value, 480 default_model: modelSel.value, 481 default_model_fallbacks: clean 482 }) 483 }); 484 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; 485 setTimeout(function() { msg.textContent = ''; }, 2000); 486 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 487 } 488 489 epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); }); 490 modelSel.addEventListener('change', saveDefault); 491 if (addFbBtn) addFbBtn.addEventListener('click', function() { 492 var first = enabledEndpoints()[0]; 493 _fallbacks.push({ endpoint_id: first ? first.id : '', model: '' }); 494 renderFallbacks(); 495 saveDefault(); 496 }); 497 498 _registerAiEndpointRefresh(function(endpoints) { 499 _endpoints = endpoints; 500 refreshEndpointOptions(epSel.value, modelSel.value); 501 }); 502} 503 504/* ââ Utility Model ââ */ 505async function initUtilityModel() { 506 var epSel = el('set-utilityEpSelect'); 507 var modelSel = el('set-utilityModelSelect'); 508 var msg = el('set-utilityChatMsg'); 509 var _endpoints = []; 510 var fallbackWidget = null; 511 if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat'; 512 if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat'; 513 514 try { 515 _endpoints = await _fetchModelEndpoints(); 516 _fillEndpointSelect(epSel, _endpoints, epSel.value, true); 517 } catch (e) { console.warn('Failed to load endpoints for utility model', e); } 518 519 function refreshModels(selectedModel) { 520 var epId = epSel.value; 521 var ep = _endpoints.find(function(e) { return e.id === epId; }); 522 _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true); 523 } 524 525 try { 526 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 527 var settings = await res.json(); 528 if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id; 529 refreshModels(settings.utility_model || ''); 530 fallbackWidget = _bindFallbackWidget({ 531 containerId: 'set-utilityFallbacks', 532 addBtnId: 'set-utilityAddFallback', 533 endpoints: function() { return _endpoints; }, 534 settingKey: 'utility_model_fallbacks', 535 initial: Array.isArray(settings.utility_model_fallbacks) 536 ? settings.utility_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; }) 537 : [], 538 }); 539 } catch (e) { console.warn('Failed to load utility model settings', e); } 540 541 // Persist whatever's currently selected. Empty endpoint or model â backend 542 // transparently falls back to the chat model (mirrors the teacher panel: 543 // no toggle, "â" means "unset, use chat"). 544 async function saveUtility() { 545 try { 546 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 547 headers: { 'Content-Type': 'application/json' }, 548 body: JSON.stringify({ 549 utility_endpoint_id: epSel.value || '', 550 utility_model: modelSel.value || '' 551 }) 552 }); 553 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; 554 setTimeout(function() { msg.textContent = ''; }, 1500); 555 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 556 } 557 558 epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); }); 559 modelSel.addEventListener('change', saveUtility); 560
561 _registerAiEndpointRefresh(function(endpoints) { 562 _endpoints = endpoints; 563 _fillEndpointSelect(epSel, _endpoints, epSel.value, true); 564 refreshModels(modelSel.value); 565 if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh(); 566 }); 567} 568 569/* ââ Teacher Model ââ */ 570// SOTA model called automatically when a self-hosted student model 571// fails an agent-mode task. Stored as a single `teacher_model` string 572// in the form `model@endpoint_name` so the backend's _resolve_model 573// can dispatch directly. Master toggle is the separate 574// `teacher_enabled` flag so the user can pause the feature without 575// losing their endpoint+model selection. 576async function initTeacherModel() { 577 var enabledToggle = el('set-teacherEnabledToggle'); 578 var epSel = el('set-teacherEpSelect'); 579 var modelSel = el('set-teacherModelSelect'); 580 var msg = el('set-teacherChatMsg'); 581 if (!epSel || !modelSel) return; 582 var _endpoints = []; 583 584 try { 585 _endpoints = await _fetchModelEndpoints(); 586 _fillEndpointSelect(epSel, _endpoints, epSel.value, true); 587 } catch (e) { console.warn('Failed to load endpoints for teacher model', e); } 588 589 function refreshModels(selectedModel) { 590 var epId = epSel.value; 591 var ep = _endpoints.find(function(e) { return e.id === epId; }); 592 _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true); 593 } 594 595 // Disable / enable the endpoint+model dropdowns based on the 596 // master switch. Greys them out so users see at a glance that the 597 // selection is dormant. 598 function syncEnabled() { 599 var off = enabledToggle ? !enabledToggle.checked : true; 600 // Dim the card when off as a "dormant" cue, but keep the endpoint+model 601 // dropdowns INTERACTIVE â the toggle gates whether escalation runs, not 602 // whether you can configure it. (Previously the config was inert when off, 603 // so users couldn't pick an endpoint until they'd already enabled it.) 604 var card = enabledToggle ? enabledToggle.closest('.admin-card') : null; 605 if (card) card.style.opacity = off ? '0.7' : ''; 606 var wrap = card ? card.querySelector('.settings-col') : null; 607 if (wrap) wrap.style.pointerEvents = ''; 608 epSel.disabled = false; 609 modelSel.disabled = false; 610 } 611 612 try { 613 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 614 var settings = await res.json(); 615 if (enabledToggle) enabledToggle.checked = !!settings.teacher_enabled; 616 // teacher_model is stored as "model@endpoint_name". Split on the 617 // LAST `@` so model ids that contain @ aren't mangled. 618 var spec = settings.teacher_model || ''; 619 var savedModel = spec; 620 var savedEpName = ''; 621 var at = spec.lastIndexOf('@'); 622 if (at >= 0) { 623 savedModel = spec.slice(0, at); 624 savedEpName = spec.slice(at + 1); 625 } 626 if (savedEpName) { 627 var match = _endpoints.find(function(ep) { 628 return ep.name && ep.name.toLowerCase().indexOf(savedEpName.toLowerCase()) >= 0; 629 }); 630 if (match) epSel.value = match.id; 631 } 632 refreshModels(savedModel); 633 syncEnabled(); 634 } catch (e) { console.warn('Failed to load teacher model settings', e); } 635 636 async function saveTeacher() { 637 try { 638 var spec = ''; 639 if (epSel.value && modelSel.value) { 640 var ep = _endpoints.find(function(e) { return e.id === epSel.value; }); 641 spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value; 642 } 643 var enabled = enabledToggle ? !!enabledToggle.checked : false; 644 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 645 headers: { 'Content-Type': 'application/json' }, 646 body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec }) 647 }); 648 msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled'; 649 msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)'; 650 setTimeout(function() { msg.textContent = ''; }, 2000); 651 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 652 } 653 654 if (enabledToggle) { 655 enabledToggle.addEventListener('change', function() { 656 syncEnabled(); 657 saveTeacher(); 658 }); 659 } 660 epSel.addEventListener('change', function() { refreshModels(''); saveTeacher(); }); 661 modelSel.addEventListener('change', saveTeacher); 662
663 _registerAiEndpointRefresh(function(endpoints) { 664 _endpoints = endpoints; 665 _fillEndpointSelect(epSel, _endpoints, epSel.value, true); 666 refreshModels(modelSel.value); 667 }); 668} 669 670/* ââ Image Generation ââ */ 671async function initImageSettings() { 672 const modelSel = el('set-imgModelSelect'); 673 const qualSel = el('set-imgQualitySelect'); 674 const msg = el('set-imgSettingsMsg'); 675 const enabledToggle = el('set-imgEnabledToggle'); 676 const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null; 677 try { 678 const modelsRes = await fetch('/api/models', { credentials: 'same-origin' }); 679 const modelsData = await modelsRes.json(); 680 // Inpaint-compat allowlist â image gen here is scoped to inpainting only, 681 // so DALL-E / GPT-Image-1 (no inpaint API) are excluded. Currently: 682 // - any model with 'inpaint' in the id 683 // - Stable Diffusion 3.5 Medium (inpaint via diffusers pipeline) 684 const _isInpaintModel = (mid) => { 685 const lower = String(mid || '').toLowerCase(); 686 return lower.includes('inpaint') 687 || lower.includes('3.5-medium') 688 || lower.includes('3-5-medium') 689 || lower.includes('sd-3.5-med'); 690 }; 691 const imageModels = []; 692 (modelsData.items || []).forEach(item => { 693 (item.models || []).forEach(mid => { 694 if (_isInpaintModel(mid)) imageModels.push(mid); 695 }); 696 }); 697 sortModelIds(imageModels).forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); }); 698 // Hardcoded fallbacks shown as "(not detected)" so users know what to 699 // download/serve to enable inpaint here. 700 ['stable-diffusion-3.5-medium', 'stable-diffusion-inpainting'].forEach(mid => { 701 if (!imageModels.includes(mid)) { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid + ' (not detected)'; modelSel.appendChild(opt); } 702 }); 703 } catch (e) { console.warn('Failed to load models for image settings', e); } 704 try { 705 const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 706 const settings = await settingsRes.json(); 707 if (settings.image_model) modelSel.value = settings.image_model; 708 if (settings.image_quality) qualSel.value = settings.image_quality; 709 if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled !== false; 710 } catch (e) { console.warn('Failed to load settings', e); } 711 712 function syncImgDisabled() { 713 var off = enabledToggle && !enabledToggle.checked; 714 var card = enabledToggle ? enabledToggle.closest('.admin-card') : null; 715 if (card) card.style.opacity = off ? '0.45' : ''; 716 if (configWrap) configWrap.style.pointerEvents = off ? 'none' : ''; 717 } 718 syncImgDisabled(); 719 720 async function saveSettings() { 721 try { 722 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, 723 body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : true, image_model: modelSel.value, image_quality: qualSel.value }) }); 724 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000); 725 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 726 } 727 modelSel.addEventListener('change', saveSettings); 728 qualSel.addEventListener('change', saveSettings); 729 if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); }); 730} 731 732/* ââ Vision ââ */ 733async function initVisionSettings() { 734 const vlSel = el('set-vlModelSelect'); 735 const msg = el('set-visionSettingsMsg'); 736 const enabledToggle = el('set-visionEnabledToggle'); 737 const configWrap = vlSel ? vlSel.closest('div[style*="flex-direction"]') : null; 738 var _visionEndpoints = []; 739 var visionFallbackWidget = null; 740 var _vlExclude = ['audio', 'realtime', 'tts', 'dall-e', 'embedding', 'search', 'whisper']; 741 function _isVisionModel(mid) { 742 var lower = String(mid || '').toLowerCase(); 743 return !_vlExclude.some(function(kw) { return lower.includes(kw); }); 744 } 745 try { 746 const modelsRes = await fetch('/api/models', { credentials: 'same-origin' }); 747 const modelsData = await modelsRes.json(); 748 const visionModels = [];
749 (modelsData.items || []).forEach(item => { 750 if (item.offline) return; 751 (item.models || []).forEach(mid => { 752 if (_isVisionModel(mid)) { 753 visionModels.push(mid); 754 } 755 }); 756 }); 757 sortModelIds(visionModels).forEach(mid => { 758 var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt); 759 }); 760 } catch (e) { console.warn('Failed to load models for vision settings', e); } 761 // Also pull the raw endpoint list so the fallback widget can resolve 762 // endpoint-id â models the same way the other cards do. 763 try { 764 _visionEndpoints = await _fetchModelEndpoints(); 765 } catch (e) { console.warn('Failed to load endpoints for vision fallback', e); } 766 try { 767 const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 768 const settings = await settingsRes.json(); 769 if (settings.vision_model) vlSel.value = settings.vision_model; 770 if (enabledToggle) enabledToggle.checked = settings.vision_enabled !== false; 771 visionFallbackWidget = _bindFallbackWidget({ 772 containerId: 'set-visionFallbacks', 773 addBtnId: 'set-visionAddFallback', 774 endpoints: function() { return _visionEndpoints; }, 775 // Vision fallback list filters to vision-capable models (same heuristic 776 // as the primary select above â exclude audio/tts/embedding/etc.). 777 modelsFilter: function(mid) { return _isVisionModel(mid); }, 778 settingKey: 'vision_model_fallbacks', 779 initial: Array.isArray(settings.vision_model_fallbacks) 780 ? settings.vision_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; }) 781 : [], 782 }); 783 } catch (e) { console.warn('Failed to load vision settings', e); } 784 785 function syncVisionDisabled() { 786 var off = enabledToggle && !enabledToggle.checked; 787 var card = enabledToggle ? enabledToggle.closest('.admin-card') : null; 788 if (card) card.style.opacity = off ? '0.45' : ''; 789 if (configWrap) configWrap.style.pointerEvents = off ? 'none' : ''; 790 } 791 syncVisionDisabled(); 792 793 async function saveSettings() { 794 try { 795 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, 796 body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) }); 797 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000); 798 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 799 } 800 vlSel.addEventListener('change', saveSettings); 801 if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); }); 802 803 _registerAiEndpointRefresh(function(endpoints) { 804 _visionEndpoints = endpoints; 805 if (visionFallbackWidget && visionFallbackWidget.refresh) visionFallbackWidget.refresh(); 806 }); 807} 808 809/* ââ Face Recognition ââ */ 810 811/* ââ Text to Speech ââ */ 812async function initTtsSettings() { 813 var provSel = el('set-ttsProviderSelect'); 814 var modelSelect = el('set-ttsModelSelect'); 815 var modelInput = el('set-ttsModelInput'); 816 var voiceSelect = el('set-ttsVoiceSelect'); 817 var voiceInput = el('set-ttsVoiceInput'); 818 var modelRow = el('set-ttsModelRow'); 819 var voiceRow = el('set-ttsVoiceRow'); 820 var speedSelect = el('set-ttsSpeedSelect'); 821 var speedRow = el('set-ttsSpeedRow'); 822 var ttsMsg = el('set-ttsSettingsMsg'); 823 var ttsEnabledToggle = el('set-ttsEnabledToggle'); 824 var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null; 825 826 function isEndpoint() { return provSel.value.startsWith('endpoint:'); } 827 function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; } 828 function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; } 829 830 function updateVisibility() { 831 var prov = provSel.value; 832 modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none'; 833 voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex'; 834 speedRow.style.display = prov === 'disabled' ? 'none' : 'flex'; 835 if (isEndpoint()) { 836 modelSelect.style.display = ''; modelInput.style.display = 'none'; 837 voiceSelect.style.display = ''; voiceInput.style.display = 'none'; 838 } else { 839 modelSelect.style.display = 'none'; modelInput.style.display = ''; 840 voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : ''; 841 } 842 } 843 844 var ttsKeywords = ['tts', 'audio']; 845 try { 846 var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); 847 var endpoints = await epRes.json();
848 endpoints.forEach(function(ep) { 849 if (!ep.is_enabled) return; 850 var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw))); 851 if (!hasTTS) return; 852 var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt); 853 }); 854 } catch (e) { console.warn('Failed to load endpoints for TTS', e); } 855 856 try { 857 var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 858 var settings = await settingsRes.json(); 859 if (settings.tts_provider) provSel.value = settings.tts_provider; 860 if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; } 861 if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; } 862 if (settings.tts_speed) { speedSelect.value = settings.tts_speed; } 863 if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false; 864 } catch (e) { console.warn('Failed to load TTS settings', e); } 865 866 function syncTtsDisabled() { 867 var off = ttsEnabledToggle && !ttsEnabledToggle.checked; 868 var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null; 869 if (card) card.style.opacity = off ? '0.45' : ''; 870 if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : ''; 871 } 872 syncTtsDisabled(); 873 updateVisibility(); 874 875 async function saveTTS() { 876 try { 877 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, 878 body: JSON.stringify({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }) }); 879 ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000); 880 if (window.aiTTSManager) window.aiTTSManager.checkAvailability(); 881 } catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; } 882 } 883 884 async function saveAndClearCache() { 885 await saveTTS(); 886 fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){}); 887 } 888 889 provSel.addEventListener('change', function() { 890 var prov = provSel.value; 891 if (prov === 'local') voiceInput.value = 'af_heart'; 892 else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; } 893 else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; } 894 updateVisibility(); 895 saveTTS(); 896 }); 897 modelSelect.addEventListener('change', saveAndClearCache); 898 modelInput.addEventListener('change', saveTTS); 899 voiceSelect.addEventListener('change', saveAndClearCache); 900 voiceInput.addEventListener('change', saveTTS); 901 speedSelect.addEventListener('change', saveAndClearCache); 902 if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); }); 903 904 // Preview / test button 905 var previewBtn = el('set-ttsPreviewBtn'); 906 if (previewBtn) { 907 var previewAudio = null; 908 var previewPlaying = false; 909 function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; } 910 911 previewBtn.addEventListener('click', async function() { 912 if (previewPlaying) { 913 if (previewAudio) { previewAudio.pause(); previewAudio = null; } 914 window.speechSynthesis.cancel(); 915 resetPreview(); return; 916 } 917 var prov = provSel.value; 918 if (prov === 'disabled') { 919 ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)'; 920 setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return; 921 } 922 var testText = 'Hello, this is a test of text to speech.'; 923 previewPlaying = true; previewBtn.textContent = 'Loading...'; 924 try { 925 if (prov === 'browser') { 926 if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported'); 927 var utt = new SpeechSynthesisUtterance(testText); 928 var voiceVal = getVoice(); 929 if (voiceVal) { 930 var voices = window.speechSynthesis.getVoices(); 931 var target = voiceVal.toLowerCase(); 932 var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) || 933 voices.find(function(v) { return v.name.toLowerCase().includes(target); }); 934 if (match) utt.voice = match; 935 } 936 utt.rate = parseFloat(speedSelect.value) || 1; 937 previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)'; 938 await new Promise(function(resolve, reject) { 939 utt.onend = resolve; 940 utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); }; 941 window.speechSynthesis.speak(utt); 942 }); 943 } else { 944 var res = await fetch('/api/tts/synthesize', { 945 method: 'POST', credentials: 'same-origin', 946 headers: { 'Content-Type': 'application/json' }, 947 body: JSON.stringify({ text: testText, format: 'audio' }) 948 }); 949 if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); } 950 var blob = await res.blob(); 951 var url = URL.createObjectURL(blob); 952 previewAudio = new Audio(url); 953 previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)'; 954 await new Promise(function(resolve, reject) { 955 previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); }; 956 previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); }; 957 previewAudio.play().catch(reject); 958 }); 959 } 960 } catch (e) { 961 ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)'; 962 setTimeout(function() { ttsMsg.textContent = ''; }, 3000); 963 } finally { 964 resetPreview(); 965 } 966 }); 967 } 968} 969 970/* ââ Speech to Text ââ */ 971async function initSttSettings() { 972 var provSel = el('set-sttProviderSelect'); 973 var modelSelect = el('set-sttModelSelect'); 974 var modelInput = el('set-sttModelInput'); 975 var modelRow = el('set-sttModelRow'); 976 var langRow = el('set-sttLangRow'); 977 var langInput = el('set-sttLangInput'); 978 var sttMsg = el('set-sttSettingsMsg'); 979 var sttEnabledToggle = el('set-sttEnabledToggle'); 980 var sttConfigWrap = el('set-sttConfigWrap'); 981 // STT was removed from AI Defaults â bail if the UI isn't present. 982 if (!provSel) return; 983 984 function isEndpoint() { return provSel.value.startsWith('endpoint:'); } 985 function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; } 986 987 function updateVisibility() { 988 var prov = provSel.value; 989 var showModel = prov === 'local' || prov.startsWith('endpoint:'); 990 var showLang = prov !== 'disabled'; 991 modelRow.style.display = showModel ? 'flex' : 'none'; 992 langRow.style.display = showLang ? 'flex' : 'none'; 993 if (isEndpoint()) { 994 modelSelect.style.display = 'none'; modelInput.style.display = ''; 995 } else { 996 modelSelect.style.display = ''; modelInput.style.display = 'none'; 997 } 998 } 999 1000 function syncSttDisabled() { 1001 var off = sttEnabledToggle && !sttEnabledToggle.checked; 1002 var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null; 1003 if (card) card.style.opacity = off ? '0.45' : ''; 1004 if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : ''; 1005 } 1006 1007 // Effective provider: if toggle is off, treat as disabled regardless of provider select 1008 function effectiveProvider() { 1009 if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled'; 1010 return provSel.value; 1011 } 1012 1013 // Add API endpoints that might support STT 1014 try { 1015 var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); 1016 var endpoints = await epRes.json();
1017 endpoints.forEach(function(ep) { 1018 if (!ep.is_enabled) return; 1019 var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt); 1020 }); 1021 } catch (e) { console.warn('Failed to load endpoints for STT', e); } 1022 1023 // Load saved settings 1024 try { 1025 var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1026 var settings = await settingsRes.json(); 1027 if (settings.stt_provider) provSel.value = settings.stt_provider; 1028 if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; } 1029 if (settings.stt_language) langInput.value = settings.stt_language; 1030 if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false; 1031 } catch (e) { console.warn('Failed to load STT settings', e); } 1032 1033 syncSttDisabled(); 1034 updateVisibility(); 1035 1036 async function saveSTT() { 1037 try { 1038 var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false; 1039 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 1040 headers: { 'Content-Type': 'application/json' }, 1041 body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) }); 1042 sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000); 1043 // Notify voiceRecorder of effective provider and update send button icon 1044 if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider(); 1045 if (window._updateSendBtnIcon) window._updateSendBtnIcon(); 1046 } catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; } 1047 } 1048 1049 provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); }); 1050 modelSelect.addEventListener('change', saveSTT); 1051 modelInput.addEventListener('change', saveSTT); 1052 langInput.addEventListener('change', saveSTT); 1053 if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); }); 1054} 1055 1056/* âââââââââââââââââââââââââââââââââââââââââââ 1057 SEARCH TAB 1058 âââââââââââââââââââââââââââââââââââââââââââ */ 1059 1060var _searchProviderHints = { 1061 searxng: 'Self-hosted SearXNG instance. Leave URL empty to use the SEARXNG_INSTANCE env var.', 1062 duckduckgo: 'Free search â no API key required. Works out of the box.', 1063 brave: 'Get your API key from brave.com/search/api', 1064 google_pse: 'Requires a Google API key and a Programmable Search Engine ID (CX). Create one at programmablesearchengine.google.com', 1065 tavily: 'AI-optimized search. 1,000 free credits/month at tavily.com', 1066 serper: 'Google results via API. 2,500 free queries at serper.dev', 1067 disabled: 'Web search and deep research tools will be unavailable.', 1068}; 1069var _searchNeedsKey = { brave: 1, google_pse: 1, tavily: 1, serper: 1 }; 1070var _searchLabels = { 1071 searxng: 'SearXNG', duckduckgo: 'DuckDuckGo', brave: 'Brave Search', 1072 google_pse: 'Google PSE', tavily: 'Tavily', serper: 'Serper', disabled: 'Disabled', 1073}; 1074var _searchKeyFields = { 1075 brave: 'brave_api_key', google_pse: 'google_pse_key', 1076 tavily: 'tavily_api_key', serper: 'serper_api_key', 1077}; 1078 1079async function initSearchSettings() { 1080 var provSel = el('set-searchProvider'); 1081 var countSel = el('set-searchResultCount'); 1082 var countCustomInput = el('set-searchResultCountCustom'); 1083 var urlInput = el('set-searchUrl'); 1084 var urlRow = el('set-searchUrlRow'); 1085 var keyInput = el('set-searchApiKey'); 1086 var keyRow = el('set-searchKeyRow'); 1087 var cxInput = el('set-searchCx'); 1088 var cxRow = el('set-searchCxRow'); 1089 var hint = el('set-searchHint'); 1090 var msg = el('set-searchMsg'); 1091 var _settings = {}; 1092 1093 function keyFieldFor(prov) { return _searchKeyFields[prov] || ''; } 1094 1095 function loadKeyForProvider(prov) { 1096 var field = keyFieldFor(prov); 1097 keyInput.value = field ? (_settings[field] || _settings.search_api_key || '') : ''; 1098 } 1099 1100 function updateVisibility() { 1101 var prov = provSel.value; 1102 urlRow.style.display = prov === 'searxng' ? 'flex' : 'none'; 1103 keyRow.style.display = _searchNeedsKey[prov] ? 'flex' : 'none'; 1104 cxRow.style.display = prov === 'google_pse' ? 'flex' : 'none'; 1105 hint.textContent = _searchProviderHints[prov] || ''; 1106 if (prov === 'brave') keyInput.placeholder = 'Brave API key'; 1107 else if (prov === 'google_pse') keyInput.placeholder = 'Google API key'; 1108 else if (prov === 'tavily') keyInput.placeholder = 'Tavily API key'; 1109 else if (prov === 'serper') keyInput.placeholder = 'Serper API key'; 1110 else keyInput.placeholder = 'API key'; 1111 loadKeyForProvider(prov); 1112 } 1113 1114 function updateCountDisplay() { 1115 var val = _settings.search_result_count || 5; 1116 var presets = ['3', '5', '10', '20'];
1117 if (presets.includes(String(val))) { 1118 countSel.value = String(val); 1119 countCustomInput.style.display = 'none'; 1120 } else { 1121 countSel.value = 'custom'; 1122 countCustomInput.value = Math.max(1, Math.min(100, val)); 1123 countCustomInput.style.display = 'block'; 1124 } 1125 } 1126 1127 try { 1128 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1129 _settings = await res.json(); 1130 if (_settings.search_provider) provSel.value = _settings.search_provider; 1131 updateCountDisplay(); 1132 if (_settings.search_url) urlInput.value = _settings.search_url; 1133 if (_settings.google_pse_cx) cxInput.value = _settings.google_pse_cx; 1134 } catch (e) { console.warn('Failed to load search settings', e); } 1135 1136 countSel.addEventListener('change', function() { 1137 if (this.value === 'custom') { 1138 countCustomInput.style.display = 'block'; 1139 countCustomInput.focus(); 1140 } else { 1141 countCustomInput.style.display = 'none'; 1142 } 1143 }); 1144 1145 updateVisibility(); 1146 1147 async function refreshStatus() { 1148 try { 1149 var sRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1150 var s = await sRes.json(); 1151 _settings = s; 1152 var active = s.search_provider || 'searxng'; 1153 var label = _searchLabels[active] || active; 1154 var extra = ''; 1155 var kf = keyFieldFor(active); 1156 var hasKey = kf ? ((s[kf] || '').trim() || (s.search_api_key || '').trim()) : false; 1157 if (_searchNeedsKey[active]) { 1158 extra = hasKey ? ' (key set)' : ' (no key)'; 1159 } else if (active === 'searxng' && (s.search_url || '').trim()) { 1160 extra = ' (' + s.search_url + ')'; 1161 } 1162 var count = s.search_result_count || 5; 1163 msg.textContent = 'Active: ' + label + extra + ' \u00b7 ' + count + ' results'; 1164 msg.style.color = active === 'disabled' ? 'var(--red)' : (_searchNeedsKey[active] && !hasKey) ? 'var(--red)' : 'var(--fg)'; 1165 } catch (e) { /* ignore */ } 1166 } 1167 refreshStatus(); 1168 1169 async function saveSearch() { 1170 try { 1171 var prov = provSel.value; 1172 var resultCount; 1173 if (countSel.value === 'custom') { 1174 var customVal = parseInt(countCustomInput.value, 10); 1175 if (isNaN(customVal) || customVal < 1 || customVal > 100) { 1176 resultCount = _settings.search_result_count || 5; 1177 } else { 1178 resultCount = customVal; 1179 } 1180 } else { 1181 resultCount = parseInt(countSel.value, 10); 1182 } 1183 var payload = { 1184 search_provider: prov, 1185 search_result_count: resultCount, 1186 search_url: urlInput.value.trim(), 1187 google_pse_cx: cxInput.value.trim(), 1188 }; 1189 var kf = keyFieldFor(prov); 1190 if (kf) { 1191 payload[kf] = keyInput.value.trim(); 1192 _settings[kf] = keyInput.value.trim(); 1193 } 1194 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 1195 headers: { 'Content-Type': 'application/json' }, 1196 body: JSON.stringify(payload) 1197 }); 1198 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; 1199 setTimeout(refreshStatus, 2000); 1200 if (searchModule && searchModule.refresh) searchModule.refresh(); 1201 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 1202 } 1203 1204 provSel.addEventListener('change', function() { updateVisibility(); saveSearch(); _syncSearchPicker(); }); 1205 countSel.addEventListener('change', saveSearch); 1206 urlInput.addEventListener('change', saveSearch); 1207 keyInput.addEventListener('change', saveSearch); 1208 cxInput.addEventListener('change', saveSearch); 1209 1210 // ââ Provider picker with logos (mirrors the hidden <select>) ââ 1211 var picker = el('search-provider-picker'); 1212 var pickerBtn = el('search-provider-btn'); 1213 var pickerMenu = el('search-provider-menu'); 1214 var pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null; 1215 function _searchProviderLogoSvg(key) { 1216 return _SEARCH_PROVIDER_LOGOS[key] || ''; 1217 } 1218 function _renderSearchPickerMenu() { 1219 if (!pickerMenu) return; 1220 pickerMenu.innerHTML = Array.from(provSel.options).map(function(o) { 1221 var logo = _searchProviderLogoSvg(o.dataset.searchLogo); 1222 var active = o.value === provSel.value ? ' active' : ''; 1223 return '<div class="adm-provider-item' + active + '" role="option" data-value="' + o.value.replace(/"/g, '"') + '">' + 1224 '<span class="adm-provider-logo">' + logo + '</span>' + 1225 '<span>' + o.textContent + '</span>' + 1226 '</div>'; 1227 }).join(''); 1228 } 1229 function _syncSearchPicker() { 1230 if (!pickerCurrent) return; 1231 var opt = provSel.selectedOptions[0] || provSel.options[0]; 1232 var logo = _searchProviderLogoSvg(opt.dataset.searchLogo); 1233 pickerCurrent.querySelector('.adm-provider-logo').innerHTML = logo; 1234 pickerCurrent.querySelector('.adm-provider-name').textContent = opt.textContent; 1235 } 1236 if (picker && pickerBtn && pickerMenu && pickerCurrent) {
1237 _renderSearchPickerMenu(); 1238 _syncSearchPicker(); 1239 pickerBtn.addEventListener('click', function(e) { 1240 e.stopPropagation(); 1241 pickerMenu.classList.toggle('hidden'); 1242 }); 1243 pickerMenu.addEventListener('click', function(e) { 1244 var item = e.target.closest('.adm-provider-item'); 1245 if (!item) return; 1246 provSel.value = item.dataset.value; 1247 provSel.dispatchEvent(new Event('change', { bubbles: true })); 1248 pickerMenu.classList.add('hidden'); 1249 _renderSearchPickerMenu(); 1250 }); 1251 document.addEventListener('click', function(e) { 1252 if (!picker.contains(e.target)) pickerMenu.classList.add('hidden'); 1253 }); 1254 } 1255 1256 // ââ Fallback chain ââ 1257 // Stored as an ordered array of provider IDs (primary not included). 1258 // When the primary fails or hits rate-limit, the backend walks this 1259 // list in order trying each one. 1260 var fbWrap = el('set-searchFallbackChain'); 1261 function _availableFallbackOptions() { 1262 var primary = provSel.value; 1263 var chain = _settings.search_fallback_chain || []; 1264 var inChain = new Set(chain.concat([primary, 'disabled'])); 1265 return Array.from(provSel.options) 1266 .map(function(o) { return { value: o.value, label: o.textContent, logo: o.dataset.searchLogo }; }) 1267 .filter(function(o) { return !inChain.has(o.value); }); 1268 } 1269 function _renderFallbackChain() { 1270 if (!fbWrap) return; 1271 var chain = (_settings.search_fallback_chain || []).slice(); 1272 var esc = function(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"'); }; 1273 var chipsHtml = chain.map(function(p, i) { 1274 var label = _searchLabels[p] || p; 1275 var logo = _SEARCH_PROVIDER_LOGOS[p] || ''; 1276 return '<span class="search-fb-chip" draggable="true" data-idx="' + i + '" data-value="' + esc(p) + '">' + 1277 '<span class="search-fb-grip" title="Drag to reorder">â®â®</span>' + 1278 '<span class="search-fb-logo">' + logo + '</span>' + 1279 '<span>' + esc(label) + '</span>' + 1280 '<button type="button" class="search-fb-remove" data-value="' + esc(p) + '" title="Remove">×</button>' + 1281 '</span>'; 1282 }).join(''); 1283 var addOptions = _availableFallbackOptions(); 1284 var addSelect = addOptions.length 1285 ? '<select class="search-fb-add" id="search-fb-add"><option value="">+ Add</option>' + 1286 addOptions.map(function(o) { return '<option value="' + esc(o.value) + '">' + esc(o.label) + '</option>'; }).join('') + 1287 '</select>' 1288 : ''; 1289 fbWrap.innerHTML = chipsHtml + addSelect; 1290 // Wire chip remove + drag-reorder + add 1291 fbWrap.querySelectorAll('.search-fb-remove').forEach(function(btn) { 1292 btn.addEventListener('click', function() { 1293 var next = (_settings.search_fallback_chain || []).filter(function(p) { return p !== btn.dataset.value; }); 1294 _saveFallbackChain(next); 1295 }); 1296 }); 1297 var addSel = el('search-fb-add'); 1298 if (addSel) { 1299 addSel.addEventListener('change', function() { 1300 if (!addSel.value) return; 1301 var next = (_settings.search_fallback_chain || []).slice(); 1302 if (!next.includes(addSel.value)) next.push(addSel.value); 1303 _saveFallbackChain(next); 1304 }); 1305 } 1306 // Drag-reorder 1307 var dragging = null; 1308 fbWrap.querySelectorAll('.search-fb-chip').forEach(function(chip) { 1309 chip.addEventListener('dragstart', function() { 1310 dragging = chip; chip.classList.add('dragging'); 1311 }); 1312 chip.addEventListener('dragend', function() { 1313 if (dragging) dragging.classList.remove('dragging'); 1314 dragging = null; 1315 // Persist new order 1316 var order = Array.from(fbWrap.querySelectorAll('.search-fb-chip')).map(function(c) { return c.dataset.value; }); 1317 _saveFallbackChain(order); 1318 }); 1319 chip.addEventListener('dragover', function(e) { 1320 e.preventDefault(); 1321 if (!dragging || dragging === chip) return; 1322 var rect = chip.getBoundingClientRect(); 1323 var after = (e.clientX - rect.left) > rect.width / 2; 1324 chip.parentNode.insertBefore(dragging, after ? chip.nextSibling : chip); 1325 }); 1326 }); 1327 } 1328 async function _saveFallbackChain(chain) { 1329 _settings.search_fallback_chain = chain; 1330 try { 1331 await fetch('/api/auth/settings', { 1332 method: 'POST', credentials: 'same-origin', 1333 headers: { 'Content-Type': 'application/json' }, 1334 body: JSON.stringify({ search_fallback_chain: chain }), 1335 }); 1336 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; 1337 setTimeout(refreshStatus, 2000); 1338 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 1339 _renderFallbackChain(); 1340 } 1341 _renderFallbackChain(); 1342 // Re-render whenever the primary changes (it gets filtered out of "Add"). 1343 provSel.addEventListener('change', _renderFallbackChain); 1344 1345 // ââ Test button ââ runs a one-off query against the configured provider. 1346 var testBtn = el('set-searchTestBtn'); 1347 if (testBtn) { 1348 testBtn.addEventListener('click', async function() { 1349 var prov = provSel.value; 1350 if (!prov || prov === 'disabled') {
1351 msg.textContent = 'Pick a provider first'; 1352 msg.style.color = 'var(--red)'; 1353 return; 1354 } 1355 // Persist current form values first so the test uses what's on screen. 1356 await saveSearch(); 1357 testBtn.disabled = true; 1358 var orig = testBtn.textContent; 1359 testBtn.textContent = 'Testing...'; 1360 msg.textContent = ''; 1361 var t0 = performance.now(); 1362 try { 1363 var fd = new FormData(); 1364 fd.append('query', 'hello world'); 1365 fd.append('provider', prov); 1366 fd.append('count', '3'); 1367 var r = await fetch('/api/search/query', { method: 'POST', body: fd, credentials: 'same-origin' }); 1368 var d = await r.json(); 1369 var ms = Math.round(performance.now() - t0); 1370 if (d.error) { 1371 msg.textContent = 'â ' + d.error + ' (' + ms + 'ms)'; 1372 msg.style.color = 'var(--red)'; 1373 } else if (!d.results || !d.results.length) { 1374 msg.textContent = 'â No results returned (' + ms + 'ms)'; 1375 msg.style.color = 'var(--red)'; 1376 } else { 1377 var topTitle = (d.results[0].title || d.results[0].url || '').slice(0, 60); 1378 msg.textContent = 'â ' + d.results.length + ' result' + (d.results.length === 1 ? '' : 's') + ' · ' + ms + 'ms · top: ' + topTitle; 1379 msg.style.color = 'var(--fg)'; 1380 } 1381 } catch (e) { 1382 msg.textContent = 'â Test failed: ' + (e && e.message ? e.message : e); 1383 msg.style.color = 'var(--red)'; 1384 } finally { 1385 testBtn.disabled = false; testBtn.textContent = orig; 1386 } 1387 }); 1388 } 1389} 1390 1391// SVG logos for each search provider (16Ã16 viewBox normalised to 24Ã24). 1392var _SEARCH_PROVIDER_LOGOS = { 1393 searxng: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 4a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm0-2a8 8 0 1 1-4.93 14.32l-3.4 3.4a1 1 0 1 1-1.4-1.4l3.4-3.4A8 8 0 0 1 10 2zM13 8.5L11.5 10 13 11.5l-1 1L10.5 11 9 12.5l-1-1L9.5 10 8 8.5l1-1L10.5 9 12 7.5z"/></svg>', 1394 duckduckgo:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.5 5.5a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4zm5 0a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4zM12 13c-1.5 0-3.6.8-3.6 2.5C8.4 17.2 10.4 18 12 18s3.6-.8 3.6-2.5C15.6 13.8 13.5 13 12 13z"/></svg>', 1395 brave: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 4l-1.5 1L15 3l-3 .5L9 3 6.5 5 5 4 3 7l1.5 2L4 12l3 5 4 3 1 1 1-1 4-3 3-5-.5-3L21 7l-2-3zM12 17l-2.5-2 .5-3-2-1.5 2-1.5L11 7l3-1 3 1-.5 2 2 1.5-2 1.5.5 3L14.5 17 12 17z"/></svg>', 1396 google_pse:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21.35 11.1H12v3.2h5.35c-.5 2.4-2.55 4-5.35 4-3.25 0-5.9-2.65-5.9-5.9s2.65-5.9 5.9-5.9c1.55 0 2.95.55 4.05 1.55l2.4-2.4C16.85 4.05 14.55 3 12 3 7 3 3 7 3 12s4 9 9 9c5.2 0 8.65-3.65 8.65-8.8 0-.4-.05-.7-.3-1.1z"/></svg>', 1397 tavily: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 8.5l4 2.5v6l6 3.5 6-3.5v-6l4-2.5L12 2zm-4 9.5L12 14l4-2.5V16l-4 2.5L8 16v-4.5z"/></svg>', 1398 serper: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 4a7 7 0 1 0 4.2 12.6l4.5 4.5 1.4-1.4-4.5-4.5A7 7 0 0 0 11 4zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm-1 2v2H8v2h2v2h2v-2h2V10h-2V8h-2z"/></svg>', 1399 disabled: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><line x1="6" y1="6" x2="18" y2="18"/></svg>', 1400}; 1401 1402/* ââ Deep Research Model (AI tab) ââ */ 1403async function initResearchSettings() { 1404 var epSel = el('set-researchEndpoint'); 1405 var modelSel = el('set-researchModel'); 1406 var tokensInput = el('set-researchMaxTokens'); 1407 var extractTimeoutInput = el('set-researchExtractTimeout'); 1408 var extractConcurrencyInput = el('set-researchExtractConcurrency'); 1409 var runTimeoutInput = el('set-researchRunTimeout'); 1410 var msg = el('set-researchMsg'); 1411 var endpoints = []; 1412 1413 try { 1414 endpoints = await _fetchModelEndpoints(); 1415 _fillEndpointSelect(epSel, endpoints, epSel.value, true); 1416 } catch (e) { console.warn('Failed to load endpoints for research', e); } 1417 1418 function refreshModels(selectedModel) { 1419 var epId = epSel.value; 1420 var ep = endpoints.find(function(e) { return e.id === epId; }); 1421 _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true); 1422 } 1423 1424 try { 1425 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1426 var settings = await res.json(); 1427 if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id; 1428 refreshModels(settings.research_model || ''); 1429 if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens; 1430 if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds; 1431 if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency; 1432 if (settings.research_run_timeout_seconds !== undefined && settings.research_run_timeout_seconds !== null) { 1433 runTimeoutInput.value = settings.research_run_timeout_seconds; 1434 } 1435 } catch (e) { console.warn('Failed to load research settings', e); } 1436 1437 function showStatus() { 1438 var parts = []; 1439 if (epSel.value) { 1440 var epName = epSel.options[epSel.selectedIndex].textContent; 1441 var mName = modelSel.value ? modelSel.value.split('/').pop() : 'auto'; 1442 parts.push(epName + ' / ' + mName); 1443 } 1444 if (tokensInput.value) { 1445 parts.push('Max tokens: ' + tokensInput.value); 1446 } 1447 if (extractTimeoutInput.value) { 1448 parts.push('Extract: ' + extractTimeoutInput.value + 's'); 1449 } 1450 if (extractConcurrencyInput.value) { 1451 parts.push('Parallel: ' + extractConcurrencyInput.value); 1452 } 1453 if (runTimeoutInput.value !== '') { 1454 var rtv = parseInt(runTimeoutInput.value, 10); 1455 if (!isNaN(rtv)) { 1456 parts.push(rtv === 0 ? 'Max time: no limit' : 'Max time: ' + rtv + 's'); 1457 } 1458 } 1459 if (parts.length) { 1460 msg.textContent = parts.join(' · '); 1461 msg.style.color = 'var(--fg)'; 1462 } else { 1463 msg.textContent = 'Using chat defaults'; 1464 msg.style.color = 'var(--fg)'; 1465 } 1466 } 1467 showStatus(); 1468 1469 async function saveResearch() { 1470 var payload = { 1471 research_endpoint_id: epSel.value, 1472 research_model: modelSel.value, 1473 }; 1474 var tv = parseInt(tokensInput.value, 10); 1475 if (tv && tv >= 1024) payload.research_max_tokens = tv; 1476 var et = parseInt(extractTimeoutInput.value, 10); 1477 if (et && et >
1477= 15 && et <= 3600) payload.research_extraction_timeout_seconds = et; 1478 var ec = parseInt(extractConcurrencyInput.value, 10); 1479 if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec; 1480 if (runTimeoutInput.value !== '') { 1481 var rt = parseInt(runTimeoutInput.value, 10); 1482 // 0 = no limit (disables the hard timeout); otherwise 60s..86400s (24h) 1483 if (!isNaN(rt) && (rt === 0 || (rt >= 60 && rt <= 86400))) { 1484 payload.research_run_timeout_seconds = rt; 1485 } 1486 } 1487 try { 1488 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 1489 headers: { 'Content-Type': 'application/json' }, 1490 body: JSON.stringify(payload) 1491 }); 1492 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; 1493 setTimeout(showStatus, 2000); 1494 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 1495 } 1496 1497 epSel.addEventListener('change', async function() { 1498 refreshModels(''); 1499 saveResearch(); 1500 }); 1501 modelSel.addEventListener('change', saveResearch); 1502 tokensInput.addEventListener('change', saveResearch); 1503 extractTimeoutInput.addEventListener('change', saveResearch); 1504 extractConcurrencyInput.addEventListener('change', saveResearch); 1505 runTimeoutInput.addEventListener('change', saveResearch); 1506 1507 _registerAiEndpointRefresh(function(nextEndpoints) { 1508 endpoints = nextEndpoints; 1509 _fillEndpointSelect(epSel, endpoints, epSel.value, true); 1510 refreshModels(modelSel.value); 1511 }); 1512} 1513 1514/* ââ Deep Research Search (Search tab) ââ */ 1515async function initResearchSearchSettings() { 1516 var searchSel = el('set-researchSearch'); 1517 var msg = el('set-researchSearchMsg'); 1518 1519 function updateSearchOptions(settings) { 1520 var options = searchSel.querySelectorAll('option'); 1521 options.forEach(function(opt) { 1522 var prov = opt.value; 1523 if (!prov) return; 1524 var kf = _searchKeyFields[prov]; 1525 if (!kf) return; 1526 var hasKey = ((settings[kf] || '').trim() || (settings.search_api_key || '').trim()); 1527 if (!hasKey) { 1528 opt.textContent = (_searchLabels[prov] || prov) + ' (no key)'; 1529 opt.style.color = 'var(--red)'; 1530 } else { 1531 opt.textContent = _searchLabels[prov] || prov; 1532 opt.style.color = ''; 1533 } 1534 }); 1535 } 1536 1537 try { 1538 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1539 var settings = await res.json(); 1540 if (settings.research_search_provider) searchSel.value = settings.research_search_provider; 1541 updateSearchOptions(settings); 1542 } catch (e) { console.warn('Failed to load research search settings', e); } 1543 1544 async function saveResearchSearch() { 1545 try { 1546 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 1547 headers: { 'Content-Type': 'application/json' }, 1548 body: JSON.stringify({ research_search_provider: searchSel.value }) 1549 }); 1550 msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; 1551 setTimeout(function() { msg.textContent = ''; }, 2000); 1552 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 1553 } 1554 1555 searchSel.addEventListener('change', saveResearchSearch); 1556} 1557 1558/* ââ Agent Settings (AI tab) ââ */ 1559async function initAgentSettings() { 1560 var toolsInput = el('set-agentMaxTools'); 1561 var roundsInput = el('set-agentMaxRounds'); 1562 var msg = el('set-agentMsg'); 1563 if (!toolsInput) return; 1564 1565 try { 1566 var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1567 var settings = await res.json(); 1568 if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls; 1569 if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds; 1570 } catch (e) {} 1571 1572 // Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt` 1573 // when blank/non-numeric. Mirrors the server-side validation. 1574 function clampInt(raw, lo, hi, dflt) { 1575 var n = parseInt(raw, 10); 1576 if (isNaN(n)) return dflt; 1577 return Math.max(lo, Math.min(n, hi)); 1578 } 1579 1580 async function save() { 1581 var tools = clampInt(toolsInput.value, 0, 1000, 0); 1582 var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null; 1583 toolsInput.value = tools; // reflect the clamped value 1584 if (roundsInput) roundsInput.value = rounds; 1585 var payload = { agent_max_tool_calls: tools }; 1586 if (rounds != null) payload.agent_max_rounds = rounds; 1587 try { 1588 await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', 1589 headers: { 'Content-Type': 'application/json' }, 1590 body: JSON.stringify(payload) 1591 }); 1592 msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') + 1593 (rounds != null ? ' · ' + rounds + ' steps/message' : ''); 1594 msg.style.color = 'var(--fg)'; 1595 } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } 1596 } 1597 1598 toolsInput.addEventListener('change', save); 1599 if (roundsInput) roundsInput.addEventListener('change', save); 1600 var cur = parseInt(toolsInput.value, 10) || 0; 1601 var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null; 1602 msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') + 1603 (curR != null ? ' · ' + curR + ' steps/message' : ''); 1604} 1605 1606/* âââââââââââââââââââââââââââââââââââââââââââ 1607 APPEARANCE TAB 1608 âââââââââââââââââââââââââââââââââââââââââââ */ 1609function initAppearance() { 1610 syncAppearanceCheckboxes(); 1611 syncPrivacyCheckboxes(); 1612 1613 modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) { 1614 chk.addEventListener('change', async function() { 1615 var key = chk.dataset.uiKey; 1616 1617 if (window.UI_VIS_ADMIN_ONLY && window.UI_VIS_ADMIN_ONLY.has(key) && !chk.checked && !window._isAdmin) { 1618 chk.checked = true;
1619 if (uiModule && uiModule.showToast) { 1620 uiModule.showToast('Only admins can hide Settings.'); 1621 } 1622 return; 1623 } 1624 1625 // Hiding the Settings cog removes the only visible way to re-open this 1626 // panel. Warn the user and remind them about the `/settings` slash 1627 // command so they don't lock themselves out. 1628 if (key === 'sidebar-settings-btn' && !chk.checked) { 1629 var ok = true; 1630 try { 1631 ok = await (uiModule && uiModule.styledConfirm 1632 ? uiModule.styledConfirm( 1633 'Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.', 1634 { confirmText: 'Hide', cancelText: 'Cancel' } 1635 ) 1636 : Promise.resolve(window.confirm('Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.'))); 1637 } catch (_) { ok = false; } 1638 if (!ok) { 1639 chk.checked = true; 1640 return; 1641 } 1642 if (uiModule && uiModule.showToast) { 1643 uiModule.showToast('Settings cog hidden â type /settings to bring it back.', 5000); 1644 } 1645 } 1646 1647 var s = window.loadUIVis(); 1648 s[key] = chk.checked; 1649 window.saveUIVis(s); 1650 window.applyUIVis(s); 1651 }); 1652 }); 1653 1654 modalEl.querySelectorAll('[data-privacy-key]').forEach(function(chk) { 1655 chk.addEventListener('change', function() { 1656 if (chk.dataset.privacyKey !== 'sensitive-blur') return; 1657 localStorage.setItem('odysseus-sensitive-blur', chk.checked ? 'on' : 'off'); 1658 window.dispatchEvent(new CustomEvent('odysseus-sensitive-blur-change', { 1659 detail: { enabled: chk.checked } 1660 })); 1661 }); 1662 }); 1663 1664 var resetBtn = el('set-uiVisResetBtn'); 1665 if (resetBtn) { 1666 resetBtn.addEventListener('click', function() { 1667 localStorage.removeItem('odysseus-ui-visibility'); 1668 syncAppearanceCheckboxes(); 1669 syncPrivacyCheckboxes(); 1670 window.applyUIVis({}); 1671 }); 1672 } 1673} 1674 1675function syncAppearanceCheckboxes() { 1676 var s = window.loadUIVis ? window.loadUIVis() : {}; 1677 var defaultOff = window.UI_VIS_DEFAULT_OFF || new Set(); 1678 modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) { 1679 var key = chk.dataset.uiKey; 1680 chk.checked = key in s ? s[key] !== false : !defaultOff.has(key); 1681 }); 1682} 1683 1684function syncPrivacyCheckboxes() { 1685 modalEl.querySelectorAll('[data-privacy-key="sensitive-blur"]').forEach(function(chk) { 1686 chk.checked = localStorage.getItem('odysseus-sensitive-blur') === 'on'; 1687 }); 1688} 1689 1690/* âââââââââââââââââââââââââââââââââââââââââââ 1691 SHORTCUTS TAB 1692 âââââââââââââââââââââââââââââââââââââââââââ */ 1693 1694const SHORTCUT_DEFAULTS = { 1695 search: 'ctrl+k', 1696 toggle_sidebar: 'ctrl+b', 1697 new_session: 'ctrl+alt+n', 1698 fav_session: 'ctrl+alt+f', 1699 delete_session: 'ctrl+alt+d', 1700 cancel: 'escape', 1701 tts: 'alt+shift+t', 1702 incognito: 'ctrl+alt+i', 1703 settings: 'ctrl+,', 1704 focus_input: 'ctrl+/', 1705 // Open-tool shortcuts. Calendar is bound by default; the rest are 1706 // unbound (empty) so the user can assign their own in the panel. 1707 open_calendar: 'ctrl+alt+c', 1708 open_compare: '', 1709 open_cookbook: '', 1710 open_research: '', 1711 open_gallery: '', 1712 open_library: '', 1713 open_memory: '', 1714 open_notes: '', 1715 open_tasks: '', 1716 open_theme: '', 1717}; 1718 1719const SHORTCUT_ICONS = { 1720 search: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="10" cy="10" r="7"/><path d="M21 21l-4.35-4.35"/></svg>', 1721 toggle_sidebar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>', 1722 new_session: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>', 1723 fav_session: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>', 1724 delete_session: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>', 1725 cancel: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>', 1726 tts: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>', 1727 incognito: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/>
1727<line x1="8" y1="8" x2="16" y2="16"/></svg>', 1728 settings: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>', 1729 focus_input: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>', 1730 open_calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>', 1731 open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>',
1732 open_cookbook: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>', 1733 open_research: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>', 1734 open_gallery: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>', 1735 open_library: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', 1736 open_memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>', 1737 open_notes: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>', 1738 open_tasks: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>', 1739 open_theme: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20 5 5 0 0 0 5-5 3 3 0 0 0-3-3h-2a3 3 0 0 1-3-3 5 5 0 0 1 5-5"/></svg>', 1740}; 1741 1742const SHORTCUT_LABELS = { 1743 search: 'Search conversations', 1744 toggle_sidebar: 'Toggle sidebar', 1745 new_session: 'New session', 1746 fav_session: 'Favorite session', 1747 delete_session: 'Delete session', 1748 cancel: 'Cancel / close', 1749 tts: 'Play/stop TTS', 1750 incognito: 'Toggle incognito', 1751 settings: 'Toggle Window', 1752 focus_input: 'Focus chat input', 1753 open_calendar: 'Open Calendar', 1754 open_compare: 'Open Compare', 1755 open_cookbook: 'Open Cookbook', 1756 open_research: 'Open Deep Research', 1757 open_gallery: 'Open Gallery', 1758 open_library: 'Open Library', 1759 open_memory: 'Open Memory', 1760 open_notes: 'Open Notes', 1761 open_tasks: 'Open Tasks', 1762 open_theme: 'Open Theme', 1763}; 1764 1765const SHORTCUT_CATEGORIES = [ 1766 { name: 'Navigation', keys: ['search', 'toggle_sidebar', 'focus_input', 'settings'] }, 1767 { name: 'Sessions', keys: ['new_session', 'fav_session', 'delete_session'] }, 1768 { name: 'Tools', keys: ['incognito', 'tts', 'cancel'] }, 1769 { name: 'Open Tools', keys: ['open_calendar', 'open_compare', 'open_cookbook', 'open_research', 'open_gallery', 'open_library', 'open_memory', 'open_notes', 'open_tasks', 'open_theme'] }, 1770]; 1771 1772function _formatKeyCaps(combo) { 1773 return combo.split('+').map(p => { 1774 let label; 1775 if (p === 'ctrl') label = 'Ctrl'; 1776 else if (p === 'alt') label = 'Alt'; 1777 else if (p === 'shift') label = 'Shift'; 1778 else if (p === 'meta') label = 'Cmd'; 1779 else if (p === 'escape') label = 'Esc'; 1780 else if (p === ',') label = ','; 1781 else if (p === '/') label = '/'; 1782 else if (p === 'space') label = 'Space'; 1783 else label = p.charAt(0).toUpperCase() + p.slice(1); 1784 return `<kbd>${label}</kbd>`; 1785 }).join(''); 1786} 1787 1788function _comboFromEvent(e) { 1789 // Drop a stray AltGr keystroke (e.g. AltGr+E to type â¬) so it isn't recorded 1790 // as a bogus ctrl+alt+<char> binding â onKey ignores empty combos. See 1791 // platform.js for the macOS carve-out and Windows trade-off. 1792 if (isAltGrEvent(e)) return ''; 1793 const parts = []; 1794 if (e.ctrlKey || e.metaKey) parts.push('ctrl'); 1795 if (e.altKey) parts.push('alt'); 1796 if (e.shiftKey) parts.push('shift'); 1797 const key = e.key.toLowerCase(); 1798 if (!['control', 'alt', 'shift', 'meta'].includes(key)) { 1799 parts.push(key === ' ' ? 'space' : key); 1800 } 1801 return parts.join('+'); 1802} 1803 1804async function initShortcuts() { 1805 const listEl = el('shortcuts-list'); 1806 const resetBtn = el('shortcuts-reset-btn'); 1807 if (!listEl) return; 1808 1809 // Load saved keybinds 1810 let keybinds = { ...SHORTCUT_DEFAULTS }; 1811 try { 1812 const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 1813 const settings = await res.json(); 1814 if (settings.keybinds) keybinds = { ...keybinds, ...settings.keybinds }; 1815 } catch (e) {} 1816 1817 function _findConflicts() { 1818 const comboMap = {}; 1819 for (const [action, combo] of Object.entries(keybinds)) { 1820 if (!comboMap[combo]) comboMap[combo] = []; 1821 comboMap[combo].push(action); 1822 } 1823 const conflicts = new Set(); 1824 for (const actions of Object.values(comboMap)) { 1825 if (actions.length > 1) actions.forEach(a => conflicts.add(a)); 1826 } 1827 return conflicts; 1828 } 1829 1830 function render() { 1831 listEl.innerHTML = ''; 1832 const conflicts = _findConflicts(); 1833 1834 for (const cat of SHORTCUT_CATEGORIES) { 1835 const catHeader = document.createElement('div'); 1836 catHeader.className = 'shortcut-category'; 1837 catHeader.textContent = cat.name; 1838 listEl.appendChild(catHeader); 1839 1840 for (const action of cat.keys) { 1841 if (!(action in keybinds)) continue; 1842 const combo = keybinds[action];
1843 // Unbound shortcuts (empty combo) still render so the user can 1844 // assign one \u2014 they show a "Set" affordance instead of keycaps. 1845 const label = SHORTCUT_LABELS[action] || action; 1846 const icon = SHORTCUT_ICONS[action] || ''; 1847 const isCustom = combo !== (SHORTCUT_DEFAULTS[action] || ''); 1848 const hasConflict = combo && conflicts.has(action); 1849 const row = document.createElement('div'); 1850 row.className = 'shortcut-row' + (hasConflict ? ' shortcut-conflict' : ''); 1851 row.dataset.action = action; 1852 const keyContent = combo ? _formatKeyCaps(combo) : '<span class="shortcut-unset">Set</span>'; 1853 row.innerHTML = ` 1854 <span class="shortcut-label"><span class="shortcut-icon">${icon}</span>${esc(label)}${hasConflict ? '<span class="shortcut-warn" title="Duplicate shortcut">!</span>' : ''}</span> 1855 <div class="shortcut-controls"> 1856 <span class="shortcut-hint" hidden></span> 1857 <button class="shortcut-key${combo ? '' : ' shortcut-key-unset'}" data-action="${action}" title="Click to rebind">${keyContent}</button> 1858 <button class="shortcut-action-btn ${isCustom ? 'is-reset' : ''}" data-action="${action}" title="${isCustom ? 'Reset to default' : 'Confirm'}" style="${isCustom ? '' : 'visibility:hidden'}"> 1859 ${isCustom ? '\u21A9' : '\u2713'} 1860 </button> 1861 </div> 1862 `; 1863 listEl.appendChild(row); 1864 } 1865 } 1866 1867 listEl.querySelectorAll('.shortcut-key').forEach(btn => { 1868 btn.addEventListener('click', () => startRebind(btn)); 1869 }); 1870 1871 listEl.querySelectorAll('.shortcut-action-btn.is-reset').forEach(btn => { 1872 btn.addEventListener('click', () => { 1873 const action = btn.dataset.action; 1874 keybinds[action] = SHORTCUT_DEFAULTS[action]; 1875 saveKeybinds(); 1876 render(); 1877 }); 1878 }); 1879 } 1880 1881 function startRebind(btn) { 1882 const action = btn.dataset.action; 1883 const row = btn.closest('.shortcut-row'); 1884 const actionBtn = row.querySelector('.shortcut-action-btn'); 1885 const hintEl = row.querySelector('.shortcut-hint'); 1886 1887 // Remove any other active rebind 1888 listEl.querySelectorAll('.shortcut-key.listening').forEach(b => { 1889 b.classList.remove('listening'); 1890 b.innerHTML = _formatKeyCaps(keybinds[b.dataset.action]); 1891 const otherRow = b.closest('.shortcut-row'); 1892 const otherAction = otherRow.querySelector('.shortcut-action-btn'); 1893 if (otherAction && !otherAction.classList.contains('is-reset')) otherAction.style.visibility = 'hidden'; 1894 }); 1895 1896 btn.classList.add('listening'); 1897 btn.textContent = 'Press keys...'; 1898 // Show confirm button 1899 actionBtn.textContent = '\u2713'; 1900 actionBtn.classList.remove('is-reset'); 1901 actionBtn.style.visibility = 'visible'; 1902 actionBtn.title = 'Confirm'; 1903 // Hint: tell the user how to commit / cancel the rebind. 1904 if (hintEl) { 1905 hintEl.hidden = false; 1906 hintEl.textContent = 'press a key'; 1907 } 1908 1909 let pendingCombo = null; 1910 1911 // Wire confirm button 1912 const confirmHandler = () => { 1913 if (pendingCombo) { 1914 keybinds[action] = pendingCombo; 1915 saveKeybinds(); 1916 } 1917 cleanup(); 1918 render(); 1919 }; 1920 actionBtn.addEventListener('click', confirmHandler, { once: true }); 1921 1922 function onKey(e) { 1923 e.preventDefault(); 1924 e.stopPropagation(); 1925 1926 if (e.key === 'Escape') { 1927 cleanup(); 1928 btn.innerHTML = _formatKeyCaps(keybinds[action]); 1929 const isCustom = keybinds[action] !== SHORTCUT_DEFAULTS[action]; 1930 if (isCustom) { 1931 actionBtn.textContent = '\u21A9'; 1932 actionBtn.classList.add('is-reset'); 1933 actionBtn.title = 'Reset to default'; 1934 } else { 1935 actionBtn.style.visibility = 'hidden'; 1936 } 1937 return; 1938 } 1939 1940 // Enter commits the previewed combo (same as clicking \u2713). Only acts 1941 // as commit once a combo has been captured \u2014 otherwise it would just 1942 // try to bind Enter itself. 1943 if (e.key === 'Enter' && pendingCombo) { 1944 confirmHandler(); 1945 return; 1946 } 1947 1948 const combo = _comboFromEvent(e); 1949 if (!combo || combo === 'ctrl' || combo === 'alt' || combo === 'shift' || combo === 'ctrl+alt' || combo === 'ctrl+shift' || combo === 'alt+shift' || combo === 'ctrl+alt+shift') return; 1950 1951 // Preview the combo, wait for confirm 1952 pendingCombo = combo; 1953 btn.innerHTML = _formatKeyCaps(combo); 1954 // Now that a combo is captured, prompt to commit with Enter. 1955 if (hintEl) hintEl.textContent = '\u21B5 Enter to save'; 1956 } 1957 1958 function cleanup() { 1959 btn.classList.remove('listening'); 1960 if (hintEl) { hintEl.hidden = true; hintEl.textContent = ''; } 1961 document.removeEventListener('keydown', onKey, true); 1962 actionBtn.removeEventListener('click', confirmHandler); 1963 } 1964 1965 document.addEventListener('keydown', onKey, true); 1966 } 1967 1968 async function saveKeybinds() { 1969 try { 1970 await fetch('/api/auth/settings', { 1971 method: 'POST', credentials: 'same-origin', 1972 headers: { 'Content-Type': 'application/json' }, 1973 body: JSON.stringify({ keybinds }), 1974 }); 1975 // Update global keybinds so they take effect immediately 1976 window._odysseusKeybinds = keybinds;
1977 if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved'); 1978 } catch (e) { 1979 console.error('Failed to save keybinds:', e); 1980 } 1981 } 1982 1983 if (resetBtn) { 1984 resetBtn.addEventListener('click', async () => { 1985 keybinds = { ...SHORTCUT_DEFAULTS }; 1986 render(); 1987 await saveKeybinds(); 1988 if (uiModule && uiModule.showToast) uiModule.showToast('Shortcuts reset to defaults'); 1989 }); 1990 } 1991 1992 render(); 1993} 1994 1995/* âââââââââââââââââââââââââââââââââââââââââââ 1996 INIT & REFRESH 1997 âââââââââââââââââââââââââââââââââââââââââââ */ 1998function initAccount() { 1999 // Populate user info 2000 fetch('/api/auth/status', { credentials: 'same-origin' }) 2001 .then(r => r.json()) 2002 .then(d => { 2003 const nameEl = el('settings-account-username'); 2004 const roleEl = el('settings-account-role'); 2005 const avatarEl = el('settings-account-avatar'); 2006 if (nameEl) nameEl.textContent = d.username || 'Unknown'; 2007 if (roleEl) roleEl.textContent = d.is_admin ? 'Admin' : 'User'; 2008 if (avatarEl) { 2009 const initial = (d.username || '?')[0].toUpperCase(); 2010 avatarEl.textContent = initial; 2011 } 2012 }).catch(() => {}); 2013 2014 // Change password 2015 const saveBtn = el('settings-pw-save'); 2016 const msgEl = el('settings-pw-msg'); 2017 if (saveBtn) { 2018 saveBtn.addEventListener('click', async () => { 2019 const cur = el('settings-pw-current').value; 2020 const nw = el('settings-pw-new').value; 2021 const conf = el('settings-pw-confirm').value; 2022 msgEl.style.color = ''; 2023 if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; } 2024 if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; } 2025 if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; } 2026 saveBtn.disabled = true; 2027 try { 2028 const res = await fetch('/api/auth/change-password', { 2029 method: 'POST', credentials: 'same-origin', 2030 headers: { 'Content-Type': 'application/json' }, 2031 body: JSON.stringify({ current_password: cur, new_password: nw }) 2032 }); 2033 if (!res.ok) { const d = await res.json(); throw new Error(d.detail || 'Failed'); } 2034 msgEl.style.color = 'var(--green)'; 2035 msgEl.textContent = 'Password updated'; 2036 el('settings-pw-current').value = ''; 2037 el('settings-pw-new').value = ''; 2038 el('settings-pw-confirm').value = ''; 2039 } catch (e) { 2040 msgEl.style.color = 'var(--red)'; 2041 msgEl.textContent = e.message; 2042 } finally { 2043 saveBtn.disabled = false; 2044 } 2045 }); 2046 } 2047 2048 // ââ Two-Factor Authentication ââ 2049 const tfaContent = el('settings-2fa-content'); 2050 if (tfaContent) { 2051 async function render2FA() { 2052 try { 2053 const res = await fetch('/api/auth/2fa/status', { credentials: 'same-origin' }); 2054 const data = await res.json(); 2055 if (data.enabled) { 2056 // 2FA is ON â show disable option 2057 tfaContent.innerHTML = ` 2058 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;"> 2059 <span style="color:var(--color-save-green, #4caf50);font-size:12px;font-weight:600;">✓ Enabled</span> 2060 <span style="font-size:11px;opacity:0.5;">Authenticator app required on login</span> 2061 </div> 2062 <input id="tfa-disable-pw" type="password" placeholder="Enter password to disable" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;width:100%;box-sizing:border-box;margin-bottom:6px;"> 2063 <div class="settings-row" style="justify-content:flex-end;"> 2064 <span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span> 2065 <button class="admin-btn-add" id="tfa-disable-btn" style="opacity:0.7;">Disable 2FA</button> 2066 </div>`; 2067 el('tfa-disable-btn').addEventListener('click', async () => { 2068 const pw = el('tfa-disable-pw').value; 2069 const msg = el('tfa-msg'); 2070 if (!pw) { msg.textContent = 'Enter your password'; msg.style.color = 'var(--red)'; return; } 2071 try { 2072 const r = await fetch('/api/auth/2fa/disable', { 2073 method: 'POST', credentials: 'same-origin', 2074 headers: { 'Content-Type': 'application/json' }, 2075 body: JSON.stringify({ password: pw }) 2076 }); 2077 if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); } 2078 render2FA(); 2079 } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; } 2080 }); 2081 } else { 2082 // 2FA is OFF â show setup button 2083 tfaContent.innerHTML = ` 2084 <div style="font-size:12px;opacity:0.6;margin-bottom:8px;">Add an extra layer of security with an authenticator app (Aegis, Google Authenticator, etc.)</div> 2085 <div class="settings-row" style="justify-content:flex-end;">
2086 <span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span> 2087 <button class="admin-btn-add" id="tfa-setup-btn">Set Up 2FA</button> 2088 </div>`; 2089 el('tfa-setup-btn').addEventListener('click', async () => { 2090 const msg = el('tfa-msg'); 2091 try { 2092 const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' }); 2093 if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); } 2094 const setup = await r.json(); 2095 const qrCode = safeRasterDataUrl(setup.qr_code); 2096 // Show QR code + manual secret + verify input 2097 tfaContent.innerHTML = ` 2098 <div style="text-align:center;margin-bottom:12px;"> 2099 ${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''} 2100 </div> 2101 <div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;"> 2102 Scan with your authenticator app, or enter manually: 2103 </div> 2104 <div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${esc(setup.secret)}</div> 2105 <input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;"> 2106 <div class="settings-row" style="justify-content:flex-end;"> 2107 <span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span> 2108 <button class="admin-btn-add" id="tfa-cancel-btn" style="opacity:0.5;">Cancel</button> 2109 <button class="admin-btn-add" id="tfa-verify-btn">Verify & Enable</button> 2110 </div>`; 2111 el('tfa-verify-code').focus(); 2112 el('tfa-cancel-btn').addEventListener('click', () => render2FA()); 2113 el('tfa-verify-btn').addEventListener('click', async () => { 2114 const code = el('tfa-verify-code').value.trim(); 2115 const vmsg = el('tfa-msg'); 2116 if (!code) { vmsg.textContent = 'Enter the code'; vmsg.style.color = 'var(--red)'; return; } 2117 try { 2118 const vr = await fetch('/api/auth/2fa/confirm', { 2119 method: 'POST', credentials: 'same-origin', 2120 headers: { 'Content-Type': 'application/json' }, 2121 body: JSON.stringify({ code }) 2122 }); 2123 if (!vr.ok) { const d = await vr.json(); throw new Error(d.detail || 'Invalid code'); } 2124 const result = await vr.json(); 2125 // Show backup codes 2126 const codes = result.backup_codes || []; 2127 tfaContent.innerHTML = ` 2128 <div style="color:var(--color-save-green, #4caf50);font-size:13px;font-weight:600;margin-bottom:8px;">✓ 2FA Enabled!</div> 2129 <div style="font-size:12px;opacity:0.7;margin-bottom:8px;">Save these backup codes somewhere safe. Each can be used once if you lose your authenticator:</div> 2130 <div style="font-family:monospace;font-size:12px;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;columns:2;column-gap:16px;margin-bottom:8px;">${codes.map(c => '<div style="margin-bottom:2px;">' + c + '</div>').join('')}</div> 2131 <button class="admin-btn-add" id="tfa-done-btn">Done</button>`; 2132 el('tfa-done-btn').addEventListener('click', () => render2FA()); 2133 } catch (e) { vmsg.textContent = e.message; vmsg.style.color = 'var(--red)'; } 2134 }); 2135 } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; } 2136 }); 2137 } 2138 } catch (_) { 2139 tfaContent.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load 2FA status</div>'; 2140 } 2141 } 2142 render2FA(); 2143 } 2144 2145 // Logout 2146 const logoutBtn = el('settings-logout-btn'); 2147 if (logoutBtn) { 2148 logoutBtn.addEventListener('mouseenter', () => { logoutBtn.style.opacity = '1'; logoutBtn.style.borderColor = 'var(--red)'; logoutBtn.style.color = 'var(--red)'; }); 2149 logoutBtn.addEventListener('mouseleave', () => { logoutBtn.style.opacity = ''; logoutBtn.style.borderColor = ''; logoutBtn.style.color = ''; }); 2150 logoutBtn.addEventListener('click', async () => {
2151 try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (_) {} 2152 // SECURITY: wipe all client-side state on logout so the next user that 2153 // signs in on this browser doesn't inherit the previous account's 2154 // session id, last-used model, draft chat input, or any cached lists. 2155 // Keep "odysseus-last-user" so the login form remembers the username 2156 // (if "Remember me" was on). Without this the chat composer pre-loaded 2157 // the previous user's last model into a fresh session, which read as 2158 // cross-account leakage. 2159 try { 2160 const _keepKeys = new Set(['odysseus-last-user']); 2161 const _toRemove = []; 2162 for (let i = 0; i < localStorage.length; i++) { 2163 const k = localStorage.key(i); 2164 if (k && !_keepKeys.has(k)) _toRemove.push(k); 2165 } 2166 _toRemove.forEach(k => localStorage.removeItem(k)); 2167 sessionStorage.clear(); 2168 } catch (_) {} 2169 window.location.href = '/login'; 2170 }); 2171 } 2172} 2173 2174function initAll() { 2175 modalEl = el('settings-modal'); 2176 initTabs(); 2177 initDrag(); 2178 initClose(); 2179 initOpacityToggle(); 2180 initialized = true; 2181 initDefaultChat(); 2182 initTeacherModel(); 2183 initUtilityModel(); 2184 initImageSettings(); 2185 initVisionSettings(); 2186 initTtsSettings(); 2187 initSttSettings(); 2188 initSearchSettings(); 2189 initResearchSettings(); 2190 initResearchSearchSettings(); 2191 initAgentSettings(); 2192 initAppearance(); 2193 initShortcuts(); 2194 initAccount(); 2195 initIntegrations(); 2196 initEmailSettings(); 2197 initEmailAccountsSettings(); 2198 initReminderSettings(); 2199 initUnifiedIntegrations(); 2200 // HF Cloud Storage bucket management 2201 if (window.initBucketSettings) window.initBucketSettings(); 2202 // HF Secrets & Environment management 2203 if (window.initHfSecrets) window.initHfSecrets(); 2204 if (window.initGithubRepos) window.initGithubRepos(); 2205} 2206 2207function notifyIntegrationsChanged() { 2208 try { 2209 window.dispatchEvent(new CustomEvent('odysseus-integrations-changed')); 2210 } catch (_) {} 2211} 2212 2213async function initReminderSettings() { 2214 const root = el('settings-modal'); 2215 if (!root || !root.querySelector('[data-settings-panel="reminders"]')) return; 2216 2217 // Public URL field (used for deep-links in outgoing alert emails) 2218 const pubUrlIn = el('set-app-public-url'); 2219 const pubUrlMsg = el('set-app-public-url-msg'); 2220 if (pubUrlIn) { 2221 try { 2222 const r = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 2223 const s = await r.json(); 2224 pubUrlIn.value = s.app_public_url || ''; 2225 } catch (_) {} 2226 let pubDebounce; 2227 pubUrlIn.addEventListener('input', () => { 2228 clearTimeout(pubDebounce); 2229 pubDebounce = setTimeout(async () => { 2230 try { 2231 const val = pubUrlIn.value.trim().replace(/\/+$/, ''); 2232 await fetch('/api/auth/settings', { 2233 method: 'POST', credentials: 'same-origin', 2234 headers: { 'Content-Type': 'application/json' }, 2235 body: JSON.stringify({ app_public_url: val }), 2236 }); 2237 if (pubUrlMsg) { 2238 pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)'; 2239 pubUrlMsg.style.color = 'var(--green,#50fa7b)'; 2240 setTimeout(() => { pubUrlMsg.textContent = ''; }, 2000); 2241 } 2242 } catch (_) { 2243 if (pubUrlMsg) { pubUrlMsg.textContent = 'Save failed'; pubUrlMsg.style.color = 'var(--red)'; } 2244 } 2245 }, 600); 2246 }); 2247 } 2248 2249 const channelSel = el('set-reminder-channel'); 2250 const emailOpt = el('set-reminder-channel-email-opt'); 2251 const ntfyOpt = el('set-reminder-channel-ntfy-opt'); 2252 const webhookOpt = el('set-reminder-channel-webhook-opt'); 2253 const hint = el('set-reminder-channel-hint'); 2254 const llmToggle = el('set-reminder-llm-toggle'); 2255 // "Integrations" link in the channel-hint copy. Jumps to the 2256 // Integrations tab so the user can configure the underlying accounts 2257 // (email, ntfy server) the channel dropdown depends on. Idempotent. 2258 const openIntgBtn = el('set-reminders-open-integrations'); 2259 if (openIntgBtn && !openIntgBtn.dataset.wired) { 2260 openIntgBtn.dataset.wired = '1'; 2261 openIntgBtn.addEventListener('click', (e) => { 2262 e.preventDefault(); 2263 const target = modalEl?.querySelector('[data-settings-tab="integrations"]'); 2264 if (target) target.click(); 2265 }); 2266 } 2267 if (!channelSel || !llmToggle) return; 2268 2269 // Detect configured email accounts. The legacy single-account 2270 // `/api/email/config` endpoint was a no-op stub for most installs; 2271 // the real per-account list lives at `/api/email/accounts` and is 2272 // what the Integrations panel manages. Treat the email channel as 2273 // configured if there's at least one account with SMTP set. 2274 let emailAccounts = []; 2275 try { 2276 const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); 2277 if (res.ok) { 2278 const d = await res.json(); 2279 emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password); 2280 } 2281 } catch (_) {} 2282 let smtpConfigured = emailAccounts.length > 0; 2283 2284 if (!smtpConfigured && emailOpt) { 2285 emailOpt.disabled = true; 2286 emailOpt.textContent = 'Email (add an account in Integrations)'; 2287 } 2288 2289 // Detect whether ntfy integration exists â try admin endpoint, fall back to 2290 // checking if an ntfy integration was saved in settings (non-admin users). 2291 let ntfyConfigured = false;
2292 try { 2293 const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); 2294 if (res.ok) { 2295 const data = await res.json(); 2296 ntfyConfigured = (data.integrations || []).some( 2297 i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url 2298 ); 2299 } 2300 } catch (_) {} 2301 // If admin check failed, check if ntfy was previously selected (trust the saved setting) 2302 if (!ntfyConfigured) { 2303 try { 2304 const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 2305 const s = await res.json(); 2306 if (s.reminder_channel === 'ntfy') ntfyConfigured = true; 2307 } catch (_) {} 2308 } 2309 2310 if (!ntfyConfigured && ntfyOpt) { 2311 ntfyOpt.disabled = true; 2312 ntfyOpt.textContent = 'ntfy (add in Integrations first)'; 2313 } 2314 2315 // Webhook: available whenever at least one integration with a base_url exists. 2316 // The user picks which integration to target and supplies a payload template. 2317 let allIntegrations = []; 2318 let webhookConfigured = false; 2319 try { 2320 const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); 2321 if (res.ok) { 2322 const data = await res.json(); 2323 allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false); 2324 webhookConfigured = allIntegrations.length > 0; 2325 } 2326 } catch (_) {} 2327 if (!webhookConfigured && webhookOpt) { 2328 webhookOpt.disabled = true; 2329 webhookOpt.textContent = 'Webhook (add an Integration first)'; 2330 } 2331 2332 const emailFromRow = el('set-reminder-email-from-row'); 2333 const emailAcctSel = el('set-reminder-email-account'); 2334 const emailToRow = el('set-reminder-email-to-row'); 2335 const emailToIn = el('set-reminder-email-to'); 2336 const ntfyTopicRow = el('set-reminder-ntfy-topic-row'); 2337 const ntfyTopicIn = el('set-reminder-ntfy-topic'); 2338 const webhookIntgRow = el('set-reminder-webhook-intg-row'); 2339 const webhookIntgSel = el('set-reminder-webhook-intg'); 2340 const webhookTemplateRow = el('set-reminder-webhook-template-row'); 2341 const webhookTemplateIn = el('set-reminder-webhook-template'); 2342 2343 function populateReminderEmailAccounts(selectedId = '') { 2344 if (!emailAcctSel) return; 2345 emailAcctSel.innerHTML = emailAccounts.map(a => 2346 `<option value="${a.id}">${esc(a.name || a.from_address || a.imap_user || 'Unnamed')}${a.is_default ? ' (default)' : ''}</option>` 2347 ).join(''); 2348 const fallback = (emailAccounts.find(a => a.is_default) || emailAccounts[0] || {}).id || ''; 2349 emailAcctSel.value = (selectedId && emailAccounts.some(a => a.id === selectedId)) ? selectedId : fallback; 2350 } 2351 2352 function populateWebhookIntegrations(selectedId = '') { 2353 if (!webhookIntgSel) return; 2354 webhookIntgSel.innerHTML = allIntegrations.length 2355 ? allIntegrations.map(i => `<option value="${esc(i.id)}">${esc(i.name || i.id)}</option>`).join('') 2356 : '<option value="">No integrations configured</option>'; 2357 if (selectedId && allIntegrations.some(i => i.id === selectedId)) webhookIntgSel.value = selectedId; 2358 } 2359 2360 function applyReminderChannelAvailability() { 2361 if (emailOpt) { 2362 emailOpt.disabled = !smtpConfigured; 2363 emailOpt.textContent = smtpConfigured ? 'Email' : 'Email (add an account in Integrations)'; 2364 } 2365 if (ntfyOpt) { 2366 ntfyOpt.disabled = !ntfyConfigured; 2367 ntfyOpt.textContent = ntfyConfigured ? 'ntfy' : 'ntfy (add in Integrations first)'; 2368 } 2369 if (webhookOpt) { 2370 webhookOpt.disabled = !webhookConfigured; 2371 webhookOpt.textContent = webhookConfigured ? 'Webhook' : 'Webhook (add an Integration first)'; 2372 } 2373 } 2374 2375 async function refreshReminderChannelAvailability() { 2376 const currentChannel = channelSel.value || 'browser'; 2377 const currentEmailAccount = emailAcctSel?.value || ''; 2378 const currentWebhookIntg = webhookIntgSel?.value || ''; 2379 try { 2380 const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); 2381 if (res.ok) { 2382 const d = await res.json(); 2383 emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password); 2384 } 2385 } catch (_) {} 2386 smtpConfigured = emailAccounts.length > 0; 2387 2388 ntfyConfigured = false;
2389 try { 2390 const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); 2391 if (res.ok) { 2392 const data = await res.json(); 2393 ntfyConfigured = (data.integrations || []).some( 2394 i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url 2395 ); 2396 allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false); 2397 webhookConfigured = allIntegrations.length > 0; 2398 } 2399 } catch (_) {} 2400 if (!ntfyConfigured) { 2401 try { 2402 const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 2403 const s = await res.json(); 2404 if (s.reminder_channel === 'ntfy') ntfyConfigured = true; 2405 } catch (_) {} 2406 } 2407 2408 applyReminderChannelAvailability(); 2409 populateReminderEmailAccounts(currentEmailAccount); 2410 populateWebhookIntegrations(currentWebhookIntg); 2411 if (currentChannel === 'email' && !smtpConfigured) channelSel.value = 'browser'; 2412 else if (currentChannel === 'ntfy' && !ntfyConfigured) channelSel.value = 'browser'; 2413 else if (currentChannel === 'webhook' && !webhookConfigured) channelSel.value = 'browser'; 2414 else channelSel.value = currentChannel; 2415 if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; 2416 syncChannelRows(); 2417 } 2418 2419 // Populate the "Send from" picker with all configured email accounts. 2420 populateReminderEmailAccounts(); 2421 2422 function syncChannelRows() { 2423 const isEmail = channelSel.value === 'email'; 2424 const isWebhook = channelSel.value === 'webhook'; 2425 if (emailFromRow) emailFromRow.style.display = (isEmail && emailAccounts.length > 1) ? 'flex' : 'none'; 2426 if (emailToRow) emailToRow.style.display = isEmail ? 'flex' : 'none'; 2427 if (ntfyTopicRow) ntfyTopicRow.style.display = channelSel.value === 'ntfy' ? 'flex' : 'none'; 2428 if (webhookIntgRow) webhookIntgRow.style.display = isWebhook ? 'flex' : 'none'; 2429 if (webhookTemplateRow) webhookTemplateRow.style.display = isWebhook ? 'flex' : 'none'; 2430 } 2431 2432 // Browser notifications fire on EVERY reminder (see 2433 // routes/note_routes.py â the in-app notif is always queued 2434 // regardless of channel). The hint should make that clear so 2435 // users don't think they have to choose between channels. 2436 const CHANNEL_HINTS = { 2437 browser: 'Reminders appear as browser notifications inside Odysseus.', 2438 email: 'Reminders are emailed AND shown as a browser notification.', 2439 ntfy: 'Reminders are pushed via ntfy AND shown as a browser notification.', 2440 webhook: 'Reminders are POSTed to the selected integration AND shown as a browser notification. Use {{title}} and {{message}} in the payload template.', 2441 }; 2442 2443 applyReminderChannelAvailability(); 2444 if (!channelSel.dataset.integrationRefreshWired) { 2445 channelSel.dataset.integrationRefreshWired = '1'; 2446 window.addEventListener('odysseus-integrations-changed', () => { 2447 refreshReminderChannelAvailability().catch(e => console.warn('Failed to refresh reminder channels', e)); 2448 }); 2449 } 2450 2451 // Default payload templates for known presets â auto-filled when the user 2452 // picks a matching integration so they don't have to write JSON from scratch. 2453 // Defined here (before the load block) so both the load path and the change 2454 // handler can reference it. 2455 const WEBHOOK_PRESET_TEMPLATES = { 2456 discord_webhook: '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}', 2457 }; 2458 2459 try { 2460 const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); 2461 const s = await res.json(); 2462 let savedChannel = s.reminder_channel || 'browser'; 2463 if (savedChannel === 'email' && !smtpConfigured) savedChannel = 'browser'; 2464 if (savedChannel === 'ntfy' && !ntfyConfigured) savedChannel = 'browser'; 2465 if (savedChannel === 'webhook' && !webhookConfigured) savedChannel = 'browser'; 2466 channelSel.value = savedChannel; 2467 llmToggle.checked = !!s.reminder_llm_synthesis; 2468 if (emailToIn) emailToIn.value = s.reminder_email_to || ''; 2469 if (ntfyTopicIn) ntfyTopicIn.value = s.reminder_ntfy_topic || 'Reminders'; 2470 populateWebhookIntegrations(s.reminder_webhook_integration_id || ''); 2471 if (webhookTemplateIn) { 2472 webhookTemplateIn.value = s.reminder_webhook_payload_template || ''; 2473 // If an integration is already selected but no template was ever saved, 2474 // auto-fill with the preset default so the first test works out of the box. 2475 if (!webhookTemplateIn.value && webhookIntgSel?.value) { 2476 const intg = allIntegrations.find(i => i.id === webhookIntgSel.value); 2477 const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || ''; 2478 if (tpl) { webhookTemplateIn.value = tpl; save({ reminder_webhook_payload_template: tpl }); } 2479 } 2480 } 2481 // Restore the previously-picked email account (if any), otherwise 2482 // default to the account flagged is_default in the integrations 2483 // list. Falls through to the first option if neither exists. 2484 if (emailAcctSel) { 2485 const savedId = s.reminder_email_account_id; 2486 populateReminderEmailAccounts(savedId || ''); 2487 if (emailAcctSel.value && emailAcctSel.value !== (savedId || '')) { 2488 save({ reminder_email_account_id: emailAcctSel.value || null }); 2489 } 2490 } 2491 if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; 2492 syncChannelRows(); 2493 } catch (e) { console.warn('Failed to load reminder settings', e); } 2494 2495 async function save(patch) { 2496 try { 2497 await fetch('/api/auth/settings', { 2498 method: 'POST', 2499 credentials: 'same-origin', 2500 headers: { 'Content-Type': 'application/json' }, 2501 body: JSON.stringify(patch), 2502 }); 2503 } catch (e) { console.warn('Failed to save reminder settings', e); } 2504 } 2505 2506 channelSel.addEventListener('change', () => { 2507 if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; 2508 syncChannelRows(); 2509 save({ reminder_channel: channelSel.value }); 2510 }); 2511 if (emailToIn) { 2512 let emailDebounce; 2513 emailToIn.addEventListener('input', () => { 2514 clearTimeout(emailDebounce); 2515 emailDebounce = setTimeout(() => save({ reminder_email_to: emailToIn.value.trim() }), 600); 2516 }); 2517 } 2518 if (emailAcctSel) { 2519 emailAcctSel.addEventListener('change', () => { 2520 save({ reminder_email_account_id: emailAcctSel.value || null }); 2521 }); 2522 } 2523 if (ntfyTopicIn) { 2524 let topicDebounce; 2525 ntfyTopicIn.addEventListener('input', () => { 2526 clearTimeout(topicDebounce); 2527 topicDebounce = setTimeout(() => save({ reminder_ntfy_topic: ntfyTopicIn.value.trim() || 'reminders' }), 600); 2528 }); 2529 } 2530 if (webhookIntgSel) { 2531 webhookIntgSel.addEventListener('change', () => { 2532 save({ reminder_webhook_integration_id: webhookIntgSel.value || '' }); 2533 // If the template is empty and we recognise the integration's preset, 2534 // pre-fill with a sensible default so users can test immediately. 2535 if (webhookTemplateIn && !webhookTemplateIn.value.trim()) { 2536 const intg = allIntegrations.find(i => i.id === webhookIntgSel.value); 2537 const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || ''; 2538 if (tpl) { 2539 webhookTemplateIn.value = tpl; 2540 save({ reminder_webhook_payload_template: tpl }); 2541 } 2542 } 2543 }); 2544 } 2545 if (webhookTemplateIn) { 2546 let templateDebounce; 2547 webhookTemplateIn.addEventListener('input', () => { 2548 clearTimeout(templateDebounce); 2549 templateDebounce = setTimeout(() => save({ reminder_webhook_payload_template: webhookTemplateIn.value.trim() }), 600); 2550 }); 2551 } 2552 // Dim the whole AI Synthesis card when off (matches Vision/Utility/etc.). 2553 function syncSynthesisDim() { 2554 const card = llmToggle.closest('.admin-card'); 2555 if (card) card.style.opacity = llmToggle.checked ? '' : '0.45'; 2556 } 2557 syncSynthesisDim(); 2558 llmToggle.addEventListener('change', () => { 2559 syncSynthesisDim(); 2560 save({ reminder_llm_synthesis: llmToggle.checked }); 2561 }); 2562 2563 // Test button 2564 const testBtn = el('set-reminder-test-btn'); 2565 const testMsg = el('set-reminder-test-msg'); 2566 if (testBtn) { 2567 testBtn.addEventListener('click', async () => { 2568 testBtn.disabled = true; 2569 if (testMsg) { testMsg.textContent = 'Sendingâ¦'; testMsg.style.color = 'var(--fg)'; } 2570 // Whirlpool loader right next to the "Sendingâ¦" text while it sends. 2571 let _testSpin = null;
2572 try { 2573 const _sp = (await import('./spinner.js')).default; 2574 _testSpin = _sp.createWhirlpool(14); 2575 _testSpin.element.style.cssText = 'width:14px;height:14px;margin:0 0 0 7px;display:inline-block;vertical-align:middle;'; 2576 (testMsg || testBtn).insertAdjacentElement('afterend', _testSpin.element); 2577 } catch (_) {} 2578 const _stopTestSpin = () => { try { _testSpin && _testSpin.stop(); _testSpin && _testSpin.element.remove(); } catch (_) {} }; 2579 try { 2580 const res = await fetch('/api/notes/fire-reminder', { 2581 method: 'POST', 2582 credentials: 'same-origin', 2583 headers: { 'Content-Type': 'application/json' }, 2584 body: JSON.stringify({ 2585 note_id: 'test-' + Date.now(), 2586 title: 'Test Reminder', 2587 body: 'This is a test reminder to verify your settings are working.', 2588 channel: channelSel.value, 2589 ...(channelSel.value === 'webhook' ? { 2590 webhook_integration_id: webhookIntgSel?.value || '', 2591 webhook_payload_template: webhookTemplateIn?.value.trim() || '', 2592 } : {}), 2593 }), 2594 }); 2595 const data = await res.json(); 2596 if (!res.ok) throw new Error(data.detail || 'Server error'); 2597 if (channelSel.value === 'email' && !data.email_sent) { 2598 throw new Error(data.email_error || 'Email reminder was not sent'); 2599 } 2600 if (channelSel.value === 'ntfy' && !data.ntfy_sent) { 2601 throw new Error(data.ntfy_error || 'ntfy reminder was not sent'); 2602 } 2603 if (channelSel.value === 'webhook' && !data.webhook_sent) { 2604 const activeChannel = data.channel ? ` (server used channel: "${data.channel}")` : ''; 2605 throw new Error((data.webhook_error || 'Webhook reminder was not sent') + activeChannel); 2606 } 2607 let status = 'Delivered via ' + channelSel.value; 2608 if (data.synthesis) status += ' (AI: "' + data.synthesis.slice(0, 60) + '...")'; 2609 if (data.email_sent) status += ' â email sent'; 2610 if (data.ntfy_sent) status += ' â ntfy sent'; 2611 if (data.webhook_sent) status += ' â webhook sent'; 2612 if (testMsg) { testMsg.textContent = status; testMsg.style.color = 'var(--green, #50fa7b)'; } 2613 // Also fire a browser notification so user can see it 2614 if ('Notification' in window && Notification.permission === 'granted') { 2615 try { 2616 new Notification('Test Reminder', { 2617 body: data.synthesis || 'This is a test reminder.', 2618 tag: 'reminder-test', 2619 icon: '/static/favicon.ico', 2620 }); 2621 } catch {} 2622 } 2623 } catch (e) { 2624 if (testMsg) { testMsg.textContent = 'Failed: ' + e.message; testMsg.style.color = 'var(--red)'; } 2625 } finally { 2626 _stopTestSpin(); 2627 testBtn.disabled = false; 2628 } 2629 }); 2630 } 2631} 2632 2633async function initEmailAccountsSettings() { 2634 const root = el('settings-modal'); 2635 if (!root || !root.querySelector('[data-settings-panel="email"]')) return; 2636 const manageBtn = el('set-email-open-integrations'); 2637 if (manageBtn && manageBtn.dataset.bound !== '1') { 2638 manageBtn.dataset.bound = '1'; 2639 manageBtn.addEventListener('click', () => open('integrations')); 2640 } 2641 const tasksBtn = el('set-email-open-tasks'); 2642 if (tasksBtn && tasksBtn.dataset.bound !== '1') { 2643 tasksBtn.dataset.bound = '1'; 2644 tasksBtn.addEventListener('click', async () => { 2645 try { 2646 const mod = await import('./tasks.js'); 2647 const openTasks = mod.openTasks || (mod.default && mod.default.openTasks); 2648 if (typeof openTasks === 'function') openTasks(); 2649 else document.getElementById('tool-tasks-btn')?.click(); 2650 } catch (_) { 2651 document.getElementById('tool-tasks-btn')?.click(); 2652 } 2653 }); 2654 } 2655 const listEl = el('set-email-accounts-list'); 2656 const msgEl = el('set-email-accounts-msg'); 2657 const formEl = el('set-email-accounts-form'); 2658 const addBtn = el('set-email-accounts-add-btn'); 2659 if (!listEl || !addBtn || !formEl) return; 2660 2661 const esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 2662 2663 async function fetchAccounts() { 2664 const r = await fetch('/api/email/accounts', { credentials: 'same-origin' }); 2665 const d = await r.json(); 2666 return d.accounts || []; 2667 } 2668 2669 function renderRow(a) { 2670 const imap = a.imap_host ? `${a.imap_host}:${a.imap_port}` : '<no IMAP>'; 2671 const badge = a.is_default 2672 ? '<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 6px;border-radius:3px;background:color-mix(in srgb, var(--accent,#50fa7b) 15%, transparent);color:var(--accent,#50fa7b)">Default</span>' 2673 : (a.enabled ? '' : '<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 6px;border-radius:3px;opacity:0.4">Disabled</span>'); 2674 return `<div class="email-account-row" data-acc-id="${esc(a.id)}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:6px"> 2675 <div style="flex:1;min-width:0"> 2676 <div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${esc(a.name)} ${badge}</div> 2677 <div style="font-size:11px;opacity:0.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.imap_user || a.from_address || '')} â ${esc(imap)}</div> 2678 </div> 2679 ${a.is_default ? '' : `<button class="admin-btn-sm email-acc-default-btn" style="font-size:10px">Make Default</button>`} 2680 <button class="admin-btn-sm email-acc-edit-btn" style="font-size:10px">Edit</button> 2681 <button class="admin-btn-sm email-acc-del-btn" style="font-size:10px;opacity:0.6">Delete</button> 2682 </div>`; 2683 } 2684 2685 async function renderList() { 2686 const accs = await fetchAccounts(); 2687 if (!accs.length) { 2688 listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center">No email accounts configured</div>'; 2689 return; 2690 } 2691 listEl.innerHTML = accs.map(renderRow).join(''); 2692 listEl.querySelectorAll('.email-account-row').forEach(row => { 2693 const id = row.dataset.accId; 2694 row.querySelector('.email-acc-default-btn')?.addEventListener('click', async (e) => { 2695 e.stopPropagation(); 2696 await fetch(`/api/email/accounts/${id}/set-default`, { method: 'POST', credentials: 'same-origin' }); 2697 renderList(); 2698 }); 2699 row.querySelector('.email-acc-edit-btn')?.addEventListener('click', (e) => { 2700 e.stopPropagation(); 2701 showForm(accs.find(a => a.id === id)); 2702 }); 2703 row.querySelector('.email-acc-del-btn')?.addEventListener('click', async (e) => { 2704 e.stopPropagation(); 2705 if (!await window.styledConfirm(`Delete account "${accs.find(a => a.id === id)?.name}"?`, { confirmText: 'Delete', danger: true })) return; 2706 await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 2707 renderList(); 2708 }); 2709 }); 2710 } 2711 2712 function showForm(existing) { 2713 const a = existing || {}; 2714 const isEdit = !!existing; 2715 formEl.style.display = ''; 2716 // Small `?` indicator next to each label. Hover/focus to read the 2717 // hint via the native `title` tooltip. tabindex makes it 2718 // keyboard-focusable too. 2719 const _hint = (tip) => 2720 `<span class="eaf-hint" title="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0" ` 2721 + `style="display:inline-block;width:13px;height:13px;border-radius:50%;` 2722 + `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;` 2723 + `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
2724 // Provider presets â picking one fills host/port/STARTTLS for both 2725 // IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally 2726 // blank because it may live on another machine (DNS, LAN, Tailscale). 2727 const PROVIDERS = { 2728 gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, 2729 migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, 2730 icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, 2731 outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, 2732 fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } }, 2733 yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } }, 2734 dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } }, 2735 }; 2736 const _providerOptions = Object.entries(PROVIDERS) 2737 .map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`) 2738 .join(''); 2739 const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl'); 2740 formEl.innerHTML = ` 2741 <h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3> 2742 <div class="settings-col"> 2743 <div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label><select id="eaf-provider" class="settings-select"><option value="">Customâ¦</option>${_providerOptions}</select></div> 2744 <div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. âWorkâ or âPersonalâ). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional â leave blank to use email)" value="${esc(a.name || '')}"></div> 2745 <div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="[email protected]" value="${esc(a.from_address || '')}"></div> 2746 <div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div> 2747 <div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div> 2748 <div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div> 2749 <div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div> 2750 <div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA (Gmail, iCloud, etc.).')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div> 2751 <div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Tur
2751n OFF for port 993 (IMAPS â already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div> 2752 <div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">â optional, leave blank for read-only</span></div> 2753 <div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div> 2754 <div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="eaf-smtp-port" class="settings-input" type="number" value="${esc(a.smtp_port || 465)}" style="max-width:100px"></div> 2755 <div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div> 2756 <div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}>
2756<span class="admin-slider"></span></label></div> 2757 <div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div> 2758 <div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password â often the same as your IMAP password.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div> 2759 <div class="settings-row" style="margin-top:10px;align-items:center;"> 2760 <button class="admin-btn-add" id="eaf-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;"> 2761 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> 2762 ${isEdit ? 'Save' : 'Create'} 2763 </button> 2764 <span id="eaf-msg" style="font-size:11px;flex:1;margin-left:8px;"></span> 2765 <button class="admin-btn-add" id="eaf-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
2766 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 2767 Cancel 2768 </button> 2769 </div> 2770 </div> 2771 `; 2772 2773 // Provider preset â autofill host/port/STARTTLS for both halves. 2774 el('eaf-provider').addEventListener('change', (e) => { 2775 const p = PROVIDERS[e.target.value]; 2776 if (!p) return; 2777 el('eaf-imap-host').value = p.imap.host; 2778 el('eaf-imap-port').value = p.imap.port; 2779 el('eaf-imap-starttls').checked = !!p.imap.starttls; 2780 el('eaf-smtp-host').value = p.smtp.host; 2781 el('eaf-smtp-port').value = p.smtp.port; 2782 el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl'); 2783 }); 2784 el('eaf-smtp-security').value = _smtpSecurity(a); 2785 2786 // "Same as IMAP" toggle â hide the SMTP creds rows when on. The save 2787 // handler copies the IMAP user/password into SMTP at submit time. 2788 const _syncSmtpSame = () => { 2789 const same = el('eaf-smtp-same').checked; 2790 formEl.querySelectorAll('.eaf-smtp-creds').forEach(r => { 2791 r.style.display = same ? 'none' : ''; 2792 }); 2793 }; 2794 el('eaf-smtp-same').addEventListener('change', _syncSmtpSame); 2795 _syncSmtpSame(); 2796 2797 el('eaf-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 2798 el('eaf-save').addEventListener('click', async () => { 2799 const body = { 2800 name: el('eaf-name').value.trim(), 2801 from_address: el('eaf-from').value.trim(), 2802 imap_host: el('eaf-imap-host').value.trim(), 2803 imap_port: parseInt(el('eaf-imap-port').value) || 993, 2804 imap_user: el('eaf-imap-user').value.trim(), 2805 imap_starttls: el('eaf-imap-starttls').checked, 2806 smtp_host: el('eaf-smtp-host').value.trim(), 2807 smtp_port: parseInt(el('eaf-smtp-port').value) || 465, 2808 smtp_security: el('eaf-smtp-security').value, 2809 smtp_user: el('eaf-smtp-user').value.trim(), 2810 }; 2811 if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value; 2812 if (el('eaf-smtp-pass').value) body.smtp_password = el('eaf-smtp-pass').value; 2813 // "Same as IMAP" toggle â copy IMAP username/password into SMTP at 2814 // save time, so the hidden SMTP-creds rows don't matter. We only 2815 // mirror the password if the user actually typed an IMAP one 2816 // (otherwise SMTP keeps whatever it already had on the server). 2817 if (el('eaf-smtp-same').checked) { 2818 body.smtp_user = body.imap_user; 2819 if (body.imap_password) body.smtp_password = body.imap_password; 2820 } 2821 // Name is optional â fall back to the From address so the list view 2822 // still has a label to render. Only refuse if both are blank. 2823 if (!body.name) body.name = body.from_address; 2824 if (!body.name) { el('eaf-msg').textContent = 'Need at least a Name or Email'; el('eaf-msg').style.color = 'var(--red)'; return; } 2825 2826 try { 2827 const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts'; 2828 const method = isEdit ? 'PUT' : 'POST'; 2829 const r = await fetch(url, { 2830 method, credentials: 'same-origin', 2831 headers: { 'Content-Type': 'application/json' }, 2832 body: JSON.stringify(body), 2833 }); 2834 const d = await r.json(); 2835 if (d.ok || d.id) { 2836 el('eaf-msg').textContent = 'Saved'; 2837 el('eaf-msg').style.color = 'var(--green,#50fa7b)'; 2838 setTimeout(() => { formEl.style.display = 'none'; renderList(); }, 400); 2839 } else { 2840 el('eaf-msg').textContent = d.error || 'Save failed'; 2841 el('eaf-msg').style.color = 'var(--red)'; 2842 } 2843 } catch (e) { 2844 el('eaf-msg').textContent = 'Error: ' + e.message; 2845 el('eaf-msg').style.color = 'var(--red)'; 2846 } 2847 }); 2848 } 2849 2850 addBtn.addEventListener('click', () => showForm(null)); 2851 await renderList(); 2852} 2853 2854async function initEmailSettings() { 2855 const root = el('settings-modal'); 2856 if (!root || !root.querySelector('[data-settings-panel="email"]')) return; 2857 2858 // Load current email config 2859 try { 2860 const res = await fetch('/api/email/config'); 2861 const cfg = await res.json(); 2862 if (el('set-email-imap-host')) el('set-email-imap-host').value = cfg.imap_host || ''; 2863 if (el('set-email-imap-port')) el('set-email-imap-port').value = cfg.imap_port || ''; 2864 if (el('set-email-imap-user')) el('set-email-imap-user').value = cfg.imap_user || ''; 2865 if (el('set-email-imap-pass')) el('set-email-imap-pass').value = ''; // never prefill 2866 if (el('set-email-smtp-host')) el('set-email-smtp-host').value = cfg.smtp_host || ''; 2867 if (el('set-email-smtp-port')) el('set-email-smtp-port').value = cfg.smtp_port || ''; 2868 if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || ''; 2869 if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = ''; 2870 if (el('set-email-from')) el('set-email-from').value = cfg.from_address || ''; 2871 } catch (_) {} 2872 2873 // Load contacts config 2874 try { 2875 const res = await fetch('/api/contacts/config'); 2876 const cfg = await res.json(); 2877 if (el('set-carddav-url')) el('set-carddav-url').value = cfg.url || ''; 2878 if (el('set-carddav-user')) el('set-carddav-user').value = cfg.username || ''; 2879 if (el('set-carddav-pass')) el('set-carddav-pass').value = ''; 2880 } catch (_) {} 2881 2882 // Load writing style 2883 try { 2884 const res = await fetch('/api/email/style'); 2885 const data = await res.json(); 2886 if (el('set-email-style')) el('set-email-style').value = data.style || ''; 2887 } catch (_) {} 2888 2889 // Save email config 2890 el('set-email-save')?.addEventListener('click', async () => { 2891 const msg = el('set-email-msg'); 2892 if (msg) msg.textContent = 'Saving...'; 2893 const data = { 2894 imap_host: el('set-email-imap-host').value, 2895 imap_port: parseInt(el('set-email-imap-port').value) || 0, 2896 imap_user: el('set-email-imap-user').value, 2897 smtp_host: el('set-email-smtp-host').value, 2898 smtp_port: parseInt(el('set-email-smtp-port').value) || 0, 2899 smtp_user: el('set-email-smtp-user').value, 2900 email_from: el('set-email-from').value, 2901 }; 2902 const imapPass = el('set-email-imap-pass').value; 2903 const smtpPass = el('set-email-smtp-pass').value; 2904 if (imapPass) data.imap_password = imapPass; 2905 if (smtpPass) data.smtp_password = smtpPass; 2906 try { 2907 const res = await fetch('/api/email/config', { 2908 method: 'PUT', 2909 headers: { 'Content-Type': 'application/json' }, 2910 body: JSON.stringify(data), 2911 }); 2912 const result = await res.json(); 2913 if (msg) msg.textContent = result.success ? 'â Saved' : (result.error || 'Failed'); 2914 setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); 2915 } catch (e) { 2916 if (msg) msg.textContent = 'Failed'; 2917 } 2918 }); 2919 2920 // Save CardDAV config 2921 el('set-carddav-save')?.addEventListener('click', async () => { 2922 const msg = el('set-carddav-msg'); 2923 if (msg) msg.textContent = 'Saving...'; 2924 const data = { 2925 carddav_url: el('set-carddav-url').value, 2926 carddav_username: el('set-carddav-user').value, 2927 }; 2928 const pass = el('set-carddav-pass').value; 2929 if (pass) data.carddav_password = pass; 2930 try { 2931 const res = await fetch('/api/contacts/config', { 2932 method: 'PUT', 2933 headers: { 'Content-Type': 'application/json' }, 2934 body: JSON.stringify(data), 2935 }); 2936 const result = await res.json(); 2937 if (msg) msg.textContent = result.success ? 'â Saved' : (result.error || 'Failed'); 2938 setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); 2939 } catch (e) { 2940 if (msg) msg.textContent = 'Failed'; 2941 } 2942 }); 2943 2944 // Extract writing style 2945 el('set-email-style-extract')?.addEventListener('click', async () => { 2946 const btn = el('set-email-style-extract'); 2947 const msg = el('set-email-style-msg'); 2948 btn.disabled = true; 2949 // Render whirlpool + label inside the status area (same pattern as 2950 // the "Find" / network-discover button in Add Models). 2951 let wp = null; 2952 if (msg) { 2953 msg.className = ''; 2954 msg.innerHTML = '';
2955 try { 2956 const sp = window.spinnerModule || (await import('./spinner.js')).default; 2957 wp = sp.createWhirlpool(16); 2958 wp.element.style.cssText = 'display:inline-block;vertical-align:middle;margin:0 8px 0 0;'; 2959 const wrap = document.createElement('span'); 2960 wrap.style.cssText = 'display:inline-flex;align-items:center;'; 2961 wrap.appendChild(wp.element); 2962 const txt = document.createElement('span'); 2963 txt.textContent = 'Analyzing your sent emailsâ¦'; 2964 txt.style.cssText = 'font-size:12px;opacity:0.7;'; 2965 wrap.appendChild(txt); 2966 msg.appendChild(wrap); 2967 } catch (_) { 2968 msg.textContent = 'Analyzing your sent emailsâ¦'; 2969 } 2970 } 2971 try { 2972 const res = await fetch('/api/email/extract-style', { 2973 method: 'POST', 2974 headers: { 'Content-Type': 'application/json' }, 2975 body: JSON.stringify({ sample_count: 15 }), 2976 }); 2977 const data = await res.json(); 2978 if (data.success && data.style) { 2979 if (el('set-email-style')) el('set-email-style').value = data.style; 2980 if (msg) msg.textContent = 'â Style extracted'; 2981 } else { 2982 if (msg) msg.textContent = data.error || 'Failed'; 2983 } 2984 } catch (e) { 2985 if (msg) msg.textContent = 'Failed to extract'; 2986 } finally { 2987 if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} } 2988 btn.disabled = false; 2989 setTimeout(() => { if (msg) msg.textContent = ''; }, 5000); 2990 } 2991 }); 2992 2993 // Save writing style manually 2994 el('set-email-style-save')?.addEventListener('click', async () => { 2995 const msg = el('set-email-style-msg'); 2996 if (msg) msg.textContent = 'Saving...'; 2997 try { 2998 const res = await fetch('/api/email/style', { 2999 method: 'PUT', 3000 headers: { 'Content-Type': 'application/json' }, 3001 body: JSON.stringify({ style: el('set-email-style').value }), 3002 }); 3003 const result = await res.json(); 3004 if (msg) msg.textContent = result.success ? 'â Saved' : 'Failed'; 3005 setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); 3006 } catch (e) { 3007 if (msg) msg.textContent = 'Failed'; 3008 } 3009 }); 3010} 3011 3012async function initIntegrations() { 3013 const listEl = el('integrations-list'); 3014 const formCard = el('integration-form-card'); 3015 const addBtn = el('intg-add-btn'); 3016 if (!listEl || !formCard) return; 3017 3018 const presetSel = el('intg-preset'); 3019 const nameIn = el('intg-name'); 3020 const urlIn = el('intg-url'); 3021 const authTypeSel = el('intg-auth-type'); 3022 const authHeaderRow = el('intg-auth-header-row'); 3023 const authHeaderIn = el('intg-auth-header'); 3024 const keyIn = el('intg-key'); 3025 const descIn = el('intg-description'); 3026 const saveBtn = el('intg-save-btn'); 3027 const cancelBtn = el('intg-cancel-btn'); 3028 const testBtn = el('intg-test-btn'); 3029 const statusEl = el('intg-status'); 3030 const formTitle = el('integration-form-title'); 3031 3032 let editingId = null; 3033 let presets = {}; 3034 3035 // Presets where the secret is embedded in the URL â no separate key or 3036 // auth header is used, so hiding those fields avoids confusion. 3037 const URL_AUTH_PRESETS = ['discord_webhook']; 3038 3039 // Toggle auth header + key row visibility based on auth type and preset. 3040 function syncAuthRow() { 3041 const v = authTypeSel.value; 3042 authHeaderRow.style.display = (v === 'header' || v === 'query') ? 'flex' : 'none'; 3043 if (v === 'query') authHeaderIn.placeholder = 'api_key'; 3044 else authHeaderIn.placeholder = 'X-Auth-Token'; 3045 const keyRow = keyIn?.closest('.settings-row'); 3046 if (keyRow) keyRow.style.display = URL_AUTH_PRESETS.includes(presetSel?.value) ? 'none' : ''; 3047 } 3048 authTypeSel.addEventListener('change', syncAuthRow); 3049 3050 // Load presets 3051 try { 3052 const res = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' }); 3053 if (res.ok) { 3054 const data = await res.json(); 3055 presets = data.presets || {}; 3056 for (const [key, preset] of Object.entries(presets)) { 3057 const opt = document.createElement('option'); 3058 opt.value = key; 3059 opt.textContent = preset.name || key; 3060 presetSel.appendChild(opt); 3061 } 3062 } 3063 } catch (e) {} 3064 3065 // Preset auto-fill 3066 presetSel.addEventListener('change', () => { 3067 const p = presets[presetSel.value]; 3068 if (!p) return; 3069 nameIn.value = p.name || ''; 3070 authTypeSel.value = p.auth_type || 'none'; 3071 authHeaderIn.value = p.auth_header || ''; 3072 descIn.value = p.description || ''; 3073 syncAuthRow(); 3074 }); 3075 3076 // Render list 3077 async function renderList() { 3078 try { 3079 const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); 3080 if (!res.ok) { listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;">Admin access required</div>'; return; } 3081 const data = await res.json(); 3082 const items = data.integrations || []; 3083 if (!items.length) { 3084 listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center;">No integrations configured</div>'; 3085 return; 3086 } 3087 listEl.innerHTML = items.map(i => ` 3088 <div class="admin-card" style="display:flex;align-items:center;gap:10px;margin-bottom:6px;"> 3089 <div style="flex:1;min-width:0;"> 3090 <div style="font-size:13px;font-weight:600;">${_esc(i.name || i.id)}</div> 3091 <div style="font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(i.base_url || '')}</div> 3092 </div> 3093 <div style="display:flex;gap:4px;flex-shrink:0;"> 3094 <button class="admin-btn-sm intg-edit-btn" data-id="${i.id}" style="font-size:11px;">Edit</button>
3095 <button class="admin-btn-sm intg-del-btn" data-id="${i.id}" style="font-size:11px;opacity:0.6;">Del</button> 3096 </div> 3097 </div> 3098 `).join(''); 3099 listEl.querySelectorAll('.intg-edit-btn').forEach(b => b.addEventListener('click', () => startEdit(b.dataset.id))); 3100 listEl.querySelectorAll('.intg-del-btn').forEach(b => b.addEventListener('click', () => doDelete(b.dataset.id))); 3101 } catch (e) { listEl.innerHTML = '<div style="padding:12px;color:var(--red);font-size:12px;">Failed to load</div>'; } 3102 } 3103 3104 function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } 3105 3106 // Start editing 3107 async function startEdit(id) { 3108 editingId = id; 3109 formTitle.textContent = 'Edit Integration'; 3110 // Fetch full data (with unmasked key from a dedicated edit fetch â we'll just load what we have) 3111 try { 3112 const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); 3113 const data = await res.json(); 3114 const item = (data.integrations || []).find(i => i.id === id); 3115 if (!item) return; 3116 presetSel.value = item.preset || ''; 3117 nameIn.value = item.name || ''; 3118 urlIn.value = item.base_url || ''; 3119 authTypeSel.value = item.auth_type || 'none'; 3120 authHeaderIn.value = item.auth_header || ''; 3121 keyIn.value = ''; // masked â user re-enters if changing 3122 keyIn.placeholder = item.api_key ? 'Leave blank to keep current' : 'API key or token'; 3123 descIn.value = item.description || ''; 3124 syncAuthRow(); 3125 formCard.style.display = ''; 3126 } catch (e) {} 3127 } 3128 3129 // Show add form 3130 addBtn.addEventListener('click', () => { 3131 editingId = null; 3132 formTitle.textContent = 'Add Integration'; 3133 presetSel.value = ''; 3134 nameIn.value = ''; 3135 urlIn.value = ''; 3136 authTypeSel.value = 'header'; 3137 authHeaderIn.value = ''; 3138 keyIn.value = ''; 3139 keyIn.placeholder = 'API key or token'; 3140 descIn.value = ''; 3141 statusEl.textContent = ''; 3142 syncAuthRow(); 3143 formCard.style.display = ''; 3144 }); 3145 3146 cancelBtn.addEventListener('click', () => { 3147 formCard.style.display = 'none'; 3148 statusEl.textContent = ''; 3149 }); 3150 3151 // Save 3152 saveBtn.addEventListener('click', async () => { 3153 const payload = { 3154 name: nameIn.value.trim(), 3155 base_url: urlIn.value.trim().replace(/\/+$/, ''), 3156 auth_type: authTypeSel.value, 3157 auth_header: authHeaderIn.value.trim(), 3158 description: descIn.value.trim(), 3159 }; 3160 if (presetSel.value) payload.preset = presetSel.value; 3161 if (keyIn.value.trim()) payload.api_key = keyIn.value.trim(); 3162 if (!payload.name) { statusEl.textContent = 'Name required'; statusEl.style.color = 'var(--red)'; return; } 3163 if (!payload.base_url) { statusEl.textContent = 'URL required'; statusEl.style.color = 'var(--red)'; return; } 3164 3165 try { 3166 const url = editingId ? `/api/auth/integrations/${editingId}` : '/api/auth/integrations'; 3167 const method = editingId ? 'PUT' : 'POST'; 3168 const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'same-origin' }); 3169 if (res.ok) { 3170 statusEl.textContent = 'Saved'; 3171 statusEl.style.color = 'var(--green, #98c379)'; 3172 formCard.style.display = 'none'; 3173 await renderList(); 3174 notifyIntegrationsChanged(); 3175 } else { 3176 const err = await res.json().catch(() => ({})); 3177 statusEl.textContent = err.detail || 'Save failed'; 3178 statusEl.style.color = 'var(--red)'; 3179 } 3180 } catch (e) { 3181 statusEl.textContent = 'Error saving'; 3182 statusEl.style.color = 'var(--red)'; 3183 } 3184 }); 3185 3186 // Test 3187 testBtn.addEventListener('click', async () => { 3188 if (!editingId) { statusEl.textContent = 'Save first, then test'; statusEl.style.color = 'var(--fg)'; return; } 3189 statusEl.textContent = 'Testing...'; 3190 statusEl.style.color = 'var(--fg)'; 3191 try { 3192 const res = await fetch(`/api/auth/integrations/${editingId}/test`, { method: 'POST', credentials: 'same-origin' }); 3193 const data = await res.json(); 3194 statusEl.textContent = data.message || (data.ok ? 'OK' : 'Failed'); 3195 statusEl.style.color = data.ok ? 'var(--green, #98c379)' : 'var(--red)'; 3196 } catch (e) { 3197 statusEl.textContent = 'Connection failed'; 3198 statusEl.style.color = 'var(--red)'; 3199 } 3200 }); 3201 3202 // Delete
3203 async function doDelete(id) { 3204 if (!await window.styledConfirm('Delete this integration?', { confirmText: 'Delete', danger: true })) return; 3205 try { 3206 await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 3207 if (editingId === id) { formCard.style.display = 'none'; editingId = null; } 3208 await renderList(); 3209 notifyIntegrationsChanged(); 3210 } catch (e) {} 3211 } 3212 3213 syncAuthRow(); 3214 renderList(); 3215} 3216 3217/* ââ Unified Integrations ââ */ 3218 3219const INTG_TYPES = { 3220 api: { label: 'API', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>' }, 3221 caldav: { label: 'CalDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' }, 3222 contacts: { label: 'Contacts', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' }, 3223 carddav: { label: 'CardDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' }, 3224 email: { label: 'Email', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>' }, 3225 mcp: { label: 'MCP', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' }, 3226 codex: { label: 'Codex', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>' }, 3227 claude: { label: 'Claude', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>' }, 3228 vault: { label: 'Vault', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' }, 3229}; 3230
3231// Config shared by the Codex Agent and Claude Agent forms. Both use the same 3232// scope-gated /api/codex/* backend; this just parameterizes the UI label, 3233// default token name, and the per-agent install commands. 3234const AGENT_CONFIGS = { 3235 codex: { 3236 label: 'Codex Agent', 3237 word: 'Codex', 3238 namePrefix: 'codex agent', 3239 defaultName: 'Codex Agent', 3240 pluginPath: '/api/codex/plugin.zip', 3241 setupDescription: 'Downloads the plugin bundle and registers it with Codex. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the plugin from <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>, and runs <code>codex plugin add odysseus@personal</code>.', 3242 buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin} 3243export ODYSSEUS_API_TOKEN='${token}' 3244mkdir -p ~/plugins 3245curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip 3246python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins 3247python3 - <<'PY' 3248import json 3249from pathlib import Path 3250 3251p = Path.home() / ".agents" / "plugins" / "marketplace.json" 3252p.parent.mkdir(parents=True, exist_ok=True) 3253if p.exists(): 3254 data = json.loads(p.read_text()) 3255else: 3256 data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []} 3257 3258data.setdefault("name", "personal") 3259data.setdefault("interface", {}).setdefault("displayName", "Personal") 3260plugins = data.setdefault("plugins", []) 3261entry = { 3262 "name": "odysseus", 3263 "source": {"source": "local", "path": "./plugins/odysseus"}, 3264 "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, 3265 "category": "Productivity", 3266} 3267data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry] 3268p.write_text(json.dumps(data, indent=2) + "\\n") 3269PY 3270codex plugin add odysseus@personal 3271python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`, 3272 }, 3273 claude: { 3274 label: 'Claude Agent', 3275 word: 'Claude', 3276 namePrefix: 'claude agent', 3277 defaultName: 'Claude Agent', 3278 pluginPath: '/api/claude/plugin.zip', 3279 setupDescription: 'Downloads the skill bundle into <code>~/.claude/skills/odysseus/</code>. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the skill from <a href="/api/claude/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>. Claude Code auto-loads the skill on next start.', 3280 buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin} 3281export ODYSSEUS_API_TOKEN='${token}' 3282mkdir -p ~/.claude 3283curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip 3284python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/ 3285python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities`, 3286 }, 3287}; 3288 3289let _unifiedInited = false; 3290 3291async function initUnifiedIntegrations() { 3292 if (_unifiedInited) return; 3293 _unifiedInited = true; 3294 3295 const listEl = el('unified-integrations-list'); 3296 const formEl = el('unified-intg-form'); 3297 const addBtn = el('unified-intg-add-btn'); 3298 if (!listEl) return; 3299 let integrationNotice = ''; 3300 3301 function _openEmailSettings() { 3302 open('email'); 3303 } 3304 3305 async function fetchAll() { 3306 const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes, calendarsRes] = await Promise.all([ 3307 fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })), 3308 fetch('/api/calendar/config/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })), 3309 fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), 3310 fetch('/api/contacts/list', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { contacts: [], count: 0 }).catch(() => ({ contacts: [], count: 0 })), 3311 fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })), 3312 fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []), 3313 fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), 3314 fetch('/api/tokens', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []), 3315 fetch('/api/calendar/calendars', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { calendars: [] }).catch(() => ({ calendars: [] })), 3316 ]); 3317 const items = []; 3318 // API integrations 3319 for (const intg of (apiRes.integrations || [])) { 3320 items.push({ type: 'api', id: intg.id, name: intg.name || 'Unnamed', detail: intg.base_url || '', enabled: intg.enabled !== false, data: intg }); 3321 } 3322 // CalDAV â one card per account 3323 for (const acc of (calRes.accounts || [])) { 3324 items.push({ type: 'caldav', id: acc.id, name: acc.label || 'Calendar (CalDAV)', detail: acc.url, enabled: true, data: acc }); 3325 } 3326 // Contacts import first, then the optional CardDAV sync account. 3327 const contactCount = Number(contactsRes.count || (contactsRes.contacts || []).length || 0); 3328 if (contactCount > 0) { 3329 items.push({ 3330 type: 'contacts', 3331 id: '__contacts__', 3332 name: 'Contacts Import', 3333 detail: `${contactCount} contact${contactCount === 1 ? '' : 's'}`, 3334 enabled: true, 3335 data: contactsRes, 3336 }); 3337 } 3338 if (cardRes.url) { 3339 items.push({ 3340 type: 'carddav', 3341 id: '__carddav__', 3342 name: 'Contacts (CardDAV)', 3343 detail: cardRes.url, 3344 enabled: true, 3345 data: cardRes, 3346 }); 3347 } 3348 // Email â one entry per EmailAccount row 3349 for (const acc of (emailAccountsRes.accounts || [])) { 3350 const label = acc.name + (acc.is_default ? ' (default)' : ''); 3351 const detail = [acc.from_address || acc.imap_user, acc.imap_host].filter(
3351Boolean).join(' â '); 3352 items.push({ type: 'email', id: acc.id, name: label, detail, enabled: acc.enabled !== false, data: acc }); 3353 } 3354 // MCP servers 3355 const mcpList = Array.isArray(mcpRes) ? mcpRes : (mcpRes.servers || []); 3356 for (const srv of mcpList) { 3357 const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected'; 3358 items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv }); 3359 } 3360 for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) { 3361 const scopes = tok.scopes || []; 3362 const lowerName = (tok.name || '').toLowerCase(); 3363 let agentType = null; 3364 if (lowerName.startsWith('claude agent')) agentType = 'claude'; 3365 else if (lowerName.startsWith('codex agent')) agentType = 'codex'; 3366 else if (scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'))) { 3367 // Legacy / un-prefixed scoped tokens fall back to Codex for backwards compat. 3368 agentType = 'codex'; 3369 } 3370 if (!agentType) continue; 3371 const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`; 3372 items.push({ type: agentType, id: tok.id, name: tok.name || (agentType === 'claude' ? 'Claude Agent' : 'Codex Agent'), detail, enabled: true, data: tok }); 3373 } 3374 // Vaultwarden removed as an integration option. 3375 return items; 3376 } 3377 3378 function renderCard(item) { 3379 const t = INTG_TYPES[item.type] || INTG_TYPES.api; 3380 // Static enabled/disabled indicator â same dot every integration 3381 // type gets. (The clickable glow-on-test variant for email was 3382 // removed earlier; this matches the API/CalDAV/MCP pattern.) 3383 const statusDot = item.enabled 3384 ? '<span style="width:8px;height:8px;border-radius:50%;background:var(--color-success,#50fa7b);flex-shrink:0;--notif-glow:var(--color-success,#50fa7b);animation:cookbook-notif-pulse 2s ease-in-out infinite;" title="Active"></span>' 3385 : '<span style="width:8px;height:8px;border-radius:50%;background:var(--fg);opacity:0.3;flex-shrink:0" title="Disabled"></span>'; 3386 return `<div class="intg-card" data-intg-id="${item.id}" data-intg-type="${item.type}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb, var(--fg) 3%, transparent);margin-bottom:6px;cursor:pointer;transition:all 0.15s;" title="Click to edit"> 3387 <span style="opacity:0.6;flex-shrink:0">${t.icon}</span> 3388 <div style="flex:1;min-width:0"> 3389 <div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${item.name} <span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 5px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 50%, transparent);border-radius:3px;color:var(--accent, var(--red));background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);">${t.label}</span></div> 3390 <div style="font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${item.detail || ''}</div> 3391 </div> 3392 ${statusDot} 3393 <button class="admin-btn-sm intg-del-btn" data-intg-id="${item.id}" data-intg-type="${item.type}" data-intg-name="${(item.name || '').replace(/"/g, '"')}" title="Remove" style="background:none;border:none;padding:4px;cursor:pointer;color:var(--red);opacity:0.55;display:inline-flex;align-items:center;justify-content:center;"> 3394 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> 3395 </button> 3396 </div>`; 3397 } 3398 3399 async function renderList() { 3400 const items = await fetchAll(); 3401 const noticeHtml = integrationNotice ? ` 3402 <div class="intg-followup-note" style="display:flex;align-items:center;gap:8px;padding:8px 10px;margin-bottom:8px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:5px;background:color-mix(in srgb, var(--accent, var(--red)) 8%, transparent);font-size:11px;">
3403 <span style="flex:1;line-height:1.35">${integrationNotice}</span> 3404 <button type="button" class="admin-btn-sm intg-open-email-settings" style="white-space:nowrap;">Email settings</button> 3405 </div>` : ''; 3406 if (items.length === 0) { 3407 listEl.innerHTML = noticeHtml + '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center">No integrations configured</div>'; 3408 } else { 3409 listEl.innerHTML = noticeHtml + items.map(renderCard).join(''); 3410 } 3411 listEl.querySelector('.intg-open-email-settings')?.addEventListener('click', (e) => { 3412 e.stopPropagation(); 3413 _openEmailSettings(); 3414 }); 3415 // Wire edit clicks 3416 listEl.querySelectorAll('.intg-card').forEach(card => { 3417 card.addEventListener('click', (e) => { 3418 if (e.target.closest('.intg-del-btn')) return; 3419 const type = card.dataset.intgType; 3420 const id = card.dataset.intgId; 3421 const items2 = listEl.querySelectorAll('.intg-card'); 3422 items2.forEach(c => c.style.borderColor = ''); 3423 card.style.borderColor = 'var(--accent)'; 3424 showForm(type, id); 3425 }); 3426 }); 3427 // Wire delete 3428 listEl.querySelectorAll('.intg-del-btn').forEach(btn => { 3429 btn.addEventListener('click', async (e) => { 3430 e.stopPropagation(); 3431 const intgName = btn.dataset.intgName || 'this integration'; 3432 if (!await window.styledConfirm(`Remove "${intgName}"?`, { confirmText: 'Remove', danger: true })) return; 3433 const type = btn.dataset.intgType; 3434 const id = btn.dataset.intgId; 3435 try { 3436 if (type === 'api') await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 3437 else if (type === 'caldav') await fetch(`/api/calendar/config/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 3438 else if (type === 'contacts') { 3439 await fetch('/api/contacts/clear', { method: 'DELETE', credentials: 'same-origin' }); 3440 } 3441 else if (type === 'carddav') { 3442 await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carddav_url: '', carddav_username: '', carddav_password: '' }) }); 3443 } 3444 else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 3445 else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 3446 else if (type === 'codex' || type === 'claude') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' }); 3447 else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' }); 3448 } catch (_) {} 3449 formEl.style.display = 'none'; 3450 await renderList(); 3451 notifyIntegrationsChanged(); 3452 }); 3453 }); 3454 } 3455 3456 function showForm(type, editId) { 3457 formEl.style.display = ''; 3458 if (type === 'api') showApiForm(editId); 3459 else if (type === 'caldav') showCalDavForm(editId); 3460 else if (type === 'contacts' || type === 'carddav') showCardDavForm(); 3461 else if (type === 'email') showEmailForm(editId); 3462 else if (type === 'mcp') showMcpForm(editId); 3463 else if (type === 'codex') showAgentForm('codex', editId); 3464 else if (type === 'claude') showAgentForm('claude', editId); 3465 else if (type === 'vault') showVaultForm(); 3466 } 3467 3468 // ââ API form ââ 3469 async function showApiForm(editId) { 3470 let presets = {}; 3471 try { 3472 const r = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' }); 3473 if (r.ok) { const d = await r.json(); presets = d.presets || {}; } 3474 } catch (_) {} 3475 const presetEntries = Object.entries(presets); 3476 // Same `?` hint helper as the email form. Native title tooltip, 3477 // tabbable for keyboard users. Inline-styled so it doesn't need 3478 // a CSS dependency. 3479 const _apiHint = (tip) => 3480 `<span class="uf-hint" title="${esc(tip.replace(/<[^>]+>/g, ''))}" aria-label="${esc(tip.replace(/<[^>]+>/g, ''))}" tabindex="0" ` 3481 + `style="display:inline-block;width:13px;height:13px;border-radius:50%;` 3482 + `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;` 3483 + `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
3484 // Real <select> instead of <datalist>: datalists are silently 3485 // suppressed in Firefox when autocomplete="off" is on the input, 3486 // and they're patchy on mobile browsers. A native select renders 3487 // the same everywhere and makes the available options visible 3488 // without needing the user to type. 3489 const sortedPresets = presetEntries.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0])); 3490 const selectOpts = sortedPresets 3491 .map(([k, p]) => `<option value="${k}">${esc(p.name || k)}</option>`) 3492 .join(''); 3493 // Letter-in-brand-color logo for each API preset; outline plug icon for 3494 // "Custom (no preset)". Matches the email-provider dropdown pattern. 3495 const _apiLetter = (letter, bg) => `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`; 3496 const _apiCustomIco = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'; 3497 const API_PRESET_LOGO = { 3498 miniflux: _apiLetter('M', '#214c87'), 3499 gitea: _apiLetter('G', '#609926'), 3500 linkding: _apiLetter('L', '#1f2937'), 3501 home_assistant: _apiLetter('H', '#41bdf5'), 3502 ntfy: _apiLetter('n', '#317f43'), 3503 vaultwarden: _apiLetter('V', '#175ddc'), 3504 freshrss: _apiLetter('R', '#ef6c00'), 3505 }; 3506 const _apiIconFor = (k) => { 3507 if (!k) return _apiCustomIco; 3508 if (API_PRESET_LOGO[k]) return API_PRESET_LOGO[k]; 3509 const first = (presets[k]?.name || k).trim().charAt(0).toUpperCase() || '?'; 3510 return _apiLetter(first, '#6b7280'); 3511 }; 3512 const _apiRows = [['', 'Custom (no preset)'], ...sortedPresets.map(([k, p]) => [k, p.name || k])] 3513 .map(([k, label]) => `<button type="button" class="ufapi-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${_apiIconFor(k)}<span>${esc(label)}</span></button>`).join(''); 3514 formEl.innerHTML = ` 3515 <div class="admin-card" style="margin-top:8px"> 3516 <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>API Integration</h2> 3517 <div class="settings-col"> 3518 <div class="settings-row"><label class="settings-label">Preset</label> 3519 <div style="position:relative;flex:1;min-width:0;"> 3520 <select id="uf-api-preset" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom (no preset)</option>${selectOpts}</select> 3521 <button type="button" id="uf-api-preset-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;"> 3522 <span class="ufapi-icon" style="display:inline-flex;align-items:center;">${_apiCustomIco}</span> 3523 <span class="ufapi-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom (no preset)</span> 3524 <span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">â¾</span> 3525 </button> 3526 <div id="uf-api-preset-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_apiRows}</div> 3527 </div> 3528 </div> 3529 <div class="settings-row"><label class="settings-label">
3529Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div> 3530 <div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div> 3531 <div id="uf-api-ntfy-hint" style="display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;"></div> 3532 <div class="settings-row"><label class="settings-label">Auth${_apiHint('How this service expects the credential to be sent. <b>Bearer</b> = sends "Authorization: Bearer YOUR_KEY" (most modern APIs, ntfy, OpenAI-style). <b>Header</b> = sends YOUR_KEY verbatim under a header name you choose (Miniflux uses X-Auth-Token). <b>Basic</b> = HTTP basic auth (user:pass). <b>None</b> = the API is open / no auth.')}</label><select id="uf-api-auth" class="settings-input"><option value="bearer">Bearer (most common)</option><option value="header">Header</option><option value="basic">Basic</option><option value="none">None</option></select></div> 3533 <div class="settings-row" id="uf-api-header-row"><label class="settings-label">Header${_apiHint('The HTTP header name the key goes under (Miniflux: X-Auth-Token; most others: Authorization). Only used when Auth = Header.')}</label><input id="uf-api-header" class="settings-input" placeholder="X-Auth-Token"></div> 3534 <div class="settings-row"><label class="settings-label">API Key${_apiHint('The secret token the service issued you (generated in its admin panel / settings). Used to prove your identity on each request. Required for any Auth mode except None.')}</label><input id="uf-api-key" class="settings-input" type="password" placeholder="Token/key"></div> 3535 <div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-api-save">Save</button><button class="admin-btn-sm" id="uf-api-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-api-cancel" style="opacity:0.7">Cancel</button><span id="uf-api-msg" style="font-size:11px"></span></div> 3536 </div> 3537 </div>`; 3538 // Custom preset dropdown wire-up (hidden select stays as data source). 3539 (() => { 3540 const trig = el('uf-api-preset-trigger'); 3541 const menu = el('uf-api-preset-menu'); 3542 const sel = el('uf-api-preset'); 3543 if (!trig || !menu || !sel) return; 3544 const lbl = trig.querySelector('.ufapi-label'); 3545 const ico = trig.querySelector('.ufapi-icon'); 3546 const _setFromKey = (k) => { 3547 const row = menu.querySelector(`.ufapi-option[data-value="${k}"]`); 3548 const text = row?.querySelector('span')?.textContent || 'Custom (no preset)'; 3549 if (lbl) lbl.textContent = text; 3550 if (ico) ico.innerHTML = _apiIconFor(k); 3551 }; 3552 const _close = () => { menu.style.display = 'none'; }; 3553 const _open = () => { 3554 menu.style.display = 'block'; 3555 const tRect = trig.getBoundingClientRect(); 3556 const mRect = menu.getBoundingClientRect(); 3557 const below = window.innerHeight - tRect.bottom; 3558 const above = tRect.top; 3559 if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; } 3560 else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; } 3561 const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } }; 3562 setTimeout(() => document.addEventListener('click', onDoc, true), 0); 3563 }; 3564 trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); }); 3565 menu.querySelectorAll('.ufapi-option').forEach(btn => { 3566 btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); 3567 btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); 3568 btn.addEventListener('click', (e) => { 3569 e.stopPropagation(); 3570 const k = btn.dataset.value || ''; 3571 sel.value = k; 3572 _setFromKey(k); 3573 _close(); 3574 sel.dispatchEvent(new Event('change', { bubbles: true })); 3575 }); 3576 }); 3577 _setFromKey(sel.value || ''); 3578 })(); 3579 3580 const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint'); 3581 let _editId = editId && editId !== 'new' ? editId : null; 3582 // Load existing 3583 if (_editId) {
3584 try { 3585 const r = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); 3586 const d = await r.json(); 3587 const item = (d.integrations || []).find(i => i.id === _editId); 3588 if (item) { name.value = item.name || ''; url.value = item.base_url || ''; auth.value = item.auth_type || 'none'; header.value = item.auth_header || ''; } 3589 } catch (_) {} 3590 } 3591 // Native <select>: the option `value` is the preset key directly, so 3592 // no typed-name â key lookup is needed (datalist-era leftover). 3593 const _applyPreset = () => { 3594 const p = presets[preset.value]; 3595 const isNtfy = preset.value === 'ntfy' || (p && (p.name || '').toLowerCase() === 'ntfy'); 3596 const isUrlAuth = preset.value === 'discord_webhook'; // secret embedded in URL â no key/auth fields needed 3597 if (ntfyHint) { 3598 ntfyHint.style.display = isNtfy ? 'block' : 'none'; 3599 if (isNtfy) { 3600 ntfyHint.innerHTML = 'Enter the ntfy server URL Odysseus can reach. Examples: <code>http://127.0.0.1:8091</code>, <code>http://100.x.y.z:8091</code>, or <code>https://ntfy.example.com</code>.'; 3601 } 3602 } 3603 if (url) { 3604 url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : isUrlAuth ? 'https://discord.com/api/webhooks/...' : 'http://localhost:8080'; 3605 } 3606 // For presets that embed the secret in the URL, hide auth/key/header rows 3607 // so users aren't confused into thinking they need to fill them in. 3608 const keyRow = key?.closest('.settings-row'); 3609 const authRow = auth?.closest('.settings-row'); 3610 const headerRow = el('uf-api-header-row'); 3611 if (keyRow) keyRow.style.display = isUrlAuth ? 'none' : ''; 3612 if (authRow) authRow.style.display = isUrlAuth ? 'none' : ''; 3613 if (headerRow) headerRow.style.display = isUrlAuth ? 'none' : ''; 3614 if (!p) return; 3615 name.value = p.name || ''; 3616 auth.value = p.auth_type || 'none'; 3617 header.value = p.auth_header || ''; 3618 }; 3619 preset.addEventListener('change', _applyPreset); 3620 _applyPreset(); 3621 el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 3622 el('uf-api-save').addEventListener('click', async () => { 3623 const presetKey = preset.value || undefined; 3624 const body = { name: name.value, base_url: url.value, auth_type: auth.value, auth_header: header.value, preset: presetKey }; 3625 if (key.value) body.api_key = key.value; 3626 try { 3627 const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations'; 3628 const m = _editId ? 'PUT' : 'POST'; 3629 const r = await fetch(u, { method: m, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); 3630 if (!r.ok) throw new Error(); 3631 const saved = await r.json().catch(() => null); 3632 // If this was a create, capture the new ID so Test works 3633 // immediately without needing a form reopen. The POST response 3634 // shape is {ok, integration: {id, ...}} â saved.id at the top 3635 // level would silently miss, leaving Test perpetually stuck on 3636 // "Save first" until the form was reopened. 3637 if (!_editId && saved) _editId = saved.integration?.id || saved.id; 3638 el('uf-api-msg').textContent = 'Saved'; el('uf-api-msg').style.color = 'var(--green,#50fa7b)'; 3639 await renderList(); 3640 notifyIntegrationsChanged(); 3641 } catch (_) { el('uf-api-msg').textContent = 'Failed'; el('uf-api-msg').style.color = 'var(--red)'; } 3642 }); 3643 el('uf-api-test').addEventListener('click', async () => { 3644 if (!_editId) { el('uf-api-msg').textContent = 'Save first'; return; } 3645 try { 3646 const r = await fetch(`/api/auth/integrations/${_editId}/test`, { method: 'POST', credentials: 'same-origin' }); 3647 const d = await r.json(); 3648 // Backend returns {ok: bool, message: str} 3649 if (d.ok) { 3650 el('uf-api-msg').textContent = d.message || 'Connected'; 3651 el('uf-api-msg').style.color = 'var(--green,#50fa7b)'; 3652 } else { 3653 el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 360); 3654 el('uf-api-msg').style.color = 'var(--red)'; 3655 } 3656 } catch (e) { el('uf-api-msg').textContent = 'Error: ' + e.message; el('uf-api-msg').style.color = 'var(--red)'; } 3657 }); 3658 } 3659 3660 // ââ CalDAV form (supports add + edit per account) ââ 3661 async function showCalDavForm(editId) { 3662 const isNew = !editId || editId === 'new'; 3663 formEl.innerHTML = ` 3664 <div class="admin-card" style="margin-top:8px"> 3665 <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${isNew ? 'Add CalDAV Calendar' : 'Edit CalDAV Calendar'}</h2> 3666 <div class="settings-col"> 3667 <div class="settings-row"><label class="settings-label">Label</label><input id="uf-caldav-label" class="settings-input" placeholder="e.g. Work, Personal"></div> 3668 <div class="settings-row"><label class="settings-label">Server URL</label><input id="uf-caldav-url" class="settings-input" placeholder="https://www.google.com/calendar/dav/[email protected]/user/"></div> 3669 <div class="settings-row"><label class="settings-label">Username</label><input id="uf-caldav-user" class="settings-input" placeholder="[email protected]"></div> 3670 <div class="settings-row"><label class="settings-label">Password</label><input id="uf-caldav-pass" class="settings-input" type="password" placeholder="${isNew ? '' : 'Leave blank to keep existing'}"></div> 3671 <div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-caldav-save">Save</button><button class="admin-btn-sm" id="uf-caldav-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-caldav-cancel" style="opacity:0.7">Cancel</button><span id="uf-caldav-msg" style="font-size:11px;margin-left:6px"></span></div> 3672 </div> 3673 </div>`; 3674 3675 if (!isNew) {
3676 try { 3677 const r = await fetch('/api/calendar/config/accounts', { credentials: 'same-origin' }); 3678 const d = await r.json(); 3679 const acc = (d.accounts || []).find(a => a.id === editId); 3680 if (acc) { 3681 el('uf-caldav-label').value = acc.label || ''; 3682 el('uf-caldav-url').value = acc.url || ''; 3683 el('uf-caldav-user').value = acc.username || ''; 3684 } 3685 } catch (_) {} 3686 } 3687 3688 el('uf-caldav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 3689 3690 const _runCalDavTest = async () => { 3691 const body = { 3692 url: el('uf-caldav-url').value.trim(), 3693 username: el('uf-caldav-user').value.trim(), 3694 password: el('uf-caldav-pass').value, 3695 }; 3696 if (!isNew && !body.password) body.account_id = editId; 3697 try { 3698 const r = await fetch('/api/calendar/test', { 3699 method: 'POST', credentials: 'same-origin', 3700 headers: { 'Content-Type': 'application/json' }, 3701 body: JSON.stringify(body), 3702 }); 3703 return await r.json(); 3704 } catch (e) { 3705 return { ok: false, error: 'Network error: ' + e.message }; 3706 } 3707 }; 3708 3709 const _setCalDavMsg = (text, ok) => { 3710 const msg = el('uf-caldav-msg'); 3711 msg.textContent = text; 3712 msg.style.color = ok ? 'var(--green, #50fa7b)' : 'var(--red)'; 3713 }; 3714 3715 el('uf-caldav-save').addEventListener('click', async () => { 3716 _setCalDavMsg('Testingâ¦', true); 3717 el('uf-caldav-msg').style.color = ''; 3718 const d = await _runCalDavTest(); 3719 if (!d.ok) { 3720 _setCalDavMsg(d.error || 'Connection failed â not saved', false); 3721 return; 3722 } 3723 try { 3724 const payload = { 3725 label: el('uf-caldav-label').value.trim(), 3726 url: el('uf-caldav-url').value.trim(), 3727 username: el('uf-caldav-user').value.trim(), 3728 password: el('uf-caldav-pass').value, 3729 }; 3730 let resp; 3731 if (isNew) { 3732 resp = await fetch('/api/calendar/config/accounts', { 3733 method: 'POST', credentials: 'same-origin', 3734 headers: { 'Content-Type': 'application/json' }, 3735 body: JSON.stringify(payload), 3736 }); 3737 } else { 3738 resp = await fetch(`/api/calendar/config/accounts/${editId}`, { 3739 method: 'PUT', credentials: 'same-origin', 3740 headers: { 'Content-Type': 'application/json' }, 3741 body: JSON.stringify(payload), 3742 }); 3743 } 3744 if (!resp.ok) { 3745 const err = await resp.json().catch(() => ({})); 3746 _setCalDavMsg(err.detail || 'Save failed', false); 3747 return; 3748 } 3749 _setCalDavMsg('Saved', true); 3750 formEl.style.display = 'none'; 3751 await renderList(); 3752 notifyIntegrationsChanged(); 3753 } catch (_) { 3754 _setCalDavMsg('Save failed', false); 3755 } 3756 }); 3757 3758 el('uf-caldav-test').addEventListener('click', async () => { 3759 _setCalDavMsg('Testingâ¦', true); 3760 el('uf-caldav-msg').style.color = ''; 3761 const d = await _runCalDavTest(); 3762 _setCalDavMsg(d.ok ? 'Connected' : (d.error || 'Failed'), d.ok); 3763 }); 3764 } 3765 3766 // ââ CardDAV form + contacts manager ââ 3767 async function showCardDavForm() { 3768 formEl.innerHTML = ` 3769 <div class="admin-card" style="margin-top:8px"> 3770 <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Contacts (CardDAV)</h2> 3771 <div class="settings-col"> 3772 <div class="settings-row"><label class="settings-label">URL</label><input id="uf-carddav-url" class="settings-input" placeholder="http://localhost:5232/user/contacts/"></div> 3773 <div class="settings-row"><label class="settings-label">Username</label><input id="uf-carddav-user" class="settings-input"></div> 3774 <div class="settings-row"><label class="settings-label">Password</label><input id="uf-carddav-pass" class="settings-input" type="password"></div> 3775 <div class="settings-row" style="margin-top:8px;align-items:center;"> 3776 <button class="admin-btn-add" id="uf-carddav-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
3777 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> 3778 Save 3779 </button> 3780 <span id="uf-carddav-msg" style="font-size:11px;flex:1;margin-left:8px"></span> 3781 <button class="admin-btn-add" id="uf-carddav-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;"> 3782 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 3783 Cancel 3784 </button> 3785 </div> 3786 </div> 3787 </div> 3788 <div class="admin-card contacts-manager" style="margin-top:8px"> 3789 <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"> 3790 <h2 style="font-size:13px;margin:0;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Contacts Import <span id="cm-count" style="opacity:0.5;font-weight:normal;font-size:11px;"></span></h2> 3791 <button class="admin-btn-sm" id="cm-import-btn" style="margin-left:auto;">Import</button> 3792 <button class="admin-btn-sm" id="cm-export-vcf-btn">Export .vcf</button> 3793 <button class="admin-btn-sm" id="cm-export-csv-btn">Export .csv</button> 3794 <button class="admin-btn-sm" id="cm-add-toggle">+ Add</button> 3795 <input type="file" id="cm-import-file" accept=".vcf,.csv,text/vcard,text/csv" multiple style="display:none"> 3796 </div> 3797 <div id="cm-add-row" class="contacts-add-row" style="display:none;"> 3798 <input id="cm-add-name" class="settings-input" placeholder="Name" style="flex:1;min-width:0;"> 3799 <input id="cm-add-email" class="settings-input" placeholder="[email protected]" style="flex:1;min-width:0;"> 3800 <button class="admin-btn-sm" id="cm-add-save">Save</button> 3801 </div> 3802 <div id="cm-list" class="contacts-list"><div style="opacity:0.4;font-size:11px;padding:8px 2px;">Loadingâ¦</div></div> 3803 </div>`; 3804 try { 3805 const r = await fetch('/api/contacts/config', { credentials: 'same-origin' }); const d = await r.json(); 3806 el('uf-carddav-url').value = d.url || ''; el('uf-carddav-user').value = d.username || ''; 3807 } catch (_) {} 3808 el('uf-carddav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 3809 el('uf-carddav-save').addEventListener('click', async () => { 3810 const body = { carddav_url: el('uf-carddav-url').value, carddav_username: el('uf-carddav-user').value }; 3811 if (el('uf-carddav-pass').value) body.carddav_password = el('uf-carddav-pass').value; 3812 try { 3813 await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); 3814 el('uf-carddav-msg').textContent = 'Saved'; 3815 el('uf-carddav-msg').style.color = 'var(--green, #50fa7b)'; 3816 // Refresh both the sub-panel (contacts manager) AND the 3817 // outer integrations list so the CardDAV row appears 3818 // immediately instead of waiting for a page reload. 3819 await _renderContactsManager(); 3820 await renderList(); 3821 notifyIntegrationsChanged(); 3822 } catch (_) { 3823 el('uf-carddav-msg').textContent = 'Failed'; 3824 el('uf-carddav-msg').style.color = 'var(--red)'; 3825 } 3826 }); 3827 // Add-row toggle + save 3828 el('cm-add-toggle')?.addEventListener('click', () => { 3829 const row = el('cm-add-row'); 3830 const open = row.style.display !== 'none'; 3831 row.style.display = open ? 'none' : 'flex'; 3832 if (!open) el('cm-add-name')?.focus(); 3833 }); 3834 el('cm-add-save')?.addEventListener('click', async () => {
3835 const name = el('cm-add-name').value.trim(); 3836 const email = el('cm-add-email').value.trim(); 3837 if (!email) { el('cm-add-email').focus(); return; } 3838 try { 3839 await fetch('/api/contacts/add', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }) }); 3840 } catch (_) {} 3841 el('cm-add-name').value = ''; el('cm-add-email').value = ''; 3842 el('cm-add-row').style.display = 'none'; 3843 await _renderContactsManager(); 3844 }); 3845 const _downloadContacts = async (format) => { 3846 const btn = el(format === 'csv' ? 'cm-export-csv-btn' : 'cm-export-vcf-btn'); 3847 const orig = btn ? btn.textContent : ''; 3848 if (btn) { btn.textContent = 'Exporting...'; btn.disabled = true; } 3849 try { 3850 const res = await fetch(`/api/contacts/export?format=${encodeURIComponent(format)}`, { credentials: 'same-origin' }); 3851 if (!res.ok) throw new Error('Export failed'); 3852 const blob = await res.blob(); 3853 const url = URL.createObjectURL(blob); 3854 const a = document.createElement('a'); 3855 a.href = url; 3856 a.download = format === 'csv' ? 'odysseus-contacts.csv' : 'odysseus-contacts.vcf'; 3857 document.body.appendChild(a); 3858 a.click(); 3859 a.remove(); 3860 setTimeout(() => URL.revokeObjectURL(url), 1000); 3861 } catch (_) { 3862 uiModule.showError ? uiModule.showError('Export failed') : alert('Export failed'); 3863 } finally { 3864 if (btn) { btn.textContent = orig; btn.disabled = false; } 3865 } 3866 }; 3867 el('cm-export-vcf-btn')?.addEventListener('click', () => _downloadContacts('vcf')); 3868 el('cm-export-csv-btn')?.addEventListener('click', () => _downloadContacts('csv')); 3869 3870 // Import .vcf/.csv â read each selected file as text, concatenate by type, 3871 // then POST. Imported CardDAV contacts immediately feed email autocomplete 3872 // because compose searches /api/contacts/search. 3873 el('cm-import-btn')?.addEventListener('click', () => el('cm-import-file')?.click()); 3874 el('cm-import-file')?.addEventListener('change', async (e) => { 3875 const files = Array.from(e.target.files || []); 3876 if (!files.length) return; 3877 const btn = el('cm-import-btn'); 3878 const orig = btn ? btn.textContent : ''; 3879 if (btn) { btn.textContent = 'Importingâ¦'; btn.disabled = true; } 3880 try { 3881 const texts = await Promise.all(files.map(f => f.text())); 3882 const vcfParts = []; 3883 const csvParts = []; 3884 texts.forEach((text, idx) => { 3885 const name = (files[idx]?.name || '').toLowerCase(); 3886 if (name.endsWith('.csv') || !String(text || '').toUpperCase().includes('BEGIN:VCARD')) csvParts.push(text); 3887 else vcfParts.push(text); 3888 }); 3889 let imported = 0, total = 0, failed = 0; 3890 const _postImport = async (body) => { 3891 const r = await fetch('/api/contacts/import', { 3892 method: 'POST', credentials: 'same-origin', 3893 headers: { 'Content-Type': 'application/json' }, 3894 body: JSON.stringify(body), 3895 }); 3896 const d = await r.json(); 3897 if (d.error) throw new Error(d.error); 3898 imported += Number(d.imported || 0); 3899 total += Number(d.total || 0); 3900 failed += Number(d.failed || 0); 3901 }; 3902 if (vcfParts.length) await _postImport({ vcf: vcfParts.join('\n') }); 3903 if (csvParts.length) await _postImport({ csv: csvParts.join('\n') }); 3904 if (!vcfParts.length && !csvParts.length) throw new Error('No contact data found'); 3905 const msg = `Imported ${imported}/${total}` + (failed ? ` (${failed} failed)` : ''); 3906 uiModule.showToast ? uiModule.showToast(msg) : null; 3907 } catch (err) { 3908 uiModule.showError ? uiModule.showError(err?.message || 'Import failed') : alert(err?.message || 'Import failed'); 3909 } finally { 3910 if (btn) { btn.textContent = orig; btn.disabled = false; } 3911 e.target.value = ''; 3912 await _renderContactsManager(); 3913 } 3914 }); 3915 await _renderContactsManager(); 3916 } 3917 3918 // Render the contacts list inside the manager card with inline edit +
3919 // delete. Each row: name + emails; pencil flips to editable inputs. 3920 async function _renderContactsManager() { 3921 const list = el('cm-list'); 3922 if (!list) return; 3923 let contacts = []; 3924 try { 3925 const r = await fetch('/api/contacts/list', { credentials: 'same-origin' }); 3926 const d = await r.json(); 3927 contacts = d.contacts || []; 3928 } catch (_) { 3929 list.innerHTML = '<div style="opacity:0.5;font-size:11px;padding:8px 2px;">Failed to load contacts (check CardDAV config above).</div>'; 3930 return; 3931 } 3932 const cnt = el('cm-count'); 3933 if (cnt) cnt.textContent = contacts.length ? `(${contacts.length})` : ''; 3934 if (!contacts.length) { 3935 list.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 2px;">No contacts yet.</div>'; 3936 return; 3937 } 3938 // Sort by name for a stable list. 3939 contacts.sort((a, b) => (a.name || '').localeCompare(b.name || '')); 3940 list.innerHTML = contacts.map(c => { 3941 const emails = (c.emails || []).join(', '); 3942 const phones = (c.phones || []).join(', '); 3943 const sub = [emails, phones].filter(Boolean).join(' · '); 3944 return `<div class="contact-row" data-uid="${esc(c.uid)}"> 3945 <div class="contact-row-view" style="display:flex;align-items:center;gap:8px;"> 3946 <div style="flex:1;min-width:0;"> 3947 <div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div> 3948 <div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div> 3949 </div> 3950 <button class="admin-btn-sm contact-edit" title="Edit" style="display:inline-flex;align-items:center;gap:4px;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border));"> 3951 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> 3952 Edit 3953 </button> 3954 <button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;"> 3955 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> 3956 Delete 3957 </button> 3958 </div> 3959 <div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;"> 3960 <input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name"> 3961 <input class="settings-input contact-edit-emails" value="${esc(emails)}" placeholder="email1, email2"> 3962 <input class="settings-input contact-edit-phones" value="${esc(phones)}" placeholder="phone1, phone2"> 3963 <div style="display:flex;gap:6px;"><button class="admin-btn-sm contact-save">Save</button><button class="admin-btn-sm contact-cancel" style="opacity:0.7;">Cancel</button></div> 3964 </div> 3965 </div>`; 3966 }).join(''); 3967 // Wire each row's edit / delete / save / cancel. 3968 list.querySelectorAll('.contact-row').forEach(row => { 3969 const uid = row.dataset.uid; 3970 const view = row.querySelector('.contact-row-view'); 3971 const editForm = row.querySelector('.contact-row-edit'); 3972 row.querySelector('.contact-edit')?.addEventListener('click', () => { 3973 view.style.display = 'none'; 3974 editForm.style.display = 'flex'; 3975 }); 3976 row.querySelector('.contact-cancel')?.addEventListener('click', () => { 3977 editForm.style.display = 'none'; 3978 view.style.display = 'flex'; 3979 }); 3980 row.querySelector('.contact-save')?.addEventListener('click', async () => { 3981 const body = { 3982 name: row.querySelector('.contact-edit-name').value.trim(), 3983 emails: row.querySelector('.contact-edit-emails').value.split(',').map(s =>
3983 s.trim()).filter(Boolean), 3984 phones: row.querySelector('.contact-edit-phones').value.split(',').map(s => s.trim()).filter(Boolean), 3985 }; 3986 try { 3987 await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); 3988 } catch (_) {} 3989 await _renderContactsManager(); 3990 }); 3991 row.querySelector('.contact-del')?.addEventListener('click', async () => { 3992 const ok = uiModule.styledConfirm 3993 ? await uiModule.styledConfirm('Delete this contact?', { confirmText: 'Delete', danger: true }) 3994 : window.confirm('Delete this contact?'); 3995 if (!ok) return; 3996 try { 3997 await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'DELETE', credentials: 'same-origin' }); 3998 } catch (_) {} 3999 await _renderContactsManager(); 4000 }); 4001 }); 4002 } 4003 4004 // ââ Email form (multi-account) ââ 4005 // When editId is a real account id, edit that row. When editId is falsy or 'new', 4006 // create a fresh account. Posts to /api/email/accounts, never to the legacy 4007 // /api/email/config which would overwrite the default. 4008 async function showEmailForm(editId) { 4009 const isEdit = editId && editId !== 'new' && editId !== '__email__'; 4010 let existing = null; 4011 if (isEdit) { 4012 try { 4013 const r = await fetch('/api/email/accounts', { credentials: 'same-origin' }); 4014 const d = await r.json(); 4015 existing = (d.accounts || []).find(a => a.id === editId) || null; 4016 } catch (_) {} 4017 } 4018 const placeholderPass = (isEdit && existing) ? '(leave blank to keep current)' : ''; 4019 // Small `?` indicator next to each label (native title tooltip). 4020 const _hint = (tip) => 4021 `<span class="uf-hint" title="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0" ` 4022 + `style="display:inline-block;width:13px;height:13px;border-radius:50%;` 4023 + `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;` 4024 + `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`; 4025 // Provider presets â picking one auto-fills IMAP + SMTP host/port. 4026 // Dovecot is IMAP-only here; the host is intentionally blank because 4027 // it may be remote (DNS, LAN, Tailscale), not localhost. 4028 const PROVIDERS = { 4029 gmail: { label: 'Gmail', emailEx: '[email protected]', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, 4030 migadu: { label: 'Migadu', emailEx: '[email protected]', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, 4031 icloud: { label: 'iCloud', emailEx: '[email protected]', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, 4032 outlook: { label: 'Outlook / Office 365', emailEx: '[email protected]', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, 4033 fastmail: { label: 'Fastmail', emailEx: '[email protected]', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } }, 4034 yahoo: { label: 'Yahoo', emailEx: '[email protected]', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } }, 4035 dovecot: { label: 'Dovecot IMAP (no SMTP)', emailEx: '[email protected]', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } }, 4036 }; 4037 const _providerOptions = Object.entries(PROVIDERS) 4038 .map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join(''); 4039 // Provider logos â small SVGs the custom dropdown renders next to each 4040 // option. Letter-in-brand-color circle for known providers; outline 4041 // envelope for "Customâ¦". Inline SVG (no external assets, no emoji). 4042 const _letterLogo = (letter, bg) =>
4042 `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`; 4043 const _customLogo = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>'; 4044 const PROV_LOGO = { 4045 '': _customLogo, 4046 gmail: _letterLogo('G', '#ea4335'), 4047 migadu: _letterLogo('M', '#3aa39d'), 4048 icloud: _letterLogo('i', '#3693f3'), 4049 outlook: _letterLogo('O', '#0078d4'), 4050 fastmail: _letterLogo('F', '#4a5fbb'), 4051 yahoo: _letterLogo('Y', '#6001d2'), 4052 dovecot: _letterLogo('D', '#6b7280'), 4053 }; 4054 const _provOptionRows = [['', 'Customâ¦'], ...Object.entries(PROVIDERS).map(([k, v]) => [k, v.label])] 4055 .map(([k, label]) => `<button type="button" class="ufp-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${PROV_LOGO[k] || _customLogo}<span>${esc(label)}</span></button>`).join(''); 4056 const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl'); 4057 formEl.innerHTML = ` 4058 <div class="admin-card" style="margin-top:8px"> 4059 <h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2> 4060 <div class="settings-col"> 4061 <div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label> 4062 <div class="ufp-wrap" style="position:relative;flex:1;min-width:0;"> 4063 <select id="uf-email-provider" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Customâ¦</option>${_providerOptions}</select> 4064 <button type="button" id="uf-email-provider-trigger" class="settings-select" style="display:flex;align-items:center;gap:8px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;"> 4065 <span class="ufp-icon" style="display:inline-flex;align-items:center;">${PROV_LOGO['']}</span> 4066 <span class="ufp-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Customâ¦</span> 4067 <span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">â¾</span> 4068 </button> 4069 <div id="uf-email-provider-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:280px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_provOptionRows}</div> 4070 </div> 4071 </div> 4072 <div id="uf-email-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div> 4073 <div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. âWorkâ or âPersonalâ). Leave blank to use the email address.')}</label><input id="uf-email-name" class="settings-input" placeholder="(optional â leave blank to use email)"></div> 4074 <div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="uf-email-from" class="settings-input" placeholder="[email protected]"></div> 4075 <div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>IMAP (Receiving)</div> 4076 <div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div> 4077 <div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div> 4078 <div class="settings-row"><label class="settings-label">Username${_hint('Yes â your full email address goes here too (e.g. [email protected]). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="[email protected]"></div> 4079 <div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password â those are blocked for IMAP). For Migadu, Fastmail, Outlook, etc.: your regular mailbox password works.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div> 4080 <div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Tur
4080n OFF for port 993 (IMAPS â already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div> 4081 <div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">â optional, leave blank for read-only</span></div> 4082 <div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div> 4083 <div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></div> 4084 <div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div> 4085 <div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div> 4086 <div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div> 4087 <div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password â often the same as your IMAP password.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div> 4088 <div class="settings-row" style="margin-top:4px"><label class="settings-label">Default${_hint('Use this account whenever no specific account is chosen.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-email-default"><span class="admin-slider"></span></label><span style="font-size:10px;opacity:0.5;margin-left:6px">Used when nothing else is selected</span></div> 4089 <div class="settings-row" style="margin-top:10px;align-items:center;"> 4090 <button class="admin-btn-add" id="uf-email-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;"> 4091 <span class="uf-email-save-ico" style="display:inline-flex;width:11px;height:11px;align-items:center;justify-content:center;">
4092 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> 4093 </span> 4094 <span class="uf-email-save-label">${isEdit ? 'Save' : 'Create'}</span> 4095 </button> 4096 <button class="admin-btn-add" id="uf-email-test" style="display:inline-flex;align-items:center;gap:5px;opacity:0.85;"> 4097 <span class="uf-email-test-ico" style="display:inline-flex;width:11px;height:11px;align-items:center;justify-content:center;"> 4098 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 4 12 14.01 9 11.01"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg> 4099 </span> 4100 Test 4101 </button> 4102 <span id="uf-email-msg" style="font-size:11px;flex:1;margin-left:8px"></span> 4103 <button class="admin-btn-add" id="uf-email-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
4104 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 4105 Cancel 4106 </button> 4107 </div> 4108 </div> 4109 </div>`; 4110 4111 // Provider-specific helper notes â surfaces for providers that 4112 // require an app-specific password (Gmail killed basic IMAP auth 4113 // in 2022; iCloud + Yahoo follow the same model). The Generate 4114 // button opens the right page in a new tab and copies the URL for 4115 // mobile / cross-device flows. 4116 const PROVIDER_NOTES = { 4117 gmail: { 4118 title: 'Gmail needs an App Password', 4119 body: 'Your regular Google password won\'t work for IMAP. Generate a 16-character App Password (requires 2-Step Verification enabled) and paste it as the Password.', 4120 url: 'https://myaccount.google.com/apppasswords', 4121 }, 4122 icloud: { 4123 title: 'iCloud needs an App-S
4123pecific Password', 4124 body: 'Sign in to your Apple ID, go to Sign-In and Security â App-Specific Passwords, and generate one (requires 2FA on your Apple ID).', 4125 url: 'https://account.apple.com/account/manage', 4126 }, 4127 yahoo: { 4128 title: 'Yahoo needs an App Password', 4129 body: 'Generate an App Password from Yahoo Account Security (requires 2-Step Verification enabled) and paste it as the Password.', 4130 url: 'https://login.yahoo.com/account/security/app-passwords', 4131 }, 4132 }; 4133 const noteEl = el('uf-email-provider-note'); 4134 const _copyProviderUrl = async (text) => { 4135 const value = String(text || ''); 4136 if (!value) return false; 4137 if (navigator.clipboard && window.isSecureContext) { 4138 try { 4139 await navigator.clipboard.writeText(value); 4140 return true; 4141 } catch (_) { 4142 // Fall through to the textarea path below. 4143 } 4144 } 4145 const ta = document.createElement('textarea'); 4146 ta.value = value; 4147 ta.setAttribute('readonly', 'readonly'); 4148 ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;'; 4149 document.body.appendChild(ta); 4150 ta.focus(); 4151 ta.select(); 4152 ta.setSelectionRange(0, value.length); 4153 let ok = false; 4154 try { ok = document.execCommand('copy'); } catch (_) { ok = false; } 4155 ta.remove(); 4156 return ok; 4157 }; 4158 if (noteEl && !noteEl._ufProviderCopyWired) { 4159 noteEl._ufProviderCopyWired = true; 4160 noteEl.addEventListener('click', async (e) => { 4161 const copyBtn = e.target.closest?.('.uf-prov-copy'); 4162 if (!copyBtn || !noteEl.contains(copyBtn)) return; 4163 e.preventDefault(); 4164 e.stopPropagation(); 4165 const url = copyBtn.dataset.url || ''; 4166 const orig = copyBtn.innerHTML; 4167 const ok = await _copyProviderUrl(url); 4168 if (!ok) { 4169 uiModule.showError?.('Copy failed'); 4170 return; 4171 } 4172 uiModule.showToast?.('Copied'); 4173 copyBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Copied'; 4174 setTimeout(() => { 4175 if (copyBtn.isConnected) copyBtn.innerHTML = orig; 4176 }, 1500); 4177 }); 4178 } 4179 const _renderProviderNote = (key) => { 4180 const n = PROVIDER_NOTES[key]; 4181 if (!n) { noteEl.style.display = 'none'; noteEl.innerHTML = ''; return; } 4182 noteEl.style.display = ''; 4183 noteEl.innerHTML = ` 4184 <div style="font-weight:600;margin-bottom:3px;">${esc(n.title)}</div> 4185 <div style="opacity:0.8;margin-bottom:6px;">${esc(n.body)}</div> 4186 <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;"> 4187 <a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer" class="admin-btn-sm" style="background:var(--red);border-color:var(--red);color:#fff;text-decoration:none;display:inline-flex;align-items:center;gap:5px;font-weight:600;"> 4188 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg> 4189 Generate App Password 4190 </a> 4191 <button type="button" class="admin-btn-sm uf-prov-copy" data-url="${esc(n.url)}" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;"> 4192 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> 4193 Copy link 4194 </button> 4195 </div>`; 4196 }; 4197 4198 // Custom dropdown wire-up â the native <select> stays in the DOM as the 4199 // data source and accessibility target, but the visible UI is a button + 4200 // popup so each provider row can render with its SVG logo. Selecting an 4201 // option updates select.value and dispatches a `change` event so the 4202 // existing autofill handler below runs unchanged. 4203 (() => { 4204 const trigger = el('uf-email-provider-trigger'); 4205 const menu = el('uf-email-provider-menu'); 4206 const sel = el('uf-email-provider'); 4207 if (!trigger || !menu || !sel) return; 4208 const labelEl = trigger.querySelector('.ufp-label'); 4209 const iconEl = trigger.querySelector('.ufp-icon'); 4210 const _setFromKey = (k) => {
4211 const row = menu.querySelector(`.ufp-option[data-value="${k}"]`); 4212 const lbl = row?.querySelector('span')?.textContent || 'Customâ¦'; 4213 if (labelEl) labelEl.textContent = lbl; 4214 if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo; 4215 }; 4216 const _closeMenu = () => { menu.style.display = 'none'; }; 4217 const _openMenu = () => { 4218 menu.style.display = 'block'; 4219 // Drop-up when there's not enough room below the trigger. 4220 const tRect = trigger.getBoundingClientRect(); 4221 const mRect = menu.getBoundingClientRect(); 4222 const below = window.innerHeight - tRect.bottom; 4223 const above = tRect.top; 4224 if (mRect.height > below && above > below) { 4225 menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; 4226 } else { 4227 menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; 4228 } 4229 const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; 4230 setTimeout(() => document.addEventListener('click', onDoc, true), 0); 4231 }; 4232 trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); }); 4233 menu.querySelectorAll('.ufp-option').forEach(btn => { 4234 btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); 4235 btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); 4236 btn.addEventListener('click', (e) => { 4237 e.stopPropagation(); 4238 const k = btn.dataset.value || ''; 4239 sel.value = k; 4240 _setFromKey(k); 4241 _closeMenu(); 4242 sel.dispatchEvent(new Event('change', { bubbles: true })); 4243 }); 4244 }); 4245 _setFromKey(sel.value || ''); 4246 })(); 4247 4248 // Provider preset â autofill IMAP + SMTP host/port + STARTTLS, set the 4249 // helper note, and update the Email/Username placeholders to a 4250 // provider-specific example so users see the right format at a glance. 4251 el('uf-email-provider').addEventListener('change', (e) => { 4252 const key = e.target.value; 4253 _renderProviderNote(key); 4254 const p = PROVIDERS[key]; 4255 if (!p) return; 4256 el('uf-imap-host').value = p.imap.host; 4257 el('uf-imap-port').value = p.imap.port; 4258 el('uf-imap-starttls').checked = !!p.imap.starttls; 4259 el('uf-smtp-host').value = p.smtp.host; 4260 el('uf-smtp-port').value = p.smtp.port; 4261 el('uf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl'); 4262 if (p.emailEx) { 4263 el('uf-email-from').placeholder = p.emailEx; 4264 el('uf-imap-user').placeholder = p.emailEx; 4265 el('uf-smtp-user').placeholder = p.emailEx; 4266 } 4267 }); 4268 4269 // "Same as IMAP" toggle â hide the SMTP creds rows when on. 4270 const _syncSmtpSame = () => { 4271 const same = el('uf-smtp-same').checked; 4272 formEl.querySelectorAll('.uf-smtp-creds').forEach(r => { 4273 r.style.display = same ? 'none' : ''; 4274 }); 4275 }; 4276 el('uf-smtp-same').addEventListener('change', _syncSmtpSame); 4277 _syncSmtpSame(); 4278 if (existing) { 4279 el('uf-email-name').value = existing.name || ''; 4280 el('uf-email-from').value = existing.from_address || ''; 4281 el('uf-imap-host').value = existing.imap_host || ''; 4282 el('uf-imap-port').value = existing.imap_port || 993; 4283 el('uf-imap-user').value = existing.imap_user || ''; 4284 el('uf-imap-starttls').checked = existing.imap_starttls !== false; 4285 el('uf-smtp-host').value = existing.smtp_host || ''; 4286 el('uf-smtp-port').value = existing.smtp_port || 465; 4287 el('uf-smtp-security').value = _smtpSecurity(existing); 4288 el('uf-smtp-user').value = existing.smtp_user || ''; 4289 el('uf-email-default').checked = !!existing.is_default; 4290 // If the saved SMTP user matches the IMAP user, keep the "Same as 4291 // IMAP" toggle ON (and stay hidden). Otherwise turn it off so the 4292 // separate SMTP credentials are visible for editing. 4293 const sameCreds = !!(existing.imap_user && existing.smtp_user && existing.imap_user === existing.smtp_user); 4294 el('uf-smtp-same').checked = sameCreds || !existing.smtp_user; 4295 _syncSmtpSame(); 4296 } else { 4297 el('uf-imap-port').value = 993; 4298 el('uf-smtp-port').value = 465; 4299 el('uf-smtp-security').value = 'ssl'; 4300 } 4301 el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 4302
4303 // Reset the Test button to neutral when the user edits any field 4304 // after a test â stale green/red would imply the new values were 4305 // tested too. 4306 const _resetTestBtn = () => { 4307 const btn = el('uf-email-test'); 4308 if (!btn) return; 4309 btn.style.background = ''; 4310 btn.style.borderColor = ''; 4311 btn.style.color = ''; 4312 btn.style.boxShadow = ''; 4313 btn.style.animation = ''; 4314 const ico = btn.querySelector('.uf-email-test-ico'); 4315 if (ico && btn.dataset.origIco) ico.innerHTML = btn.dataset.origIco; 4316 }; 4317 formEl.querySelectorAll('input, select').forEach(inp => { 4318 if (inp.id === 'uf-email-msg') return; 4319 inp.addEventListener('input', _resetTestBtn); 4320 inp.addEventListener('change', _resetTestBtn); 4321 }); 4322 4323 // Collect the current form values + apply the "Same as IMAP" mirror â 4324 // shared by both Save and Test so they agree on what's being sent. 4325 const _collectBody = () => { 4326 const body = { 4327 name: el('uf-email-name').value.trim(), 4328 from_address: el('uf-email-from').value.trim(), 4329 imap_host: el('uf-imap-host').value.trim(), 4330 imap_port: parseInt(el('uf-imap-port').value) || 993, 4331 imap_user: el('uf-imap-user').value.trim(), 4332 imap_starttls: el('uf-imap-starttls').checked, 4333 smtp_host: el('uf-smtp-host').value.trim(), 4334 smtp_port: parseInt(el('uf-smtp-port').value) || 465, 4335 smtp_security: el('uf-smtp-security').value, 4336 smtp_user: el('uf-smtp-user').value.trim(), 4337 is_default: el('uf-email-default').checked, 4338 }; 4339 if (el('uf-imap-pass').value) body.imap_password = el('uf-imap-pass').value; 4340 if (el('uf-smtp-pass').value) body.smtp_password = el('uf-smtp-pass').value; 4341 if (el('uf-smtp-same').checked) { 4342 body.smtp_user = body.imap_user; 4343 if (body.imap_password) body.smtp_password = body.imap_password; 4344 } 4345 return body; 4346 }; 4347 4348 // Spinner SVG kept inline so we can swap it back to the original 4349 // checkmark on completion. ~13px to match the button icon size. 4350 const _spinner = '<span style="display:inline-block;width:11px;height:11px;border-radius:50%;border:1.5px solid currentColor;border-top-color:transparent;animation:whirlpool-spin 0.7s linear infinite"></span>';
4351 const _checkIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>'; 4352 4353 el('uf-email-test').addEventListener('click', async () => { 4354 const body = _collectBody(); 4355 // Edit-mode + blank password = use the saved row's stored creds 4356 // via the account_id shortcut. Other overrides in the body still 4357 // win (server merges). 4358 if (isEdit && !body.imap_password) body.account_id = editId; 4359 const msg = el('uf-email-msg'); 4360 const btn = el('uf-email-test'); 4361 const ico = btn.querySelector('.uf-email-test-ico'); 4362 btn.dataset.origIco = btn.dataset.origIco || ico.innerHTML; 4363 btn.disabled = true; 4364 // Clear any prior green/red while testing. 4365 btn.style.background = ''; 4366 btn.style.borderColor = ''; 4367 btn.style.color = ''; 4368 btn.style.boxShadow = ''; 4369 btn.style.animation = ''; 4370 ico.innerHTML = _spinner; 4371 msg.textContent = ''; 4372 msg.style.color = ''; 4373 try { 4374 const r = await fetch('/api/email/accounts/test', { 4375 method: 'POST', credentials: 'same-origin', 4376 headers: { 'Content-Type': 'application/json' }, 4377 body: JSON.stringify(body), 4378 }); 4379 const d = await r.json(); 4380 if (d.ok) { 4381 // Button becomes the indicator â green checkmark with the 4382 // cookbook-style halo + breathing animation. No status text; 4383 // the glow is the signal. 4384 btn.style.background = 'var(--green, #50fa7b)'; 4385 btn.style.borderColor = 'var(--green, #50fa7b)'; 4386 btn.style.color = '#0b0'; 4387 btn.style.boxShadow = 4388 '0 0 0 2px color-mix(in srgb, var(--green, #50fa7b) 25%, transparent),' 4389 + ' 0 0 10px 2px color-mix(in srgb, var(--green, #50fa7b) 55%, transparent)'; 4390 btn.style.animation = 'cookbook-srv-glow-ok 2.4s ease-in-out infinite'; 4391 ico.innerHTML = _checkIcon; 4392 } else { 4393 // Failure â red glow, original icon, error detail in status 4394 // text so we can say WHICH half failed (IMAP vs SMTP). 4395 btn.style.background = 'var(--red)'; 4396 btn.style.borderColor = 'var(--red)'; 4397 btn.style.color = '#fff'; 4398 btn.style.boxShadow = 4399 '0 0 0 2px color-mix(in srgb, var(--red) 25%, transparent),' 4400 + ' 0 0 10px 2px color-mix(in srgb, var(--red) 55%, transparent)'; 4401 ico.innerHTML = btn.dataset.origIco; 4402 const imap = d.imap?.ok ? 'IMAP ok' : `IMAP: ${d.imap?.error || 'fail'}`; 4403 const smtp = d.smtp ? (d.smtp.ok ? ' · SMTP ok' : ` · SMTP: ${d.smtp.error || 'fail'}`) : ''; 4404 msg.textContent = imap + smtp; 4405 msg.style.color = 'var(--red)'; 4406 } 4407 } catch (e) { 4408 btn.style.background = 'var(--red)'; 4409 btn.style.borderColor = 'var(--red)'; 4410 btn.style.color = '#fff'; 4411 ico.innerHTML = btn.dataset.origIco; 4412 msg.textContent = 'Test error: ' + e.message; 4413 msg.style.color = 'var(--red)'; 4414 } finally { 4415 btn.disabled = false; 4416 } 4417 }); 4418 4419 el('uf-email-save').addEventListener('click', async () => { 4420 const body = _collectBody(); 4421 // Name is optional â fall back to Email so the list still has a label. 4422 if (!body.name) body.name = body.from_address; 4423 if (!body.name) { el('uf-email-msg').textContent = 'Need at least a Name or Email'; el('uf-email-msg').style.color = 'var(--red)'; return; } 4424 const saveBtn = el('uf-email-save'); 4425 saveBtn.disabled = true; 4426 const saveIcoEl = saveBtn.querySelector('.uf-email-save-ico'); 4427 const saveLblEl = saveBtn.querySelector('.uf-email-save-label'); 4428 const prevIco = saveIcoEl.innerHTML; 4429 const prevLbl = saveLblEl.textContent; 4430 saveIcoEl.innerHTML = _spinner; 4431 saveLblEl.textContent = 'Savingâ¦'; 4432 try { 4433 const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts'; 4434 const method = isEdit ? 'PUT' : 'POST'; 4435 const r = await fetch(url, { 4436 method, credentials: 'same-origin', 4437 headers: { 'Content-Type': 'application/json' }, 4438 body: JSON.stringify(body), 4439 }); 4440 const d = await r.json(); 4441 if (!(d.ok || d.id)) { 4442 el('uf-email-msg').textContent = d.error || 'Failed'; 4443 el('uf-email-msg').style.color = 'var(--red)'; 4444 return; 4445 } 4446 el('uf-email-msg').textContent = 'Saved'; 4447 el('uf-email-msg').style.color = 'var(--green,#50fa7b)'; 4448 integrationNotice = 'Email account saved. For more settings, go to Settings > Email.'; 4449 formEl.style.display = 'none'; 4450 await renderList(); 4451 notifyIntegrationsChanged(); 4452 } catch (e) { 4453 el('uf-email-msg').textContent = 'Error: ' + e.message; 4454 el('uf-email-msg').style.color = 'var(--red)'; 4455 } finally { 4456 saveBtn.disabled = false;
4457 saveIcoEl.innerHTML = prevIco; 4458 saveLblEl.textContent = prevLbl; 4459 } 4460 }); 4461 } 4462 4463 // ââ Vaultwarden form ââ 4464 async function showVaultForm() { 4465 formEl.innerHTML = ` 4466 <div class="admin-card" style="margin-top:8px"> 4467 <h2 style="font-size:13px">Vaultwarden (Password Vault)</h2> 4468 <div id="uf-vault-status" style="font-size:11px;opacity:0.7;margin-bottom:8px">Loading...</div> 4469 <div class="settings-col"> 4470 <div class="settings-row"><label class="settings-label">Server URL</label><input id="uf-vault-url" class="settings-input" placeholder="https://vault.example.com"></div> 4471 <div class="settings-row"><label class="settings-label">Email</label><input id="uf-vault-email" class="settings-input" placeholder="[email protected]"></div> 4472 <div class="settings-row"><label class="settings-label">Master Password</label><input id="uf-vault-pass" class="settings-input" type="password" placeholder="Only required for Login / Unlock"></div> 4473 <div class="settings-row" style="margin-top:4px;flex-wrap:wrap;gap:4px"> 4474 <button class="admin-btn-sm" id="uf-vault-save">Save Config</button> 4475 <button class="admin-btn-sm" id="uf-vault-login">Login</button> 4476 <button class="admin-btn-sm" id="uf-vault-unlock">Unlock</button> 4477 <button class="admin-btn-sm" id="uf-vault-lock" style="opacity:0.7">Lock</button> 4478 <button class="admin-btn-sm" id="uf-vault-logout" style="opacity:0.7">Logout</button> 4479 <button class="admin-btn-sm" id="uf-vault-cancel" style="opacity:0.7">Cancel</button> 4480 <span id="uf-vault-msg" style="font-size:11px;margin-left:4px"></span> 4481 </div> 4482 <div style="font-size:10px;opacity:0.5;margin-top:6px;line-height:1.4"> 4483 <strong>Login</strong> registers this device with your Vaultwarden account (once per account).<br> 4484 <strong>Unlock</strong> decrypts the vault â required after restart or Lock. Session is saved so the assistant can read passwords. 4485 </div> 4486 </div> 4487 </div>`; 4488 4489 const msg = (text, color) => { 4490 const m = el('uf-vault-msg'); 4491 m.textContent = text || ''; 4492 m.style.color = color || ''; 4493 }; 4494 4495 async function refreshStatus() { 4496 try { 4497 const r = await fetch('/api/vault/config', { credentials: 'same-origin' }); 4498 const d = await r.json(); 4499 el('uf-vault-url').value = d.server_url || ''; 4500 el('uf-vault-email').value = d.email || ''; 4501 const installed = d.bw_installed; 4502 const parts = []; 4503 parts.push(installed ? 'bw CLI: installed' : 'bw CLI: NOT installed (install nodejs-bitwarden-cli)'); 4504 parts.push(d.unlocked ? 'Status: UNLOCKED' : 'Status: locked'); 4505 if (d.unlocked_at) parts.push(`Last unlock: ${d.unlocked_at.replace('T',' ').slice(0,19)}`); 4506 const statusEl = el('uf-vault-status'); 4507 statusEl.textContent = parts.join(' â '); 4508 statusEl.style.color = !installed ? 'var(--red)' : d.unlocked ? 'var(--green,#50fa7b)' : ''; 4509 } catch (_) { 4510 el('uf-vault-status').textContent = 'Failed to load vault status'; 4511 } 4512 } 4513 await refreshStatus(); 4514 4515 el('uf-vault-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 4516 4517 el('uf-vault-save').addEventListener('click', async () => { 4518 msg('Saving...'); 4519 try { 4520 const r = await fetch('/api/vault/config', { 4521 method: 'POST', credentials: 'same-origin', 4522 headers: { 'Content-Type': 'application/json' }, 4523 body: JSON.stringify({ server_url: el('uf-vault-url').value, email: el('uf-vault-email').value }), 4524 }); 4525 const d = await r.json(); 4526 if (d.ok) { msg('Saved', 'var(--green,#50fa7b)'); await refreshStatus(); await renderList(); } 4527 else msg(d.error || 'Failed', 'var(--red)'); 4528 } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } 4529 }); 4530 4531 el('uf-vault-login').addEventListener('click', async () => { 4532 const email = el('uf-vault-email').value.trim(); 4533 const pass = el('uf-vault-pass').value; 4534 if (!email || !pass) { msg('Email + master password required', 'var(--red)'); return; } 4535 msg('Logging in...');
4536 try { 4537 const r = await fetch('/api/vault/login', { 4538 method: 'POST', credentials: 'same-origin', 4539 headers: { 'Content-Type': 'application/json' }, 4540 body: JSON.stringify({ email, master_password: pass }), 4541 }); 4542 const d = await r.json(); 4543 if (d.ok) { 4544 msg(d.already ? 'Already logged in â use Unlock' : 'Logged in', 'var(--green,#50fa7b)'); 4545 el('uf-vault-pass').value = ''; 4546 await refreshStatus(); await renderList(); 4547 } else msg(d.error || 'Login failed', 'var(--red)'); 4548 } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } 4549 }); 4550 4551 el('uf-vault-unlock').addEventListener('click', async () => { 4552 const pass = el('uf-vault-pass').value; 4553 if (!pass) { msg('Master password required', 'var(--red)'); return; } 4554 msg('Unlocking...'); 4555 try { 4556 const r = await fetch('/api/vault/unlock', { 4557 method: 'POST', credentials: 'same-origin', 4558 headers: { 'Content-Type': 'application/json' }, 4559 body: JSON.stringify({ master_password: pass }), 4560 }); 4561 const d = await r.json(); 4562 if (d.ok) { 4563 msg('Vault unlocked', 'var(--green,#50fa7b)'); 4564 el('uf-vault-pass').value = ''; 4565 await refreshStatus(); await renderList(); 4566 } else msg(d.error || 'Unlock failed', 'var(--red)'); 4567 } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } 4568 }); 4569 4570 el('uf-vault-lock').addEventListener('click', async () => { 4571 msg('Locking...'); 4572 try { 4573 await fetch('/api/vault/lock', { method: 'POST', credentials: 'same-origin' }); 4574 msg('Locked', 'var(--green,#50fa7b)'); 4575 await refreshStatus(); await renderList(); 4576 } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } 4577 }); 4578 4579 el('uf-vault-logout').addEventListener('click', async () => { 4580 if (!await window.styledConfirm('Log out of Bitwarden CLI? You\'ll need to re-enter your master password to log back in.', { c
4580onfirmText: 'Log out' })) return; 4581 msg('Logging out...'); 4582 try { 4583 await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' }); 4584 msg('Logged out', 'var(--green,#50fa7b)'); 4585 await refreshStatus(); await renderList(); 4586 } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } 4587 }); 4588 } 4589 4590 // ââ MCP form â full management view ââ 4591 async function showMcpForm(editId) { 4592 // Toggle an in-flight loading state on a button (disabled + dimmed + label). 4593 function _setBtnLoading(btn, loading, label) { 4594 if (!btn) return; 4595 btn.disabled = loading; 4596 btn.style.opacity = loading ? '0.6' : ''; 4597 btn.style.cursor = loading ? 'progress' : ''; 4598 if (label != null) btn.textContent = label; 4599 } 4600 function _showMcpPasteback(id) { 4601 const msg = el('uf-mcp-msg'); if (!msg) return; 4602 if (el('uf-mcp-pasteback')) return; // already shown 4603 msg.innerHTML = 4604 'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' + 4605 '<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' + 4606 '<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>'; 4607 const pasteGo = el('uf-mcp-paste-go'); 4608 if (pasteGo) pasteGo.addEventListener('click', async () => { 4609 const cb = el('uf-mcp-pasteback').value.trim(); 4610 if (!cb) return; 4611 const pf = new FormData(); pf.append('callback_url', cb); 4612 _setBtnLoading(pasteGo, true, 'Submittingâ¦'); 4613 try { 4614 await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf }); 4615 } finally { 4616 _setBtnLoading(pasteGo, false, 'Submit'); 4617 } 4618 }); 4619 } 4620 4621 // Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag), 4622 // opens it once, then resolves on connected/error. 4623 async function _handleMcpAuth(id, initialAuthUrl, tries = 90) { 4624 let opened = false; 4625 const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } }; 4626 openAuth(initialAuthUrl); 4627 const msg = el('uf-mcp-msg'); 4628 let fails = 0; 4629 for (let i = 0; i < tries; i++) { 4630 await new Promise(res => setTimeout(res, 2000)); 4631 try { 4632 const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' }); 4633 if (!r.ok) throw new Error('HTTP ' + r.status); 4634 const list = await r.json(); 4635 fails = 0; 4636 const s = Array.isArray(list) ? list.find(x => x.id === id) : null; 4637 if (!s) continue; 4638 if (s.auth_url) openAuth(s.auth_url); 4639 if (s.status === 'connected') { 4640 if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`; 4641 await renderList(); return; 4642 } 4643 if (s.status === 'error') { 4644 if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return; 4645 } 4646 } catch (e) { 4647 // Tolerate a single blip, but surface persistent failures instead of 4648 // silently polling until timeout. 4649 if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) â still retryingâ¦`; 4650 } 4651 } 4652 if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.'; 4653 } 4654 if (editId && editId !== 'new') { 4655 // Show management view for existing server 4656 formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>'; 4657 try { 4658 const res = await fetch('/api/mcp/servers', { credentials: 'same-origin' }); 4659 const servers = await res.json(); 4660 const srv = servers.find(s => (s.id || s.name) === editId); 4661 if (!srv) { formEl.innerHTML = '<div class="admin-card" style="margin-top:8px">Server not found</div>'; return; } 4662 const esc = s => String(s||'').replace(/&/g,'&').replace(/</g,'<'); 4663 const statusColor = srv.needs_oauth ? '#e5a33a' : srv.status === 'connected' ? 'var(--green,#50fa7b)' : srv.status === 'error' ? 'var(--red)' : 'var(--fg)'; 4664 const toolInfo = srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : ''; 4665 const statusText = srv.needs_oauth ? 'Needs authorization' : srv.status === 'connected' ? `Connected (${toolInfo})` : srv.status === 'error' ? `Error: ${esc(srv.error || 'unknown')}` : 'Disconnected'; 4666 formEl.innerHTML = ` 4667 <div class="admin-card" style="margin-top:8px"> 4668 <h2 style="font-size:13px">${esc(srv.name)}</h2> 4669 <div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
4670 <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor}"></span> 4671 <span style="font-size:11px;opacity:0.7">${statusText}</span> 4672 </div> 4673 <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px"> 4674 ${srv.needs_oauth ? `<a href="/api/mcp/oauth/authorize/${srv.id}" target="_blank" class="admin-btn-sm" style="background:var(--red);color:#fff;text-decoration:none">Authorize</a>` : ''} 4675 <button class="admin-btn-sm" id="uf-mcp-reconnect">Reconnect</button> 4676 <button class="admin-btn-sm" id="uf-mcp-toggle">${srv.is_enabled ? 'Disable' : 'Enable'}</button> 4677 <button class="admin-btn-sm" id="uf-mcp-cancel" style="opacity:0.7">Close</button> 4678 <span id="uf-mcp-msg" style="font-size:11px"></span> 4679 </div> 4680 <div id="uf-mcp-tools-panel"></div> 4681 </div>`; 4682 // Reconnect 4683 el('uf-mcp-reconnect').addEventListener('click', async () => { 4684 const msg = el('uf-mcp-msg'); msg.textContent = 'Reconnecting...'; 4685 try { 4686 const r = await fetch(`/api/mcp/servers/${srv.id}/reconnect`, { method: 'POST', credentials: 'same-origin' }); 4687 const d = await r.json(); 4688 msg.textContent = d.connected ? `Connected (${d.tool_count} tools)` : `Failed: ${d.error || 'unknown'}`; 4689 await renderList(); 4690 showMcpForm(editId); // refresh this view 4691 } catch (e) { msg.textContent = 'Failed'; } 4692 }); 4693 // Toggle enable/disable 4694 el('uf-mcp-toggle').addEventListener('click', async () => { 4695 const fd = new FormData(); fd.append('is_enabled', String(!srv.is_enabled)); 4696 await fetch(`/api/mcp/servers/${srv.id}`, { method: 'PATCH', body: fd, credentials: 'same-origin' }); 4697 await renderList(); 4698 showMcpForm(editId); 4699 }); 4700 el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 4701 // Load tools list 4702 if (srv.status === 'connected' && srv.tool_count > 0) { 4703 const panel = el('uf-mcp-tools-panel'); 4704 try { 4705 const tr = await fetch(`/api/mcp/servers/${srv.id}/tools`, { credentials: 'same-origin' }); 4706 const tools = await tr.json(); 4707 if (tools.length) { 4708 const disabled = new Set(tools.filter(t => t.is_disabled).map(t => t.name)); 4709 panel.innerHTML = `<div class="mcp-tools-header"><span>Tools</span><span style="display:flex;gap:8px;align-items:center"><span class="mcp-tools-count">${tools.length - disabled.size}/${tools.length} enabled</span><a href="#" id="uf-mcp-all">All</a> <a href="#" id="uf-mcp-none">None</a></span></div><div class="mcp-tools-list">${tools.map(t => `<label title="${esc(t.description)}"><input type="checkbox" data-mcp-tool-name="${esc(t.name)}" ${!t.is_disabled ? 'checked' : ''}>
4709<span><strong>${esc(t.name)}</strong> <span style="opacity:0.5">â ${esc((t.description||'').slice(0,80))}</span></span></label>`).join('')}</div>`; 4710 const saveFn = async () => { 4711 const dis = []; 4712 panel.querySelectorAll('input[type=checkbox]').forEach(cb => { if (!cb.checked) dis.push(cb.dataset.mcpToolName); }); 4713 await fetch(`/api/mcp/servers/${srv.id}/tools`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ disabled: dis }) }); 4714 const cnt = panel.querySelector('.mcp-tools-count'); 4715 if (cnt) cnt.textContent = `${tools.length - dis.length}/${tools.length} enabled`; 4716 }; 4717 panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.addEventListener('change', saveFn)); 4718 el('uf-mcp-all')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = true); saveFn(); }); 4719 el('uf-mcp-none')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false); saveFn(); }); 4720 } 4721 } catch (_) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px">Failed to load tools</span>'; } 4722 } 4723 } catch (_) { formEl.innerHTML = '<div class="admin-card" style="margin-top:8px">Failed to load server</div>'; } 4724 } else { 4725 // Add new MCP server form 4726 formEl.innerHTML = ` 4727 <div class="admin-card" style="margin-top:8px"> 4728 <h2 style="font-size:13px">Add MCP Server</h2> 4729 <div class="settings-col"> 4730 <div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div> 4731 <div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option><option value="http">Streamable HTTP</option></select></div> 4732 <div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;"> 4733 <div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div> 4734 <div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div> 4735 <div class="settings-row"><label class="settings-label">Env</label><input id="uf-mcp-env" class="settings-input" placeholder='{"KEY": "value"}'></div> 4736 </div> 4737 <div id="uf-mcp-sse-fields" style="display:none;flex-direction:column;gap:6px;"> 4738 <div class="settings-row"><label class="settings-label">URL</label><input id="uf-mcp-url" class="settings-input" placeholder="http://localhost:3001/sse"></div> 4739 </div> 4740 <div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-mcp-save">Save</button><button class="admin-btn-sm" id="uf-mcp-cancel" style="opacity:0.7">Cancel</button><span id="uf-mcp-msg" style="font-size:11px"></span></div> 4741 </div> 4742 </div>`; 4743 el('uf-mcp-transport').addEventListener('change', () => { 4744 const v = el('uf-mcp-transport').value; 4745 const isUrl = (v === 'sse' || v === 'http'); 4746 el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex'; 4747 el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none'; 4748 const urlInput = el('uf-mcp-url'); 4749 if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse'; 4750 }); 4751 el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); 4752 el('uf-mcp-save').addEventListener('click', async () => { 4753 const transport = el('uf-mcp-transport').value; 4754 // routes/mcp_routes.py uses FastAPI Form(...) â send multipart, not JSON. 4755 const fd = new FormData(); 4756 fd.append('name', el('uf-mcp-name').value); 4757 fd.append('transport', transport); 4758 if (transport === 'stdio') { 4759 fd.append('command', el('uf-mcp-cmd').value); 4760 let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {} 4761 let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {} 4762 fd.append('args', args); 4763 fd.append('env', env); 4764 } else { 4765 fd.append('url', el('uf-mcp-url').value); 4766 } 4767 const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel'); 4768 const _origLabel = saveBtn.textContent; 4769 _setBtnLoading(saveBtn, true, 'Savingâ¦'); if (cancelBtn) cancelBtn.disabled = true; 4770 try { 4771 const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd }); 4772 const data = await r.json().catch(() => ({})); 4773 if (r.ok && data.needs_auth) { 4774 el('uf-mcp-msg').textContent = 'Preparing authorizationâ¦'; 4775 _handleMcpAuth(data.id, data.auth_url); 4776 } else if (r.ok && (data.connected || data.status === 'connected')) { 4777 el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`; 4778 formEl.style.display = 'none'; await renderList(); 4779 } else if (r.ok) { 4780 el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList(); 4781 } else { 4782 el('uf-mcp-msg').textContent = `Failed (${r.status})`; 4783 } 4784 } catch (_) { el('uf-mcp-msg').textContent = 'Failed'; } 4785 finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; } 4786 }); 4787 } 4788 } 4789 4790 async function showAgentForm(kind, editId) { 4791 const cfg = AGENT_CONFIGS[kind] || AGENT_CONFIGS.codex; 4792 let tokens = []; 4793 try { 4794 const tokRes = await fetch('/api/tokens', { credentials: 'same-origin' }); 4795 if (tokRes.ok) tokens = await tokRes.json(); 4796 } catch (_) {} 4797 4798 const toolScopes = [ 4799 { key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' }, 4800 { key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' }, 4801 { key: 'documents:read', label: 'Documents', detail: 'Read documents when a document API is enabled' }, 4802 { key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' }, 4803 { key: 'email:read', label: 'Email', detail: 'Read email when an email API is enabled' }, 4804 { key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts without sending' }, 4805 { key: 'email:send', label: 'Email send', detail: 'Send email directly' }, 4806 { key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events when enabled' }, 4807 { key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' }, 4808 { key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' }, 4809 { key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' }, 4810 { key: 'cookbook:read', label: 'Cookbook', detail: 'List cookbook tasks + tail the
4810ir tmux output (debug a model serve from outside the UI)' }, 4811 { key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Launch and stop cookbook serve tasks. Powerful: runs SSH commands on your configured servers, bounded by the same allowlist the UI uses (vllm/python3/sglang/llama-server/...)' }, 4812 ]; 4813 // Strict name-prefix match keeps Codex and Claude tokens in their own forms. 4814 const agentTokens = (Array.isArray(tokens) ? tokens : []).filter(tok => 4815 (tok.name || '').toLowerCase().startsWith(cfg.namePrefix) 4816 ); 4817 const current = agentTokens.find(t => String(t.id) === String(editId)); 4818 const _scopeIcons = { 4819 todos: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="8" y1="9" x2="16" y2="9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>', 4820 documents: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', 4821 email: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>', 4822 calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>', 4823 memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 2 9.5v3A2.5 2.5 0 0 0 4.5 15a2.5 2.5 0 0 0 2.5 2.5A2.5 2.5 0 0 0 9.5 20H10V2z"/><path d="M14.5 2a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1 2.5 2.5A2.5 2.5 0 0 1 22 9.5v3A2.5 2.5 0 0 1 19.5 15a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 14.5 20H14V2z"/></svg>', 4824 cookbook: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>', 4825 }; 4826 const _scopeNiceLabel = (label) => label.replace(/\s+(write|drafts?|send)$/i, ''); 4827 const _scopeAction = (key) => (key.split(':')[1] || '').toLowerCase(); 4828 const _pillStyle = (action) => { 4829 if (action === 'read') return 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);'; 4830 return 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));'; 4831 }; 4832 const scopeToggles = (t) => { 4833 const scopes = new Set(t.scopes || []); 4834 return toolScopes.map(scope => { 4835 const tool = scope.key.split(':')[0]; 4836 const action = _scopeAction(scope.key); 4837 const icon = _scopeIcons[tool] || ''; 4838 const niceLabel = _scopeNiceLabel(scope.label); 4839 return ` 4840 <label class="settings-row" style="align-items:center;gap:8px;display:flex;min-height:30px;padding:2px 0;"> 4841 <span style="opacity:0.7;display:inline-flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;">${icon}</span> 4842 <span class="settings-label" style="width:75px;flex-shrink:0;padding:0;">${esc(niceLabel)}</span> 4843 <span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;margin-left:-3px;box-sizing:border-box;${_pillStyle(action)}">${esc(action)}</span> 4844 <span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(scope.detail)}</span> 4845 <label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="uf-codex-scope" data-token-id="${esc(t.id)}" data-scope="${esc(scope.key)}" ${scopes.has(scope.key) ? 'checked' : ''}>
4845<span class="admin-slider"></span></label> 4846 </label>`; 4847 }).join(''); 4848 }; 4849 const tokenRows = agentTokens.length ? agentTokens.map(t => ` 4850 <div class="uf-codex-token" data-token-id="${esc(t.id)}" style="border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-top:8px;"> 4851 <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;"> 4852 <div style="flex:1;min-width:0;"> 4853 <input type="text" class="uf-codex-rename settings-input" data-token-id="${esc(t.id)}" value="${esc(t.name || cfg.defaultName)}" placeholder="${esc(cfg.defaultName)} (e.g. ${esc(cfg.word)} on laptop)" style="font-size:12px;font-weight:600;padding:3px 6px;width:100%;background:transparent;border:1px solid transparent;border-radius:4px;" title="Click to rename this agent"> 4854 <div style="font-size:10px;opacity:0.52;margin-top:2px;">${esc(t.token_prefix || 'token')}...${t.last_used_at ? ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}` : ' · Never used'}</div> 4855 </div> 4856 <button class="admin-btn-sm uf-codex-copy-prefix" data-token-prefix="${esc(t.token_prefix || '')}" title="Copy token prefix (full token only shown once, at creation)" style="opacity:0.7">Copy</button> 4857 <button class="admin-btn-delete uf-codex-revoke" data-token-id="${esc(t.id)}">Revoke</button> 4858 </div> 4859 <div style="font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;">Tool access</div> 4860 ${scopeToggles(t)} 4861 <div class="uf-codex-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;"></div> 4862 </div>`).join('') : `<div style="opacity:0.45;font-size:11px;padding:8px 0;">No ${esc(cfg.word)} tokens yet.</div>`; 4863 const origin = window.location.origin || ''; 4864 const setupForToken = (token) => cfg.buildSetup(origin, token); 4865 4866 formEl.innerHTML = ` 4867 <div class="admin-card" style="margin-top:8px"> 4868 <h2 style="font-size:13px">${esc(cfg.label)}</h2> 4869 <div style="font-size:11px;opacity:0.65;line-height:1.45;margin:-2px 0 8px;">Generates a scoped token + setup commands so ${esc(cfg.word)} on your own machine can read/write your Odysseus data (todos, email, calendar, etc.). The agent runs in your terminal â it isn't streamed inside Odysseus.</div> 4870 <div class="settings-col"> 4871 <div id="uf-codex-pending" style="display:${current ? 'none' : 'block'};font-size:11px;opacity:0.6;padding:6px 0;">Creating agent...</div> 4872 <div id="uf-codex-reveal" style="display:none;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.04);width:100%;box-sizing:border-box;"> 4873 <div style="font-weight:600;font-size:12px;margin-bottom:6px;">${esc(cfg.word)} setup</div> 4874 4875 <div style="font-size:11px;opacity:0.62;margin-bottom:4px;">Copy this token now — it will not be shown again.</div> 4876 <code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;padding:6px 8px;background:rgba(0,0,0,0.08);border-radius:4px;"></code> 4877 <div style="margin-top:6px;"> 4878 <button class="admin-btn-sm" id="uf-codex-copy-token">Copy token</button> 4879 </div> 4880 4881 <div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Quickstart — or copy setup directly in your terminal</div> 4882 <div style="font-size:11px;opacity:0.62;margin-bottom:6px;">${cfg.setupDescription}</div> 4883 <pre style="margin:0;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;font-size:10px;line-height:1.45;padding:8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;width:100%;box-sizing:border-box;"><code id="uf-codex-setup-code"></code></pre> 4884 <div style="margin-top:6px;"> 4885 <button class="admin-btn-sm" id="uf-codex-copy-setup">Copy setup</button> 4886 </div> 4887 4888 <div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Configure access</div> 4889 <div style="font-size:11px;opacity:0.62;margin-bottom:6px;">Toggle which Odysseus tools this agent can use. New agents start with chat only.</div> 4890 <div id="uf-codex-inline-scopes"></div> 4891 </div> 4892 <div style="font-size:11px;font-weight:600;opacity:0.62;margin-top:10px;">${agentTokens.length ? 'Existing agents' : 'Agents'}</div> 4893 <div id="uf-codex-token-list">${tokenRows}</div> 4894 <div class="settings-row" style="margin-top:10px;align-items:center;"> 4895 <button class="admin-btn-add" id="uf-codex-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
4896 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> 4897 Save 4898 </button> 4899 <span id="uf-codex-msg" style="font-size:11px;flex:1;margin-left:8px"></span> 4900 <button class="admin-btn-add" id="uf-codex-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;"> 4901 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 4902 Cancel 4903 </button> 4904 </div> 4905 </div> 4906 </div>`; 4907 4908 el('uf-codex-cancel')?.addEventListener('click', () => { formEl.style.display = 'none'; }); 4909 el('uf-codex-save')?.addEventListener('click', () => { 4910 const msg = el('uf-codex-msg'); 4911 if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; } 4912 setTimeout(() => { formEl.style.display = 'none'; }, 350); 4913 }); 4914 4915 const _autoCreateCodex = async () => { 4916 const msg = el('uf-codex-msg'); 4917 const pending = el('uf-codex-pending'); 4918 const existingNames = new Set(agentTokens.map(t => (t.name || '').trim())); 4919 let name = cfg.defaultName; 4920 let n = 2; 4921 while (existingNames.has(name)) { name = `${cfg.defaultName} ${n++}`; } 4922 const fd = new FormData(); 4923 fd.append('name', name); 4924 fd.append('scopes', 'chat'); 4925 try { 4926 const r = await fetch('/api/tokens', { method: 'POST', credentials: 'same-origin', body: fd }); 4927 const d = await r.json(); 4928 if (!r.ok) throw new Error(d.detail || 'Failed'); 4929 if (pending) pending.style.display = 'none'; 4930 el('uf-codex-token').textContent = d.token || ''; 4931 el('uf-codex-reveal').style.display = ''; 4932 const setupBtn = el('uf-codex-copy-setup'); 4933 if (setupBtn) setupBtn.dataset.token = d.token || ''; 4934 const setupCode = el('uf-codex-setup-code'); 4935 if (setupCode) setupCode.textContent = setupForToken(d.token || ''); 4936 // Populate inline scope toggles for the just-created token (Configure access already open) 4937 const newToken = { id: d.id, name, scopes: d.scopes || ['chat'] }; 4938 const inlineEl = el('uf-codex-inline-scopes'); 4939 if (inlineEl) { 4940 inlineEl.innerHTML = ` 4941 <div class="uf-codex-token" data-token-id="${esc(newToken.id)}"> 4942 ${scopeToggles(newToken)} 4943 <div class="uf-codex-scope-msg" data-token-id="${esc(newToken.id)}" style="font-size:11px;min-height:14px;"></div> 4944 </div>`; 4945 _wireScopeChange(inlineEl); 4946 } 4947 if (msg) { 4948 msg.textContent = `Created "${name}".`; 4949 msg.style.color = 'var(--green, #50fa7b)'; 4950 } 4951 await renderList(); 4952 } catch (err) { 4953 if (pending) pending.style.display = 'none'; 4954 if (msg) { 4955 msg.textContent = err?.message || 'Failed'; 4956 msg.style.color = 'var(--red)'; 4957 } 4958 } 4959 }; 4960 if (!current) _autoCreateCodex(); 4961 const _copyCodexToken = async (text) => { 4962 const value = String(text || ''); 4963 if (!value) return false; 4964 if (navigator.clipboard && window.isSecureContext) { 4965 try { 4966 await navigator.clipboard.writeText(value); 4967 return true; 4968 } catch (_) {} 4969 } 4970 const ta = document.createElement('textarea'); 4971 ta.value = value; 4972 ta.setAttribute('readonly', 'readonly'); 4973 ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;'; 4974 document.body.appendChild(ta); 4975 ta.focus(); 4976 ta.select(); 4977 ta.setSelectionRange(0, value.length); 4978 let ok = false; 4979 try { ok = document.execCommand('copy'); } catch (_) { ok = false; } 4980 ta.remove(); 4981 return ok; 4982 }; 4983 const _selectTextFallback = (text, containerId) => { 4984 const code = document.createElement('pre'); 4985 code.textContent = text; 4986 code.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:10px;margin:6px 0 0;'; 4987 el(containerId)?.appendChild(code); 4988 const range = document.createRange(); 4989 range.selectNodeContents(code); 4990 const selection = window.getSelection(); 4991 selection.removeAllRanges(); 4992 selection.addRange(range); 4993 }; 4994 el('uf-codex-copy-setup')?.addEventListener('click', async () => { 4995 const token = el('uf-codex-copy-setup')?.dataset.token || ''; 4996 const btn = el('uf-codex-copy-setup'); 4997 if (!token) { 4998 if (btn) { 4999 btn.textContent = 'Add agent first'; 5000 setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600); 5001 } 5002 return; 5003 } 5004 const setup = setupForToken(token); 5005 const ok = await _copyCodexToken(setup); 5006 if (!btn) return; 5007 btn.textContent = ok ? 'Copied setup' : 'Select setup'; 5008 if (!ok) _selectTextFallback(setup, 'uf-codex-reveal'); 5009 setTimeout(() => { const latest = el('uf-codex-copy-setup'); if (latest) latest.textContent = 'Copy setup'; }, 1600); 5010 }); 5011 el('uf-codex-copy-token')?.addEventListener('click', async () => { 5012 const token = el('uf-codex-token')?.textContent || ''; 5013 const ok = await _copyCodexToken(token); 5014 const btn = el('uf-codex-copy-token'); 5015 if (!btn) return; 5016 btn.textContent = ok ? 'Copied token' : 'Select token'; 5017 if (!ok) _selectTextFallback(token, 'uf-codex-reveal'); 5018 setTimeout(() => { const latest = el('uf-codex-copy-token'); if (latest) latest.textContent = 'Copy token'; }, 1600); 5019 }); 5020 formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => { 5021 btn.addEventListener('click', async () => { 5022 if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Terminal agents using it will lose access.`, { confirmText: 'Revoke', danger: true })) return; 5023 await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' }); 5024 formEl.style.display = 'none'; 5025 await renderList(); 5026 }); 5027 }); 5028 // Rename: PATCH the token's name when the user blurs the input (or hits Enter). 5029 formEl.querySelectorAll('.uf-codex-rename').forEach(input => { 5030 const original = input.value; 5031 const commit = async () => { 5032 const name = (input.value || '').trim(); 5033 if (!name || name === original) return; 5034 try { 5035 const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, { 5036 method: 'PATCH', 5037 credentials: 'same-origin', 5038 headers: { 'Content-Type': 'application/json' }, 5039 body: JSON.stringify({ name }), 5040 }); 5041 if (!r.ok) throw new Error('Save failed'); 5042 input.style.borderColor = 'var(--green, #50fa7b)'; 5043 setTimeout(() => { input.style.borderColor = 'transparent'; }, 800); 5044 await renderList(); 5045 } catch (_) { 5046 input.value = original; 5047 input.style.borderColor = 'var(--red)'; 5048 setTimeout(() => { input.style.borderColor = 'transparent'; }, 1200); 5049 } 5050 }; 5051 input.addEventListener('blur', commit); 5052 input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } }); 5053 }); 5054 // Copy token prefix (full token irrecoverable after the one-time creation reveal). 5055 formEl.querySelectorAll('.uf-codex-copy-prefix').forEach(btn => { 5056 btn.addEventListener('click', async () => { 5057 const prefix = btn.dataset.tokenPrefix || ''; 5058 if (!prefix) return; 5059 try { 5060 if (navigator.clipboard && window.isSecureContext) { 5061 await navigator.clipboard.writeText(prefix); 5062 } else { 5063 const ta = document.createElement('textarea'); 5064 ta.value = prefix; 5065 ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;'; 5066 document.body.appendChild(ta); 5067 ta.select(); 5068 try { document.execCommand('copy'); } catch (_) {} 5069 ta.remove(); 5070 } 5071 const label = btn.textContent; 5072 btn.textContent = 'Copied prefix'; 5073 setTimeout(() => { btn.textContent = label; }, 1400); 5074 } catch (_) {} 5075 }); 5076 }); 5077 function _wireScopeChange(scope) { 5078 scope.querySelectorAll('.uf-codex-scope').forEach(cb => { 5079 if (cb.dataset.wired === '1') return; 5080 cb.dataset.wired = '1'; 5081 cb.addEventListener('change', async () => { 5082 const tokenId = cb.dataset.tokenId; 5083 const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`); 5084 const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`); 5085 const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope); 5086 try { 5087 const r = await fetch(`/api/tokens/${tokenId}`, { 5088 method: 'PATCH', 5089 credentials: 'same-origin', 5090 headers: { 'Content-Type': 'application/json' }, 5091 body: JSON.stringify({ scopes }), 5092 }); 5093 const d = await r.json().catch(() => ({})); 5094 if (!r.ok) throw new Error(d.detail || 'Failed'); 5095 if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; } 5096 await renderList(); 5097 } catch (err) { 5098 cb.checked = !cb.checked; 5099 if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; } 5100 } 5101 }); 5102 }); 5103 } 5104 _wireScopeChange(formEl); 5105 } 5106 5107 // ââ Add button with type picker ââ 5108 if (addBtn) { 5109 addBtn.addEventListener('click', () => { 5110 formEl.style.display = ''; 5111 const _typeOptions = [ 5112 ['api', 'API Service'], 5113 ['caldav', 'CalDAV Calendar'], 5114 ['claude', 'Claude Agent'], 5115 ['codex', 'Codex Agent'], 5116 ['carddav', 'Contacts (CardDAV)'], 5117 ['contacts', 'Contacts Import'], 5118 ['email', 'Email (IMAP/SMTP)'], 5119 ['mcp', 'MCP Tool Server'], 5120 ]; 5121 const _iconFor = (k) => (INTG_TYPES[k]?.icon || '').replace(/width="14"/, 'width="16"').replace(/height="14"/, 'height="16"'); 5122 const _rowsHtml = _typeOptions.map(([k, label]) => `<button type="button" class="uf-type-option" data-value="${k}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;"><span style="display:inline-flex;color:var(--accent, var(--red));flex-shrink:0;">${_iconFor(k)}</span><span>${esc(label)}</span></button>`).join(''); 5123 formEl.innerHTML = ` 5124 <div class="admin-card" style="margin-top:8px"> 5125 <h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Add Integration</h2> 5126 <div class="settings-col"> 5127 <div class="settings-row"><label class="settings-label">Type</label> 5128 <div style="position:relative;flex:1;min-width:0;"> 5129 <button type="button" id="uf-type-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
5130 <span class="uf-type-icon" style="display:inline-flex;color:var(--accent, var(--red));"></span> 5131 <span class="uf-type-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.65;">Select...</span> 5132 <span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">â¾</span> 5133 </button> 5134 <div id="uf-type-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_rowsHtml}</div> 5135 </div> 5136 </div> 5137 </div> 5138 </div>`; 5139 const trigger = el('uf-type-trigger'); 5140 const menu = el('uf-type-menu'); 5141 const labelEl = trigger.querySelector('.uf-type-label'); 5142 const iconEl = trigger.querySelector('.uf-type-icon'); 5143 const _closeMenu = () => { menu.style.display = 'none'; }; 5144 const _openMenu = () => { 5145 menu.style.display = 'block'; 5146 // Drop-up when there's not enough room below the trigger (mobile 5147 // landscape / docked keyboard / long lists near the bottom of screen). 5148 const tRect = trigger.getBoundingClientRect(); 5149 const mRect = menu.getBoundingClientRect(); 5150 const below = window.innerHeight - tRect.bottom; 5151 const above = tRect.top; 5152 if (mRect.height > below && above > below) { 5153 menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; 5154 } else { 5155 menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; 5156 } 5157 const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } }; 5158 setTimeout(() => document.addEventListener('click', onDoc, true), 0); 5159 }; 5160 trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); }); 5161 menu.querySelectorAll('.uf-type-option').forEach(btn => { 5162 btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; }); 5163 btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); 5164 btn.addEventListener('click', (e) => { 5165 e.stopPropagation(); 5166 const k = btn.dataset.value; 5167 const lbl = btn.querySelector('span:last-child')?.textContent || ''; 5168 if (labelEl) { labelEl.textContent = lbl; labelEl.style.opacity = '1'; } 5169 if (iconEl) iconEl.innerHTML = _iconFor(k); 5170 _closeMenu(); 5171 showForm(k, 'new'); 5172 }); 5173 }); 5174 }); 5175 } 5176 5177 await renderList(); 5178} 5179 5180/* ââ Admin visibility sync ââ */ 5181function syncAdminVisibility() { 5182 if (!modalEl) return; 5183 const isAdmin = !!window._isAdmin; 5184 modalEl.querySelectorAll('.admin-only').forEach(el => { 5185 el.style.display = isAdmin ? '' : 'none'; 5186 }); 5187} 5188 5189/* âââââââââââââââââââââââââââââââââââââââââââ 5190 PUBLIC API 5191 âââââââââââââââââââââââââââââââââââââââââââ */ 5192export function open(tab) { 5193 if (!initialized) initAll(); 5194 syncAppearanceCheckboxes(); 5195 resetWindowPlacement(); 5196 modalEl.classList.remove('hidden'); 5197 syncAdminVisibility(); 5198 const content = modalEl.querySelector('.settings-modal-content'); 5199 if (tab) { 5200 modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); 5201 modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); 5202 } 5203 // Auto-init admin data if showing an admin tab 5204 const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services'; 5205 document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance'); 5206 syncAppearanceOpacity(activeTab === 'appearance'); 5207 if (activeTab === 'ai') refreshAiModelEndpoints(); 5208 if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) { 5209 window.adminModule._initData(); 5210 } 5211} 5212 5213export function close() { 5214 if (!modalEl) return; 5215 // Always clear the appearance-tab body class so the rest of the app 5216 // doesn't keep its dimmed state if the modal got closed mid-tab. 5217 document.body.classList.remove('settings-appearance-open'); 5218 syncAppearanceOpacity(false); // clear any opacity-slider fade 5219 const content = modalEl.querySelector('.modal-content, .settings-modal-content'); 5220 if (content && !content.classList.contains('modal-closing')) { 5221 content.classList.add('modal-closing'); 5222 content.addEventListener('animationend', () => { 5223 modalEl.classList.add('hidden'); 5224 content.classList.remove('modal-closing'); 5225 }, { once: true }); 5226 setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250); 5227 } else { 5228 modalEl.classList.add('hidden'); 5229 } 5230} 5231 5232const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints }; 5233 5234 5235export default settingsModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.