Files
odysseus/static/js/settings.js

5800 lines
310 KiB
JavaScript
Raw Permalink Normal View History

2026-05-31 23:58:26 +09:00
// static/js/settings.js — Settings panel module (ES6)
// User-facing preferences: AI models, search, appearance
import uiModule from './ui.js';
import searchModule from './search.js';
import { makeWindowDraggable } from './windowDrag.js';
import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js';
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
import { providerLogo } from './providers.js';
Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts (#825) * Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts Browsers report AltGr (right Alt on AZERTY/QWERTZ and most non-US layouts, used to type @ # { } [ ] | \ and the euro sign) as ctrlKey+altKey. The default keybinds map destructive actions to Ctrl+Alt+<letter> (delete_session, new_session, incognito, open_calendar), so a non-US user typing a special character could silently fire them. Guard the shortcut matcher, the editor keydown handler, and the rebind capture with getModifierState('AltGraph'), which is true for AltGr but false for a genuine left Ctrl+Alt. macOS is excluded: there the Option key legitimately sets AltGraph and there is no AltGr/Ctrl+Alt collision to guard against, so the guard would otherwise break Ctrl+Option / Cmd+Option shortcuts (notably in Firefox). The detection lives in one place — isAltGrEvent / IS_MAC in static/js/platform.js — and all three call sites route through it, so the guards can't drift apart. The editor handler only skips the Ctrl+Alt chord block, so layout shortcuts reachable via AltGr (e.g. [ ] brush size = AltGr+5/+8 on AZERTY) keep working. * Require Ctrl+Alt for the AltGr guard and consolidate keybind test marks isAltGrEvent now also checks ctrlKey+altKey so it only suppresses the "AltGr reported as Ctrl+Alt" collision; an event asserting AltGraph on its own (a Linux ISO_Level3_Shift layout, a stray modifier) is left alone. Pin it with test_isaltgr_false_when_altgraph_set_but_not_ctrl_alt. Collapse the 12 per-test node skipif marks into one module-level pytestmark, and note in platform.js why IS_MAC intentionally covers iPad/iPhone and mirrors the isMac checks in calendar.js / sessions.js.
2026-06-02 04:12:54 +02:00
import { isAltGrEvent } from './platform.js';
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
import { bindMenuDismiss } from './escMenuStack.js';
2026-07-08 14:36:41 +02:00
import addonsModule from './addons.js';
2026-05-31 23:58:26 +09:00
let initialized = false;
let modalEl = null;
let _authPolicy = { password_min_length: 8 };
2026-05-31 23:58:26 +09:00
function el(id) { return document.getElementById(id); }
function esc(s) { return uiModule.esc(s); }
function safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
2026-05-31 23:58:26 +09:00
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
function initTabs() {
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.settingsTab;
// Lazy-init admin when first clicking an admin tab
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return;
}
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
// Mark when the Appearance tab is open so the modal can go
// semi-transparent — lets the user see the rest of the UI react as
// they flip toggles instead of having to close + reopen the modal.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
if (tab === 'ai') refreshAiModelEndpoints();
2026-07-08 14:36:41 +02:00
if (tab === 'addons') addonsModule.open();
2026-05-31 23:58:26 +09:00
});
});
}
/* ── Dragging ── */
function initDrag() {
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
// Skip interactive controls in the header (e.g. the opacity slider) so
// grabbing them doesn't start a window-drag.
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
2026-05-31 23:58:26 +09:00
});
}
function resetWindowPlacement() {
const content = modalEl && modalEl.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(prop => content.style.removeProperty(prop));
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
function initOpenPromptModalLink() {
document.addEventListener('click', async (e) => {
const link = e.target.closest('[data-open-prompt-modal]');
if (!link) return;
e.preventDefault();
// Close settings first so the prompt modal isn't stacked on top.
if (modalEl && !modalEl.classList.contains('hidden')) close();
try {
const m = await import('./presets.js');
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
if (typeof fn === 'function') fn();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
// Force the Persona tab (data-chartab="character") since the link's
// whole purpose is editing personas — not landing on Inject by default.
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
if (personaTab) personaTab.click();
});
}
2026-05-31 23:58:26 +09:00
/* ── Close on backdrop / X ── */
function initClose() {
modalEl.querySelector('.close-btn').addEventListener('click', close);
modalEl.addEventListener('mousedown', e => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modalEl) close();
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Bail when a transient popover inside the modal is open — Esc should
// dismiss just that, not the whole modal. Same-document listeners fire
// in registration order regardless of capture/bubble, so the popover's
// own handler can't pre-empt ours; we have to opt out here.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
return;
}
2026-05-31 23:58:26 +09:00
// If an integration edit/add form is open inside the modal, close
// just that — don't dismiss the whole settings modal. (Pressing
// ESC mid-edit and losing the modal was a fast-typing footgun.)
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
e.preventDefault();
e.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
e.preventDefault();
e.stopPropagation();
close();
});
}
/* ── Appearance-tab opacity slider ──
Mirrors the Theme customizer's slider: fades the settings modal's
background (and inner cards) via color-mix so the user can watch the
rest of the UI react to toggles, while keeping text/controls crisp
(no element opacity). Only shown/active on the Appearance tab. */
const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on
function _applySettingsOpacity(on) {
const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content');
if (!content) return;
const cards = content.querySelectorAll('.admin-card');
if (on) {
const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`;
const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`;
content.style.setProperty('background', bgMix, 'important');
content.style.setProperty('backdrop-filter', 'none', 'important');
content.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
cards.forEach(c => {
c.style.setProperty('background', panelMix, 'important');
c.style.setProperty('backdrop-filter', 'none', 'important');
c.style.setProperty('-webkit-backdrop-filter', 'none', 'important');
});
} else {
content.style.removeProperty('background');
content.style.removeProperty('backdrop-filter');
content.style.removeProperty('-webkit-backdrop-filter');
cards.forEach(c => {
c.style.removeProperty('background');
c.style.removeProperty('backdrop-filter');
c.style.removeProperty('-webkit-backdrop-filter');
});
}
}
// Show/hide the Peek toggle for the Appearance tab and apply or clear the fade.
function syncAppearanceOpacity(active) {
const toggle = el('settings-opacity-wrap');
if (toggle) toggle.classList.toggle('hidden', !active);
if (active) {
_applySettingsOpacity(toggle ? toggle.classList.contains('active') : false);
} else {
_applySettingsOpacity(false); // clear the fade off the Appearance tab
}
}
function initOpacityToggle() {
const toggle = el('settings-opacity-wrap');
if (!toggle || toggle.dataset.bound === '1') return;
toggle.dataset.bound = '1';
toggle.addEventListener('click', () => {
const on = !toggle.classList.contains('active');
toggle.classList.toggle('active', on);
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
_applySettingsOpacity(on);
});
}
/* ═══════════════════════════════════════════
AI TAB
═══════════════════════════════════════════ */
const _aiEndpointRefreshers = new Set();
let _aiEndpointRefreshInFlight = null;
async function _fetchModelEndpoints() {
const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const endpoints = await epRes.json();
return Array.isArray(endpoints) ? endpoints : [];
}
function _endpointLabel(ep) {
return ep.name + (ep.online ? '' : ' (offline)');
}
function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) {
if (!selectEl) return;
const previous = selected !== undefined ? selected : selectEl.value;
const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
? selectEl.options[0].textContent
: null;
while (selectEl.options.length) selectEl.remove(0);
if (blankText !== null) {
const blank = document.createElement('option');
blank.value = '';
blank.textContent = blankText;
selectEl.appendChild(blank);
}
(endpoints || []).forEach(function(ep) {
if (!ep.is_enabled) return;
const opt = document.createElement('option');
opt.value = ep.id;
opt.textContent = _endpointLabel(ep);
selectEl.appendChild(opt);
});
if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
selectEl.value = previous;
} else if (blankText !== null) {
selectEl.value = '';
}
_syncEndpointLogo(selectEl);
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Mirror the selected model's provider logo into a sibling <span id="<selectId>-logo">.
// Wires the change listener exactly once so we can call this every time the
// select is repopulated without piling on duplicate handlers.
function _syncModelLogo(selectEl) {
if (!selectEl) return;
const logoEl = document.getElementById(selectEl.id + '-logo');
if (!logoEl) return;
const apply = () => { logoEl.innerHTML = providerLogo(selectEl.value) || ''; };
apply();
if (!selectEl.dataset.logoSync) {
selectEl.dataset.logoSync = '1';
selectEl.addEventListener('change', apply);
}
}
// Same idea but for endpoint dropdowns where the <option value="…">
// is an opaque endpoint UUID — fall back to the option's text label
// so providerLogo() can pattern-match (Anthropic, OpenAI, Ollama, …).
function _syncEndpointLogo(selectEl) {
if (!selectEl) return;
const logoEl = document.getElementById(selectEl.id + '-logo');
if (!logoEl) return;
const apply = () => {
const opt = selectEl.options[selectEl.selectedIndex];
const label = (opt && opt.textContent) || selectEl.value || '';
logoEl.innerHTML = providerLogo(label) || '';
};
apply();
if (!selectEl.dataset.epLogoSync) {
selectEl.dataset.epLogoSync = '1';
selectEl.addEventListener('change', apply);
}
}
function _fillModelSelect(selectEl, models, selected, keepBlank) {
if (!selectEl) return;
const previous = selected !== undefined ? selected : selectEl.value;
const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === ''
? selectEl.options[0].textContent
: null;
while (selectEl.options.length) selectEl.remove(0);
if (blankText !== null) {
const blank = document.createElement('option');
blank.value = '';
blank.textContent = blankText;
selectEl.appendChild(blank);
}
sortModelIds(models).forEach(function(m) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = String(m).split('/').pop();
selectEl.appendChild(opt);
});
if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) {
selectEl.value = previous;
} else if (blankText !== null) {
selectEl.value = '';
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
_syncModelLogo(selectEl);
}
function _registerAiEndpointRefresh(fn) {
_aiEndpointRefreshers.add(fn);
}
export async function refreshAiModelEndpoints() {
if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight;
_aiEndpointRefreshInFlight = (async function() {
try {
const endpoints = await _fetchModelEndpoints();
_aiEndpointRefreshers.forEach(function(fn) {
try { fn(endpoints); } catch (e) { console.warn('[settings] endpoint refresh handler failed', e); }
});
} catch (e) {
console.warn('[settings] failed to refresh model endpoints', e);
} finally {
_aiEndpointRefreshInFlight = null;
}
})();
return _aiEndpointRefreshInFlight;
}
2026-05-31 23:58:26 +09:00
/* Shared fallback-chain widget — mirrors the Default Chat Model fallback UI
* for other model cards (Utility, Vision, …). Pass in the container/button
* IDs, the endpoints list, the settings key to persist under, and the
* model-filter (for Vision we exclude non-chat-capable models).
*/
function _bindFallbackWidget(opts) {
var fbContainer = el(opts.containerId);
var addBtn = el(opts.addBtnId);
var endpointsRef = opts.endpoints; // mutable list reference
var modelsFilter = opts.modelsFilter || function() { return true; };
var settingKey = opts.settingKey;
var current = opts.initial || []; // [{endpoint_id, model}]
if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} };
function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); }
function fillModels(selectEl, epId, selected) {
while (selectEl.options.length) selectEl.remove(0);
var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; });
if (ep && ep.models) {
sortModelIds(ep.models).forEach(function(m) {
2026-05-31 23:58:26 +09:00
if (!modelsFilter(m, ep)) return;
var o = document.createElement('option');
o.value = m;
o.textContent = m.split('/').pop();
selectEl.appendChild(o);
});
}
if (selected) selectEl.value = selected;
}
async function save() {
var clean = current.filter(function(f) { return f.endpoint_id && f.model; });
var body = {};
body[settingKey] = clean;
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
}
function render() {
fbContainer.innerHTML = '';
current.forEach(function(fb, idx) {
var row = document.createElement('div');
row.className = 'settings-fallback-row';
var num = document.createElement('span');
num.className = 'settings-fallback-num';
num.textContent = (idx + 1) + '.';
var epS = document.createElement('select');
epS.className = 'settings-select';
enabledEps().forEach(function(ep) {
var o = document.createElement('option');
o.value = ep.id;
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
epS.appendChild(o);
});
var first = enabledEps()[0];
epS.value = fb.endpoint_id || (first ? first.id : '');
var mS = document.createElement('select');
mS.className = 'settings-select';
fillModels(mS, epS.value, fb.model);
fb.endpoint_id = epS.value;
fb.model = mS.value;
epS.addEventListener('change', function() {
fb.endpoint_id = epS.value;
fillModels(mS, epS.value, '');
fb.model = mS.value;
save();
});
mS.addEventListener('change', function() { fb.model = mS.value; save(); });
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
rm.title = 'Remove fallback';
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
2026-05-31 23:58:26 +09:00
rm.addEventListener('click', function() {
current.splice(idx, 1);
render();
save();
});
row.appendChild(num);
row.appendChild(epS);
row.appendChild(mS);
row.appendChild(rm);
fbContainer.appendChild(row);
});
}
addBtn.addEventListener('click', function() {
var first = enabledEps()[0];
current.push({ endpoint_id: first ? first.id : '', model: '' });
render();
save();
});
render();
return {
setInitial: function(list) { current = (list || []).slice(); render(); },
refresh: render,
2026-05-31 23:58:26 +09:00
};
}
/* ── Default Chat Model ── */
async function initDefaultChat() {
var epSel = el('set-defaultEpSelect');
var modelSel = el('set-defaultModelSelect');
var msg = el('set-defaultChatMsg');
var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback');
var _endpoints = [];
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
function enabledEndpoints() {
return _endpoints.filter(function(e) { return e.is_enabled; });
}
// Fill any <select> with the models for a given endpoint id.
function fillModels(selectEl, epId, selected) {
var ep = _endpoints.find(function(e) { return e.id === epId; });
_fillModelSelect(selectEl, ep ? ep.models : [], selected, false);
2026-05-31 23:58:26 +09:00
}
try {
_endpoints = await _fetchModelEndpoints();
_fillEndpointSelect(epSel, _endpoints, epSel.value, false);
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load endpoints for default chat', e); }
function refreshModels(selectedModel) { fillModels(modelSel, epSel.value, selectedModel); }
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
renderFallbacks();
}
2026-05-31 23:58:26 +09:00
// Render the fallback chain. Each row is endpoint + model + remove.
function renderFallbacks() {
fbContainer.innerHTML = '';
_fallbacks.forEach(function(fb, idx) {
var row = document.createElement('div');
row.className = 'settings-fallback-row';
var num = document.createElement('span');
num.className = 'settings-fallback-num';
num.textContent = (idx + 1) + '.';
var epS = document.createElement('select');
epS.className = 'settings-select';
enabledEndpoints().forEach(function(ep) {
var o = document.createElement('option');
o.value = ep.id;
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
epS.appendChild(o);
});
var first = enabledEndpoints()[0];
epS.value = fb.endpoint_id || (first ? first.id : '');
var mS = document.createElement('select');
mS.className = 'settings-select';
fillModels(mS, epS.value, fb.model);
// Keep the model in sync with the values actually shown.
fb.endpoint_id = epS.value;
fb.model = mS.value;
epS.addEventListener('change', function() {
fb.endpoint_id = epS.value;
fillModels(mS, epS.value, '');
fb.model = mS.value;
saveDefault();
});
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
rm.title = 'Remove fallback';
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
2026-05-31 23:58:26 +09:00
rm.addEventListener('click', function() {
_fallbacks.splice(idx, 1);
renderFallbacks();
saveDefault();
});
row.appendChild(num);
row.appendChild(epS);
row.appendChild(mS);
row.appendChild(rm);
fbContainer.appendChild(row);
});
}
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
_fallbacks = Array.isArray(settings.default_model_fallbacks)
? settings.default_model_fallbacks.map(function(f) {
return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
})
: [];
renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
modelSel.addEventListener('change', saveDefault);
2026-05-31 23:58:26 +09:00
async function saveDefault() {
try {
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_endpoint_id: epSel.value,
default_model: modelSel.value,
default_model_fallbacks: clean
})
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
if (addFbBtn) addFbBtn.addEventListener('click', function() {
var first = enabledEndpoints()[0];
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
renderFallbacks();
saveDefault();
});
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
refreshEndpointOptions(epSel.value, modelSel.value);
});
2026-05-31 23:58:26 +09:00
}
/* ── Utility Model ── */
async function initUtilityModel() {
var epSel = el('set-utilityEpSelect');
var modelSel = el('set-utilityModelSelect');
var msg = el('set-utilityChatMsg');
var _endpoints = [];
var fallbackWidget = null;
2026-05-31 23:58:26 +09:00
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
try {
_endpoints = await _fetchModelEndpoints();
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
function refreshModels(selectedModel) {
var epId = epSel.value;
var ep = _endpoints.find(function(e) { return e.id === epId; });
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
2026-05-31 23:58:26 +09:00
}
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
refreshModels(settings.utility_model || '');
fallbackWidget = _bindFallbackWidget({
2026-05-31 23:58:26 +09:00
containerId: 'set-utilityFallbacks',
addBtnId: 'set-utilityAddFallback',
endpoints: function() { return _endpoints; },
settingKey: 'utility_model_fallbacks',
initial: Array.isArray(settings.utility_model_fallbacks)
? settings.utility_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; })
: [],
});
} catch (e) { console.warn('Failed to load utility model settings', e); }
// Persist whatever's currently selected. Empty endpoint or model → backend
// transparently falls back to the chat model (mirrors the teacher panel:
// no toggle, "—" means "unset, use chat").
async function saveUtility() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
utility_endpoint_id: epSel.value || '',
utility_model: modelSel.value || ''
})
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 1500);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
modelSel.addEventListener('change', saveUtility);
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
refreshModels(modelSel.value);
if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
});
2026-05-31 23:58:26 +09:00
}
/* ── Teacher Model ── */
// SOTA model called automatically when a self-hosted student model
// fails an agent-mode task. Stored as a single `teacher_model` string
// in the form `model@endpoint_name` so the backend's _resolve_model
// can dispatch directly. Master toggle is the separate
// `teacher_enabled` flag so the user can pause the feature without
// losing their endpoint+model selection.
async function initTeacherModel() {
var enabledToggle = el('set-teacherEnabledToggle');
var epSel = el('set-teacherEpSelect');
var modelSel = el('set-teacherModelSelect');
var msg = el('set-teacherChatMsg');
if (!epSel || !modelSel) return;
var _endpoints = [];
try {
_endpoints = await _fetchModelEndpoints();
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load endpoints for teacher model', e); }
function refreshModels(selectedModel) {
var epId = epSel.value;
var ep = _endpoints.find(function(e) { return e.id === epId; });
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
2026-05-31 23:58:26 +09:00
}
// Disable / enable the endpoint+model dropdowns based on the
// master switch. Greys them out so users see at a glance that the
// selection is dormant.
function syncEnabled() {
var off = enabledToggle ? !enabledToggle.checked : true;
// Dim the card when off as a "dormant" cue, but keep the endpoint+model
// dropdowns INTERACTIVE — the toggle gates whether escalation runs, not
// whether you can configure it. (Previously the config was inert when off,
// so users couldn't pick an endpoint until they'd already enabled it.)
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.7' : '';
var wrap = card ? card.querySelector('.settings-col') : null;
if (wrap) wrap.style.pointerEvents = '';
epSel.disabled = false;
modelSel.disabled = false;
}
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (enabledToggle) enabledToggle.checked = !!settings.teacher_enabled;
// teacher_model is stored as "model@endpoint_name". Split on the
// LAST `@` so model ids that contain @ aren't mangled.
var spec = settings.teacher_model || '';
var savedModel = spec;
var savedEpName = '';
var at = spec.lastIndexOf('@');
if (at >= 0) {
savedModel = spec.slice(0, at);
savedEpName = spec.slice(at + 1);
}
if (savedEpName) {
var match = _endpoints.find(function(ep) {
return ep.name && ep.name.toLowerCase().indexOf(savedEpName.toLowerCase()) >= 0;
});
if (match) epSel.value = match.id;
}
refreshModels(savedModel);
syncEnabled();
} catch (e) { console.warn('Failed to load teacher model settings', e); }
async function saveTeacher() {
try {
var spec = '';
if (epSel.value && modelSel.value) {
var ep = _endpoints.find(function(e) { return e.id === epSel.value; });
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
}
var enabled = enabledToggle ? !!enabledToggle.checked : false;
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
});
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
if (enabledToggle) {
enabledToggle.addEventListener('change', function() {
syncEnabled();
saveTeacher();
});
}
epSel.addEventListener('change', function() { refreshModels(''); saveTeacher(); });
modelSel.addEventListener('change', saveTeacher);
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
refreshModels(modelSel.value);
});
2026-05-31 23:58:26 +09:00
}
/* ── Image Generation ── */
async function initImageSettings() {
const modelSel = el('set-imgModelSelect');
const qualSel = el('set-imgQualitySelect');
const msg = el('set-imgSettingsMsg');
const enabledToggle = el('set-imgEnabledToggle');
const configWrap = modelSel ? modelSel.closest('div[style*="flex-direction"]') : null;
try {
const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
const modelsData = await modelsRes.json();
// Inpaint-compat allowlist — image gen here is scoped to inpainting only,
// so DALL-E / GPT-Image-1 (no inpaint API) are excluded. Currently:
// - any model with 'inpaint' in the id
// - Stable Diffusion 3.5 Medium (inpaint via diffusers pipeline)
const _isInpaintModel = (mid) => {
const lower = String(mid || '').toLowerCase();
return lower.includes('inpaint')
|| lower.includes('3.5-medium')
|| lower.includes('3-5-medium')
|| lower.includes('sd-3.5-med');
};
const imageModels = [];
(modelsData.items || []).forEach(item => {
(item.models || []).forEach(mid => {
if (_isInpaintModel(mid)) imageModels.push(mid);
});
});
sortModelIds(imageModels).forEach(mid => { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; modelSel.appendChild(opt); });
2026-05-31 23:58:26 +09:00
// Hardcoded fallbacks shown as "(not detected)" so users know what to
// download/serve to enable inpaint here.
['stable-diffusion-3.5-medium', 'stable-diffusion-inpainting'].forEach(mid => {
if (!imageModels.includes(mid)) { const opt = document.createElement('option'); opt.value = mid; opt.textContent = mid + ' (not detected)'; modelSel.appendChild(opt); }
});
} catch (e) { console.warn('Failed to load models for image settings', e); }
try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.image_model) modelSel.value = settings.image_model;
if (settings.image_quality) qualSel.value = settings.image_quality;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (enabledToggle) enabledToggle.checked = settings.image_gen_enabled === true;
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load settings', e); }
function syncImgDisabled() {
var off = enabledToggle && !enabledToggle.checked;
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
}
syncImgDisabled();
async function saveSettings() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
2026-05-31 23:58:26 +09:00
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
modelSel.addEventListener('change', saveSettings);
qualSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncImgDisabled(); saveSettings(); });
}
/* ── Vision ── */
async function initVisionSettings() {
const vlSel = el('set-vlModelSelect');
const msg = el('set-visionSettingsMsg');
const enabledToggle = el('set-visionEnabledToggle');
const configWrap = vlSel ? vlSel.closest('div[style*="flex-direction"]') : null;
var _visionEndpoints = [];
var visionFallbackWidget = null;
2026-05-31 23:58:26 +09:00
var _vlExclude = ['audio', 'realtime', 'tts', 'dall-e', 'embedding', 'search', 'whisper'];
function _isVisionModel(mid) {
var lower = String(mid || '').toLowerCase();
return !_vlExclude.some(function(kw) { return lower.includes(kw); });
}
try {
const modelsRes = await fetch('/api/models', { credentials: 'same-origin' });
const modelsData = await modelsRes.json();
const visionModels = [];
2026-05-31 23:58:26 +09:00
(modelsData.items || []).forEach(item => {
if (item.offline) return;
(item.models || []).forEach(mid => {
if (_isVisionModel(mid)) {
visionModels.push(mid);
2026-05-31 23:58:26 +09:00
}
});
});
sortModelIds(visionModels).forEach(mid => {
var opt = document.createElement('option'); opt.value = mid; opt.textContent = mid; vlSel.appendChild(opt);
});
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load models for vision settings', e); }
// Also pull the raw endpoint list so the fallback widget can resolve
// endpoint-id → models the same way the other cards do.
try {
_visionEndpoints = await _fetchModelEndpoints();
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load endpoints for vision fallback', e); }
try {
const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await settingsRes.json();
if (settings.vision_model) vlSel.value = settings.vision_model;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
_syncModelLogo(vlSel);
2026-05-31 23:58:26 +09:00
if (enabledToggle) enabledToggle.checked = settings.vision_enabled !== false;
visionFallbackWidget = _bindFallbackWidget({
2026-05-31 23:58:26 +09:00
containerId: 'set-visionFallbacks',
addBtnId: 'set-visionAddFallback',
endpoints: function() { return _visionEndpoints; },
// Vision fallback list filters to vision-capable models (same heuristic
// as the primary select above — exclude audio/tts/embedding/etc.).
modelsFilter: function(mid) { return _isVisionModel(mid); },
settingKey: 'vision_model_fallbacks',
initial: Array.isArray(settings.vision_model_fallbacks)
? settings.vision_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; })
: [],
});
} catch (e) { console.warn('Failed to load vision settings', e); }
function syncVisionDisabled() {
var off = enabledToggle && !enabledToggle.checked;
var card = enabledToggle ? enabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (configWrap) configWrap.style.pointerEvents = off ? 'none' : '';
}
syncVisionDisabled();
async function saveSettings() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
vlSel.addEventListener('change', saveSettings);
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); });
_registerAiEndpointRefresh(function(endpoints) {
_visionEndpoints = endpoints;
if (visionFallbackWidget && visionFallbackWidget.refresh) visionFallbackWidget.refresh();
});
2026-05-31 23:58:26 +09:00
}
/* ── Face Recognition ── */
/* ── Text to Speech ── */
async function initTtsSettings() {
var provSel = el('set-ttsProviderSelect');
var modelSelect = el('set-ttsModelSelect');
var modelInput = el('set-ttsModelInput');
var voiceSelect = el('set-ttsVoiceSelect');
var voiceInput = el('set-ttsVoiceInput');
var modelRow = el('set-ttsModelRow');
var voiceRow = el('set-ttsVoiceRow');
var speedSelect = el('set-ttsSpeedSelect');
var speedRow = el('set-ttsSpeedRow');
var ttsMsg = el('set-ttsSettingsMsg');
var ttsEnabledToggle = el('set-ttsEnabledToggle');
var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; }
function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; }
function updateVisibility() {
var prov = provSel.value;
modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none';
voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex';
speedRow.style.display = prov === 'disabled' ? 'none' : 'flex';
if (isEndpoint()) {
modelSelect.style.display = ''; modelInput.style.display = 'none';
voiceSelect.style.display = ''; voiceInput.style.display = 'none';
} else {
modelSelect.style.display = 'none'; modelInput.style.display = '';
voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : '';
}
}
var ttsKeywords = ['tts', 'audio'];
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw)));
if (!hasTTS) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for TTS', e); }
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.tts_provider) provSel.value = settings.tts_provider;
if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; }
if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; }
if (settings.tts_speed) { speedSelect.value = settings.tts_speed; }
if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false;
} catch (e) { console.warn('Failed to load TTS settings', e); }
function syncTtsDisabled() {
var off = ttsEnabledToggle && !ttsEnabledToggle.checked;
var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : '';
}
syncTtsDisabled();
updateVisibility();
async function saveTTS() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
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' }) });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
}
async function saveAndClearCache() {
await saveTTS();
fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){});
}
provSel.addEventListener('change', function() {
var prov = provSel.value;
if (prov === 'local') voiceInput.value = 'af_heart';
else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; }
else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; }
updateVisibility();
saveTTS();
});
modelSelect.addEventListener('change', saveAndClearCache);
modelInput.addEventListener('change', saveTTS);
voiceSelect.addEventListener('change', saveAndClearCache);
voiceInput.addEventListener('change', saveTTS);
speedSelect.addEventListener('change', saveAndClearCache);
if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); });
// Preview / test button
var previewBtn = el('set-ttsPreviewBtn');
if (previewBtn) {
var previewAudio = null;
var previewPlaying = false;
function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; }
previewBtn.addEventListener('click', async function() {
if (previewPlaying) {
if (previewAudio) { previewAudio.pause(); previewAudio = null; }
window.speechSynthesis.cancel();
resetPreview(); return;
}
var prov = provSel.value;
if (prov === 'disabled') {
ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return;
}
var testText = 'Hello, this is a test of text to speech.';
previewPlaying = true; previewBtn.textContent = 'Loading...';
try {
if (prov === 'browser') {
if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported');
var utt = new SpeechSynthesisUtterance(testText);
var voiceVal = getVoice();
if (voiceVal) {
var voices = window.speechSynthesis.getVoices();
var target = voiceVal.toLowerCase();
var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) ||
voices.find(function(v) { return v.name.toLowerCase().includes(target); });
if (match) utt.voice = match;
}
utt.rate = parseFloat(speedSelect.value) || 1;
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
utt.onend = resolve;
utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); };
window.speechSynthesis.speak(utt);
});
} else {
var res = await fetch('/api/tts/synthesize', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: testText, format: 'audio' })
});
if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); }
var blob = await res.blob();
var url = URL.createObjectURL(blob);
previewAudio = new Audio(url);
previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)';
await new Promise(function(resolve, reject) {
previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); };
previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); };
previewAudio.play().catch(reject);
});
}
} catch (e) {
ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)';
setTimeout(function() { ttsMsg.textContent = ''; }, 3000);
} finally {
resetPreview();
}
});
}
}
/* ── Speech to Text ── */
async function initSttSettings() {
var provSel = el('set-sttProviderSelect');
var modelSelect = el('set-sttModelSelect');
var modelInput = el('set-sttModelInput');
var modelRow = el('set-sttModelRow');
var langRow = el('set-sttLangRow');
var langInput = el('set-sttLangInput');
var sttMsg = el('set-sttSettingsMsg');
var sttEnabledToggle = el('set-sttEnabledToggle');
var sttConfigWrap = el('set-sttConfigWrap');
// STT was removed from AI Defaults — bail if the UI isn't present.
if (!provSel) return;
function isEndpoint() { return provSel.value.startsWith('endpoint:'); }
function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; }
function updateVisibility() {
var prov = provSel.value;
var showModel = prov === 'local' || prov.startsWith('endpoint:');
var showLang = prov !== 'disabled';
modelRow.style.display = showModel ? 'flex' : 'none';
langRow.style.display = showLang ? 'flex' : 'none';
if (isEndpoint()) {
modelSelect.style.display = 'none'; modelInput.style.display = '';
} else {
modelSelect.style.display = ''; modelInput.style.display = 'none';
}
}
function syncSttDisabled() {
var off = sttEnabledToggle && !sttEnabledToggle.checked;
var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null;
if (card) card.style.opacity = off ? '0.45' : '';
if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : '';
}
// Effective provider: if toggle is off, treat as disabled regardless of provider select
function effectiveProvider() {
if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled';
return provSel.value;
}
// Add API endpoints that might support STT
try {
var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
var endpoints = await epRes.json();
endpoints.forEach(function(ep) {
if (!ep.is_enabled) return;
var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load endpoints for STT', e); }
// Load saved settings
try {
var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await settingsRes.json();
if (settings.stt_provider) provSel.value = settings.stt_provider;
if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; }
if (settings.stt_language) langInput.value = settings.stt_language;
if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false;
} catch (e) { console.warn('Failed to load STT settings', e); }
syncSttDisabled();
updateVisibility();
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
}
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
modelSelect.addEventListener('change', saveSTT);
modelInput.addEventListener('change', saveSTT);
langInput.addEventListener('change', saveSTT);
if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); });
}
/* ═══════════════════════════════════════════
SEARCH TAB
═══════════════════════════════════════════ */
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
var _LINK = function(href, text) {
return '<a href="' + href + '" target="_blank" rel="noopener noreferrer" style="color:var(--accent, var(--red));text-decoration:underline;">' + text + '</a>';
};
2026-05-31 23:58:26 +09:00
var _searchProviderHints = {
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
searxng: 'Private, self-hosted instance. Leave URL empty to use the SEARXNG_INSTANCE env var.',
duckduckgo: 'No API key needed, but rate-limited — heavy use can return empty results. Configure a fallback below.',
brave: 'Get your API key from ' + _LINK('https://brave.com/search/api/', 'brave.com/search/api'),
google_pse: 'Requires a Google API key and a Programmable Search Engine ID (CX). Create one at ' + _LINK('https://programmablesearchengine.google.com/', 'programmablesearchengine.google.com'),
tavily: 'AI-optimized search. 1,000 free credits/month at ' + _LINK('https://tavily.com/', 'tavily.com'),
serper: 'Google results via API. 2,500 free queries at ' + _LINK('https://serper.dev/', 'serper.dev'),
2026-05-31 23:58:26 +09:00
disabled: 'Web search and deep research tools will be unavailable.',
};
var _searchNeedsKey = { brave: 1, google_pse: 1, tavily: 1, serper: 1 };
var _searchLabels = {
searxng: 'SearXNG', duckduckgo: 'DuckDuckGo', brave: 'Brave Search',
google_pse: 'Google PSE', tavily: 'Tavily', serper: 'Serper', disabled: 'Disabled',
};
var _searchKeyFields = {
brave: 'brave_api_key', google_pse: 'google_pse_key',
tavily: 'tavily_api_key', serper: 'serper_api_key',
};
async function initSearchSettings() {
var provSel = el('set-searchProvider');
var countSel = el('set-searchResultCount');
var countCustomInput = el('set-searchResultCountCustom');
2026-05-31 23:58:26 +09:00
var urlInput = el('set-searchUrl');
var urlRow = el('set-searchUrlRow');
var keyInput = el('set-searchApiKey');
var keyRow = el('set-searchKeyRow');
var cxInput = el('set-searchCx');
var cxRow = el('set-searchCxRow');
var hint = el('set-searchHint');
var msg = el('set-searchMsg');
var _settings = {};
function keyFieldFor(prov) { return _searchKeyFields[prov] || ''; }
function loadKeyForProvider(prov) {
var field = keyFieldFor(prov);
keyInput.value = field ? (_settings[field] || _settings.search_api_key || '') : '';
}
function updateVisibility() {
var prov = provSel.value;
urlRow.style.display = prov === 'searxng' ? 'flex' : 'none';
keyRow.style.display = _searchNeedsKey[prov] ? 'flex' : 'none';
cxRow.style.display = prov === 'google_pse' ? 'flex' : 'none';
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
hint.innerHTML = _searchProviderHints[prov] || '';
2026-05-31 23:58:26 +09:00
if (prov === 'brave') keyInput.placeholder = 'Brave API key';
else if (prov === 'google_pse') keyInput.placeholder = 'Google API key';
else if (prov === 'tavily') keyInput.placeholder = 'Tavily API key';
else if (prov === 'serper') keyInput.placeholder = 'Serper API key';
else keyInput.placeholder = 'API key';
loadKeyForProvider(prov);
}
function updateCountDisplay() {
var val = _settings.search_result_count || 5;
var presets = ['3', '5', '10', '20'];
if (presets.includes(String(val))) {
countSel.value = String(val);
countCustomInput.style.display = 'none';
} else {
countSel.value = 'custom';
countCustomInput.value = Math.max(1, Math.min(100, val));
countCustomInput.style.display = 'block';
}
}
2026-05-31 23:58:26 +09:00
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
_settings = await res.json();
if (_settings.search_provider) provSel.value = _settings.search_provider;
updateCountDisplay();
2026-05-31 23:58:26 +09:00
if (_settings.search_url) urlInput.value = _settings.search_url;
if (_settings.google_pse_cx) cxInput.value = _settings.google_pse_cx;
} catch (e) { console.warn('Failed to load search settings', e); }
countSel.addEventListener('change', function() {
if (this.value === 'custom') {
countCustomInput.style.display = 'block';
countCustomInput.focus();
} else {
countCustomInput.style.display = 'none';
}
});
2026-05-31 23:58:26 +09:00
updateVisibility();
async function refreshStatus() {
try {
var sRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var s = await sRes.json();
_settings = s;
var active = s.search_provider || 'searxng';
var label = _searchLabels[active] || active;
var extra = '';
var kf = keyFieldFor(active);
var hasKey = kf ? ((s[kf] || '').trim() || (s.search_api_key || '').trim()) : false;
if (_searchNeedsKey[active]) {
extra = hasKey ? ' (key set)' : ' (no key)';
} else if (active === 'searxng' && (s.search_url || '').trim()) {
extra = ' (' + s.search_url + ')';
}
var count = s.search_result_count || 5;
msg.textContent = 'Active: ' + label + extra + ' \u00b7 ' + count + ' results';
msg.style.color = active === 'disabled' ? 'var(--red)' : (_searchNeedsKey[active] && !hasKey) ? 'var(--red)' : 'var(--fg)';
} catch (e) { /* ignore */ }
}
refreshStatus();
async function saveSearch() {
try {
var prov = provSel.value;
var resultCount;
if (countSel.value === 'custom') {
var customVal = parseInt(countCustomInput.value, 10);
if (isNaN(customVal) || customVal < 1 || customVal > 100) {
resultCount = _settings.search_result_count || 5;
} else {
resultCount = customVal;
}
} else {
resultCount = parseInt(countSel.value, 10);
}
2026-05-31 23:58:26 +09:00
var payload = {
search_provider: prov,
search_result_count: resultCount,
2026-05-31 23:58:26 +09:00
search_url: urlInput.value.trim(),
google_pse_cx: cxInput.value.trim(),
};
var kf = keyFieldFor(prov);
if (kf) {
payload[kf] = keyInput.value.trim();
_settings[kf] = keyInput.value.trim();
}
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
if (searchModule && searchModule.refresh) searchModule.refresh();
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
provSel.addEventListener('change', function() { updateVisibility(); saveSearch(); _syncSearchPicker(); });
countSel.addEventListener('change', saveSearch);
urlInput.addEventListener('change', saveSearch);
keyInput.addEventListener('change', saveSearch);
cxInput.addEventListener('change', saveSearch);
// ── Provider picker with logos (mirrors the hidden <select>) ──
var picker = el('search-provider-picker');
var pickerBtn = el('search-provider-btn');
var pickerMenu = el('search-provider-menu');
var pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
function _searchProviderLogoSvg(key) {
return _SEARCH_PROVIDER_LOGOS[key] || '';
}
function _renderSearchPickerMenu() {
if (!pickerMenu) return;
pickerMenu.innerHTML = Array.from(provSel.options).map(function(o) {
var logo = _searchProviderLogoSvg(o.dataset.searchLogo);
var active = o.value === provSel.value ? ' active' : '';
return '<div class="adm-provider-item' + active + '" role="option" data-value="' + o.value.replace(/"/g, '&quot;') + '">' +
'<span class="adm-provider-logo">' + logo + '</span>' +
'<span>' + o.textContent + '</span>' +
'</div>';
}).join('');
}
function _syncSearchPicker() {
if (!pickerCurrent) return;
var opt = provSel.selectedOptions[0] || provSel.options[0];
var logo = _searchProviderLogoSvg(opt.dataset.searchLogo);
pickerCurrent.querySelector('.adm-provider-logo').innerHTML = logo;
pickerCurrent.querySelector('.adm-provider-name').textContent = opt.textContent;
}
if (picker && pickerBtn && pickerMenu && pickerCurrent) {
_renderSearchPickerMenu();
_syncSearchPicker();
pickerBtn.addEventListener('click', function(e) {
e.stopPropagation();
pickerMenu.classList.toggle('hidden');
});
pickerMenu.addEventListener('click', function(e) {
var item = e.target.closest('.adm-provider-item');
if (!item) return;
provSel.value = item.dataset.value;
provSel.dispatchEvent(new Event('change', { bubbles: true }));
pickerMenu.classList.add('hidden');
_renderSearchPickerMenu();
});
document.addEventListener('click', function(e) {
if (!picker.contains(e.target)) pickerMenu.classList.add('hidden');
});
}
// ── Fallback chain ──
// Stored as an ordered array of provider IDs (primary not included).
// When the primary fails or hits rate-limit, the backend walks this
// list in order trying each one.
var fbWrap = el('set-searchFallbackChain');
function _availableFallbackOptions() {
var primary = provSel.value;
var chain = _settings.search_fallback_chain || [];
var inChain = new Set(chain.concat([primary, 'disabled']));
return Array.from(provSel.options)
.map(function(o) { return { value: o.value, label: o.textContent, logo: o.dataset.searchLogo }; })
.filter(function(o) { return !inChain.has(o.value); });
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
var addBtn = el('set-searchAddFallback');
var TRASH_SVG = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
2026-05-31 23:58:26 +09:00
function _renderFallbackChain() {
if (!fbWrap) return;
var chain = (_settings.search_fallback_chain || []).slice();
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
fbWrap.innerHTML = '';
chain.forEach(function(p, idx) {
var row = document.createElement('div');
row.className = 'settings-fallback-row';
var num = document.createElement('span');
num.className = 'settings-fallback-num';
num.textContent = (idx + 1) + '.';
row.appendChild(num);
// Inline logo so the row identifies its provider at a glance even
// before opening the dropdown. The <select> below still drives
// selection; we just mirror its value into the logo span.
var logoWrap = document.createElement('span');
logoWrap.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;color:var(--fg);';
var setLogo = function(val) {
var srcOpt = Array.from(provSel.options).find(function(o) { return o.value === val; });
logoWrap.innerHTML = srcOpt ? _searchProviderLogoSvg(srcOpt.dataset.searchLogo) : '';
};
setLogo(p);
row.appendChild(logoWrap);
var sel = document.createElement('select');
sel.className = 'settings-select';
// Options: this row's current value + every other provider not yet in the chain (and not the primary or 'disabled').
var primary = provSel.value;
var others = new Set(chain.filter(function(x) { return x !== p; }).concat([primary, 'disabled']));
Array.from(provSel.options).forEach(function(o) {
if (o.value !== p && others.has(o.value)) return;
var opt = document.createElement('option');
opt.value = o.value;
opt.textContent = o.textContent;
sel.appendChild(opt);
2026-05-31 23:58:26 +09:00
});
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
sel.value = p;
sel.addEventListener('change', function() {
setLogo(sel.value);
2026-05-31 23:58:26 +09:00
var next = (_settings.search_fallback_chain || []).slice();
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
next[idx] = sel.value;
2026-05-31 23:58:26 +09:00
_saveFallbackChain(next);
});
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
row.appendChild(sel);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
rm.title = 'Remove fallback';
rm.innerHTML = TRASH_SVG;
rm.addEventListener('click', function() {
var next = (_settings.search_fallback_chain || []).filter(function(x, i) { return i !== idx; });
_saveFallbackChain(next);
2026-05-31 23:58:26 +09:00
});
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
row.appendChild(rm);
fbWrap.appendChild(row);
});
// Add-fallback button: disabled when there are no remaining providers to add.
if (addBtn) {
var hasMore = _availableFallbackOptions().length > 0;
addBtn.style.display = hasMore ? '' : 'none';
}
}
if (addBtn && !addBtn._wired) {
addBtn._wired = true;
addBtn.addEventListener('click', function() {
var avail = _availableFallbackOptions();
if (!avail.length) return;
var next = (_settings.search_fallback_chain || []).slice();
next.push(avail[0].value);
_saveFallbackChain(next);
2026-05-31 23:58:26 +09:00
});
}
async function _saveFallbackChain(chain) {
_settings.search_fallback_chain = chain;
try {
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ search_fallback_chain: chain }),
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
_renderFallbackChain();
}
_renderFallbackChain();
// Re-render whenever the primary changes (it gets filtered out of "Add").
provSel.addEventListener('change', _renderFallbackChain);
// ── Test button ── runs a one-off query against the configured provider.
var testBtn = el('set-searchTestBtn');
if (testBtn) {
testBtn.addEventListener('click', async function() {
var prov = provSel.value;
if (!prov || prov === 'disabled') {
msg.textContent = 'Pick a provider first';
msg.style.color = 'var(--red)';
return;
}
// Persist current form values first so the test uses what's on screen.
await saveSearch();
testBtn.disabled = true;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
var origHtml = testBtn.innerHTML;
var wp = null;
try {
var sp = window.spinnerModule || (await import('./spinner.js')).default;
wp = sp.createWhirlpool(11);
wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;';
testBtn.innerHTML = '';
testBtn.appendChild(wp.element);
testBtn.appendChild(document.createTextNode('Testing'));
} catch (_) {
testBtn.innerHTML = origHtml.replace(/>Test\s*$/, '>Testing...');
}
2026-05-31 23:58:26 +09:00
msg.textContent = '';
var t0 = performance.now();
try {
var fd = new FormData();
fd.append('query', 'hello world');
fd.append('provider', prov);
fd.append('count', '3');
var r = await fetch('/api/search/query', { method: 'POST', body: fd, credentials: 'same-origin' });
var d = await r.json();
var ms = Math.round(performance.now() - t0);
if (d.error) {
msg.textContent = '✗ ' + d.error + ' (' + ms + 'ms)';
msg.style.color = 'var(--red)';
} else if (!d.results || !d.results.length) {
msg.textContent = '⚠ No results returned (' + ms + 'ms)';
msg.style.color = 'var(--red)';
} else {
var topTitle = (d.results[0].title || d.results[0].url || '').slice(0, 60);
msg.textContent = '✓ ' + d.results.length + ' result' + (d.results.length === 1 ? '' : 's') + ' · ' + ms + 'ms · top: ' + topTitle;
msg.style.color = 'var(--fg)';
}
} catch (e) {
msg.textContent = '✗ Test failed: ' + (e && e.message ? e.message : e);
msg.style.color = 'var(--red)';
} finally {
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (wp) { try { wp.destroy(); } catch (_) {} }
testBtn.disabled = false; testBtn.innerHTML = origHtml;
2026-05-31 23:58:26 +09:00
}
});
}
}
// SVG logos for each search provider (16×16 viewBox normalised to 24×24).
var _SEARCH_PROVIDER_LOGOS = {
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>',
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>',
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>',
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>',
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>',
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>',
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>',
};
/* ── Deep Research Model (AI tab) ── */
async function initResearchSettings() {
var epSel = el('set-researchEndpoint');
var modelSel = el('set-researchModel');
var tokensInput = el('set-researchMaxTokens');
2026-06-01 14:55:24 +09:00
var extractTimeoutInput = el('set-researchExtractTimeout');
var extractConcurrencyInput = el('set-researchExtractConcurrency');
var runTimeoutInput = el('set-researchRunTimeout');
2026-05-31 23:58:26 +09:00
var msg = el('set-researchMsg');
var endpoints = [];
2026-05-31 23:58:26 +09:00
try {
endpoints = await _fetchModelEndpoints();
_fillEndpointSelect(epSel, endpoints, epSel.value, true);
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load endpoints for research', e); }
function refreshModels(selectedModel) {
2026-05-31 23:58:26 +09:00
var epId = epSel.value;
var ep = endpoints.find(function(e) { return e.id === epId; });
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
2026-05-31 23:58:26 +09:00
}
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id;
refreshModels(settings.research_model || '');
2026-05-31 23:58:26 +09:00
if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
2026-06-01 14:55:24 +09:00
if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds;
if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency;
if (settings.research_run_timeout_seconds !== undefined && settings.research_run_timeout_seconds !== null) {
runTimeoutInput.value = settings.research_run_timeout_seconds;
}
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load research settings', e); }
function showStatus() {
var parts = [];
if (epSel.value) {
var epName = epSel.options[epSel.selectedIndex].textContent;
var mName = modelSel.value ? modelSel.value.split('/').pop() : 'auto';
parts.push(epName + ' / ' + mName);
}
if (tokensInput.value) {
parts.push('Max tokens: ' + tokensInput.value);
}
2026-06-01 14:55:24 +09:00
if (extractTimeoutInput.value) {
parts.push('Extract: ' + extractTimeoutInput.value + 's');
}
if (extractConcurrencyInput.value) {
parts.push('Parallel: ' + extractConcurrencyInput.value);
}
if (runTimeoutInput.value !== '') {
var rtv = parseInt(runTimeoutInput.value, 10);
if (!isNaN(rtv)) {
parts.push(rtv === 0 ? 'Max time: no limit' : 'Max time: ' + rtv + 's');
}
}
2026-05-31 23:58:26 +09:00
if (parts.length) {
msg.textContent = parts.join(' · ');
msg.style.color = 'var(--fg)';
} else {
msg.textContent = 'Using chat defaults';
msg.style.color = 'var(--fg)';
}
}
showStatus();
async function saveResearch() {
var payload = {
research_endpoint_id: epSel.value,
research_model: modelSel.value,
};
var tv = parseInt(tokensInput.value, 10);
if (tv && tv >= 1024) payload.research_max_tokens = tv;
2026-06-01 14:55:24 +09:00
var et = parseInt(extractTimeoutInput.value, 10);
if (et && et >= 15 && et <= 3600) payload.research_extraction_timeout_seconds = et;
2026-06-01 14:55:24 +09:00
var ec = parseInt(extractConcurrencyInput.value, 10);
if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec;
if (runTimeoutInput.value !== '') {
var rt = parseInt(runTimeoutInput.value, 10);
// 0 = no limit (disables the hard timeout); otherwise 60s..86400s (24h)
if (!isNaN(rt) && (rt === 0 || (rt >= 60 && rt <= 86400))) {
payload.research_run_timeout_seconds = rt;
}
}
2026-05-31 23:58:26 +09:00
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(showStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
epSel.addEventListener('change', async function() {
refreshModels('');
2026-05-31 23:58:26 +09:00
saveResearch();
});
modelSel.addEventListener('change', saveResearch);
tokensInput.addEventListener('change', saveResearch);
2026-06-01 14:55:24 +09:00
extractTimeoutInput.addEventListener('change', saveResearch);
extractConcurrencyInput.addEventListener('change', saveResearch);
runTimeoutInput.addEventListener('change', saveResearch);
_registerAiEndpointRefresh(function(nextEndpoints) {
endpoints = nextEndpoints;
_fillEndpointSelect(epSel, endpoints, epSel.value, true);
refreshModels(modelSel.value);
});
2026-05-31 23:58:26 +09:00
}
/* ── Deep Research Search (Search tab) ── */
async function initResearchSearchSettings() {
var searchSel = el('set-researchSearch');
var msg = el('set-researchSearchMsg');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
var logoEl = el('set-researchSearch-logo');
function updateSearchLogo() {
if (!logoEl) return;
var opt = searchSel.selectedOptions[0];
var key = opt && opt.dataset ? opt.dataset.searchLogo : '';
logoEl.innerHTML = key ? (_SEARCH_PROVIDER_LOGOS[key] || '') : '';
}
2026-05-31 23:58:26 +09:00
function updateSearchOptions(settings) {
var options = searchSel.querySelectorAll('option');
options.forEach(function(opt) {
var prov = opt.value;
if (!prov) return;
var kf = _searchKeyFields[prov];
if (!kf) return;
var hasKey = ((settings[kf] || '').trim() || (settings.search_api_key || '').trim());
if (!hasKey) {
opt.textContent = (_searchLabels[prov] || prov) + ' (no key)';
opt.style.color = 'var(--red)';
} else {
opt.textContent = _searchLabels[prov] || prov;
opt.style.color = '';
}
});
}
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.research_search_provider) searchSel.value = settings.research_search_provider;
updateSearchOptions(settings);
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
updateSearchLogo();
2026-05-31 23:58:26 +09:00
} catch (e) { console.warn('Failed to load research search settings', e); }
async function saveResearchSearch() {
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ research_search_provider: searchSel.value })
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
searchSel.addEventListener('change', function() { updateSearchLogo(); saveResearchSearch(); });
2026-05-31 23:58:26 +09:00
}
/* ── Agent Settings (AI tab) ── */
async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
var roundsInput = el('set-agentMaxRounds');
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish Surface a lot of accumulated cookbook + UI work as a single non-agent commit so the agent rework lands cleanly. Highlights: - Ollama as a first-class backend in the Cookbook: * Download input accepts ollama-style names (name:tag) → backend=ollama * /api/cookbook/ollama/library (cached scrape of ollama.com + curated fallback so classic models like qwen2.5 stay reachable) * "Browse Ollama library" toggle below Download with size chips * Engine=Ollama in hwfit toolbar merges the Ollama library into the main scan list as per-tag rows with the same Fit/Param/Quant/VRAM columns; click → fills Download input - API Tokens form added to Integrations panel (matching wired loadTokens()/initTokenForm() that had no HTML) - Serve panel polish: Advanced fold tightening (-8px nudges on vLLM checks, Extra args, Spec row), n_cpu_moe + Split Mode controls pulled up 8px to align with the row's checkboxes, GGUF File dropdown exposed for Ollama backend, GPU re-render on Edit serve restore, _forceBackend flag so saved serveState wins over backend detection, cookbook:servers-changed CustomEvent so panels don't need refresh - Models page redesign: Add Models row (URL + hidden API key reveal + Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All + Clear-offline buttons in Added Models toolbar, offline-pill removed (opacity already conveys state), Engine dropdown gains Ollama option - _ping_endpoint probes /v1/models then base, accepts 4xx as reachable (vLLM returns 404 on bare /v1, fully working endpoints were showing offline) - Diagnosis card: × dismiss + Copy bundle buttons restored on the serve error feedback card - Orphan tmux sweep re-enabled behind a 60s rate-limit + background Thread (off the main event loop) so dead serves get discovered - cookbook_routes auto-register watchdog: drops the endpoint if the serve session exits non-zero within the first ~3min - ollama-rocm sidecar awareness in download wrapper (`docker exec ollama-rocm ollama pull` when host ollama isn't installed) - Skill extractor sets initial_status="published" when auto_approve_skills pref is on (audit demotes later) - Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
var supInput = el('set-agentSupervisorLadder');
2026-05-31 23:58:26 +09:00
var msg = el('set-agentMsg');
if (!toolsInput) return;
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish Surface a lot of accumulated cookbook + UI work as a single non-agent commit so the agent rework lands cleanly. Highlights: - Ollama as a first-class backend in the Cookbook: * Download input accepts ollama-style names (name:tag) → backend=ollama * /api/cookbook/ollama/library (cached scrape of ollama.com + curated fallback so classic models like qwen2.5 stay reachable) * "Browse Ollama library" toggle below Download with size chips * Engine=Ollama in hwfit toolbar merges the Ollama library into the main scan list as per-tag rows with the same Fit/Param/Quant/VRAM columns; click → fills Download input - API Tokens form added to Integrations panel (matching wired loadTokens()/initTokenForm() that had no HTML) - Serve panel polish: Advanced fold tightening (-8px nudges on vLLM checks, Extra args, Spec row), n_cpu_moe + Split Mode controls pulled up 8px to align with the row's checkboxes, GGUF File dropdown exposed for Ollama backend, GPU re-render on Edit serve restore, _forceBackend flag so saved serveState wins over backend detection, cookbook:servers-changed CustomEvent so panels don't need refresh - Models page redesign: Add Models row (URL + hidden API key reveal + Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All + Clear-offline buttons in Added Models toolbar, offline-pill removed (opacity already conveys state), Engine dropdown gains Ollama option - _ping_endpoint probes /v1/models then base, accepts 4xx as reachable (vLLM returns 404 on bare /v1, fully working endpoints were showing offline) - Diagnosis card: × dismiss + Copy bundle buttons restored on the serve error feedback card - Orphan tmux sweep re-enabled behind a 60s rate-limit + background Thread (off the main event loop) so dead serves get discovered - cookbook_routes auto-register watchdog: drops the endpoint if the serve session exits non-zero within the first ~3min - ollama-rocm sidecar awareness in download wrapper (`docker exec ollama-rocm ollama pull` when host ollama isn't installed) - Skill extractor sets initial_status="published" when auto_approve_skills pref is on (audit demotes later) - Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
2026-05-31 23:58:26 +09:00
} catch (e) {}
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
// when blank/non-numeric. Mirrors the server-side validation.
function clampInt(raw, lo, hi, dflt) {
var n = parseInt(raw, 10);
if (isNaN(n)) return dflt;
return Math.max(lo, Math.min(n, hi));
}
2026-05-31 23:58:26 +09:00
async function save() {
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
var tools = clampInt(toolsInput.value, 0, 1000, 0);
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
toolsInput.value = tools; // reflect the clamped value
if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish Surface a lot of accumulated cookbook + UI work as a single non-agent commit so the agent rework lands cleanly. Highlights: - Ollama as a first-class backend in the Cookbook: * Download input accepts ollama-style names (name:tag) → backend=ollama * /api/cookbook/ollama/library (cached scrape of ollama.com + curated fallback so classic models like qwen2.5 stay reachable) * "Browse Ollama library" toggle below Download with size chips * Engine=Ollama in hwfit toolbar merges the Ollama library into the main scan list as per-tag rows with the same Fit/Param/Quant/VRAM columns; click → fills Download input - API Tokens form added to Integrations panel (matching wired loadTokens()/initTokenForm() that had no HTML) - Serve panel polish: Advanced fold tightening (-8px nudges on vLLM checks, Extra args, Spec row), n_cpu_moe + Split Mode controls pulled up 8px to align with the row's checkboxes, GGUF File dropdown exposed for Ollama backend, GPU re-render on Edit serve restore, _forceBackend flag so saved serveState wins over backend detection, cookbook:servers-changed CustomEvent so panels don't need refresh - Models page redesign: Add Models row (URL + hidden API key reveal + Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All + Clear-offline buttons in Added Models toolbar, offline-pill removed (opacity already conveys state), Engine dropdown gains Ollama option - _ping_endpoint probes /v1/models then base, accepts 4xx as reachable (vLLM returns 404 on bare /v1, fully working endpoints were showing offline) - Diagnosis card: × dismiss + Copy bundle buttons restored on the serve error feedback card - Orphan tmux sweep re-enabled behind a 60s rate-limit + background Thread (off the main event loop) so dead serves get discovered - cookbook_routes auto-register watchdog: drops the endpoint if the serve session exits non-zero within the first ~3min - ollama-rocm sidecar awareness in download wrapper (`docker exec ollama-rocm ollama pull` when host ollama isn't installed) - Skill extractor sets initial_status="published" when auto_approve_skills pref is on (audit demotes later) - Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
2026-05-31 23:58:26 +09:00
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
body: JSON.stringify(payload)
2026-05-31 23:58:26 +09:00
});
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish Surface a lot of accumulated cookbook + UI work as a single non-agent commit so the agent rework lands cleanly. Highlights: - Ollama as a first-class backend in the Cookbook: * Download input accepts ollama-style names (name:tag) → backend=ollama * /api/cookbook/ollama/library (cached scrape of ollama.com + curated fallback so classic models like qwen2.5 stay reachable) * "Browse Ollama library" toggle below Download with size chips * Engine=Ollama in hwfit toolbar merges the Ollama library into the main scan list as per-tag rows with the same Fit/Param/Quant/VRAM columns; click → fills Download input - API Tokens form added to Integrations panel (matching wired loadTokens()/initTokenForm() that had no HTML) - Serve panel polish: Advanced fold tightening (-8px nudges on vLLM checks, Extra args, Spec row), n_cpu_moe + Split Mode controls pulled up 8px to align with the row's checkboxes, GGUF File dropdown exposed for Ollama backend, GPU re-render on Edit serve restore, _forceBackend flag so saved serveState wins over backend detection, cookbook:servers-changed CustomEvent so panels don't need refresh - Models page redesign: Add Models row (URL + hidden API key reveal + Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All + Clear-offline buttons in Added Models toolbar, offline-pill removed (opacity already conveys state), Engine dropdown gains Ollama option - _ping_endpoint probes /v1/models then base, accepts 4xx as reachable (vLLM returns 404 on bare /v1, fully working endpoints were showing offline) - Diagnosis card: × dismiss + Copy bundle buttons restored on the serve error feedback card - Orphan tmux sweep re-enabled behind a 60s rate-limit + background Thread (off the main event loop) so dead serves get discovered - cookbook_routes auto-register watchdog: drops the endpoint if the serve session exits non-zero within the first ~3min - ollama-rocm sidecar awareness in download wrapper (`docker exec ollama-rocm ollama pull` when host ollama isn't installed) - Skill extractor sets initial_status="published" when auto_approve_skills pref is on (audit demotes later) - Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
2026-05-31 23:58:26 +09:00
msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
toolsInput.addEventListener('change', save);
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
if (roundsInput) roundsInput.addEventListener('change', save);
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish Surface a lot of accumulated cookbook + UI work as a single non-agent commit so the agent rework lands cleanly. Highlights: - Ollama as a first-class backend in the Cookbook: * Download input accepts ollama-style names (name:tag) → backend=ollama * /api/cookbook/ollama/library (cached scrape of ollama.com + curated fallback so classic models like qwen2.5 stay reachable) * "Browse Ollama library" toggle below Download with size chips * Engine=Ollama in hwfit toolbar merges the Ollama library into the main scan list as per-tag rows with the same Fit/Param/Quant/VRAM columns; click → fills Download input - API Tokens form added to Integrations panel (matching wired loadTokens()/initTokenForm() that had no HTML) - Serve panel polish: Advanced fold tightening (-8px nudges on vLLM checks, Extra args, Spec row), n_cpu_moe + Split Mode controls pulled up 8px to align with the row's checkboxes, GGUF File dropdown exposed for Ollama backend, GPU re-render on Edit serve restore, _forceBackend flag so saved serveState wins over backend detection, cookbook:servers-changed CustomEvent so panels don't need refresh - Models page redesign: Add Models row (URL + hidden API key reveal + Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All + Clear-offline buttons in Added Models toolbar, offline-pill removed (opacity already conveys state), Engine dropdown gains Ollama option - _ping_endpoint probes /v1/models then base, accepts 4xx as reachable (vLLM returns 404 on bare /v1, fully working endpoints were showing offline) - Diagnosis card: × dismiss + Copy bundle buttons restored on the serve error feedback card - Orphan tmux sweep re-enabled behind a 60s rate-limit + background Thread (off the main event loop) so dead serves get discovered - cookbook_routes auto-register watchdog: drops the endpoint if the serve session exits non-zero within the first ~3min - ollama-rocm sidecar awareness in download wrapper (`docker exec ollama-rocm ollama pull` when host ollama isn't installed) - Skill extractor sets initial_status="published" when auto_approve_skills pref is on (audit demotes later) - Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
if (supInput) supInput.addEventListener('change', save);
2026-05-31 23:58:26 +09:00
var cur = parseInt(toolsInput.value, 10) || 0;
feat: round-limit handling — Continue affordance at the cap + configurable cap (#1999) * feat: round-limit handling — Continue affordance at the cap + configurable cap When the agent loop runs out of rounds (per-message step cap, default 20) while still actively using tools, it stopped silently mid-task. Now: 1. The loop emits a `rounds_exhausted` SSE event at the cap, and the UI shows a "Continue" pill at the bottom of the chat that resumes the task from where it left off. Repeated cap-hits each get a fresh Continue (multiple continues in a row). 2. The cap is configurable in Settings → Agent ("Max steps per message"), validated on the client, at the save endpoint, and at the read site. - src/agent_loop.py: track `_exhausted_rounds` (set only when a full tool-executing round completes on the last allowed round — i.e. the agent wanted to keep going); emit `{"type":"rounds_exhausted","rounds":N}` (logged). - routes/chat_routes.py: read `agent_max_rounds` (clamped 1..200), pass as `max_rounds`; forward the new event through the SSE relay. - routes/auth_routes.py: validate numeric settings on save (int + clamp; agent_max_rounds 1..200, agent_max_tool_calls 0..1000; 400 on non-int). - src/settings.py: default `agent_max_rounds = 20`. - static/: Settings input + client-side clamp; the Continue pill (reuses the existing .stopped-indicator / .continue-btn classes and theme vars --border/--fg/--bg/--accent); appended to the chat container so it survives the message re-render at stream finalize. chat.js cache version bumped. * test: cover rounds_exhausted emission (cap-hit vs normal finish) Drives the real stream_agent_loop with mocked LLM stream / tool exec / settings: a tool block every round exhausts the cap and must emit rounds_exhausted; a plain answer hits the done-break and must not. Guards the for/else logic.
2026-06-04 22:36:05 +02:00
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish Surface a lot of accumulated cookbook + UI work as a single non-agent commit so the agent rework lands cleanly. Highlights: - Ollama as a first-class backend in the Cookbook: * Download input accepts ollama-style names (name:tag) → backend=ollama * /api/cookbook/ollama/library (cached scrape of ollama.com + curated fallback so classic models like qwen2.5 stay reachable) * "Browse Ollama library" toggle below Download with size chips * Engine=Ollama in hwfit toolbar merges the Ollama library into the main scan list as per-tag rows with the same Fit/Param/Quant/VRAM columns; click → fills Download input - API Tokens form added to Integrations panel (matching wired loadTokens()/initTokenForm() that had no HTML) - Serve panel polish: Advanced fold tightening (-8px nudges on vLLM checks, Extra args, Spec row), n_cpu_moe + Split Mode controls pulled up 8px to align with the row's checkboxes, GGUF File dropdown exposed for Ollama backend, GPU re-render on Edit serve restore, _forceBackend flag so saved serveState wins over backend detection, cookbook:servers-changed CustomEvent so panels don't need refresh - Models page redesign: Add Models row (URL + hidden API key reveal + Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All + Clear-offline buttons in Added Models toolbar, offline-pill removed (opacity already conveys state), Engine dropdown gains Ollama option - _ping_endpoint probes /v1/models then base, accepts 4xx as reachable (vLLM returns 404 on bare /v1, fully working endpoints were showing offline) - Diagnosis card: × dismiss + Copy bundle buttons restored on the serve error feedback card - Orphan tmux sweep re-enabled behind a 60s rate-limit + background Thread (off the main event loop) so dead serves get discovered - cookbook_routes auto-register watchdog: drops the endpoint if the serve session exits non-zero within the first ~3min - ollama-rocm sidecar awareness in download wrapper (`docker exec ollama-rocm ollama pull` when host ollama isn't installed) - Skill extractor sets initial_status="published" when auto_approve_skills pref is on (audit demotes later) - Skill list / model list / cookbook scan misc polish
2026-06-08 22:38:49 +09:00
(curR != null ? ' · ' + curR + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
2026-05-31 23:58:26 +09:00
}
/* ═══════════════════════════════════════════
APPEARANCE TAB
═══════════════════════════════════════════ */
function initAppearance() {
syncAppearanceCheckboxes();
syncPrivacyCheckboxes();
modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) {
chk.addEventListener('change', async function() {
var key = chk.dataset.uiKey;
if (window.UI_VIS_ADMIN_ONLY && window.UI_VIS_ADMIN_ONLY.has(key) && !chk.checked && !window._isAdmin) {
chk.checked = true;
if (uiModule && uiModule.showToast) {
uiModule.showToast('Only admins can hide Settings.');
}
return;
}
// Hiding the Settings cog removes the only visible way to re-open this
// panel. Warn the user and remind them about the `/settings` slash
// command so they don't lock themselves out.
if (key === 'sidebar-settings-btn' && !chk.checked) {
var ok = true;
try {
ok = await (uiModule && uiModule.styledConfirm
? uiModule.styledConfirm(
'Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.',
{ confirmText: 'Hide', cancelText: 'Cancel' }
)
: Promise.resolve(window.confirm('Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.')));
} catch (_) { ok = false; }
if (!ok) {
chk.checked = true;
return;
}
if (uiModule && uiModule.showToast) {
uiModule.showToast('Settings cog hidden — type /settings to bring it back.', 5000);
}
}
var s = window.loadUIVis();
s[key] = chk.checked;
window.saveUIVis(s);
window.applyUIVis(s);
});
});
modalEl.querySelectorAll('[data-privacy-key]').forEach(function(chk) {
chk.addEventListener('change', function() {
if (chk.dataset.privacyKey !== 'sensitive-blur') return;
localStorage.setItem('odysseus-sensitive-blur', chk.checked ? 'on' : 'off');
window.dispatchEvent(new CustomEvent('odysseus-sensitive-blur-change', {
detail: { enabled: chk.checked }
}));
});
});
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Per-section reset buttons (arrow-circle-back icon in each card's h2).
// Removes only the keys belonging to this section from the persisted
// visibility map so other sections keep their user settings.
modalEl.querySelectorAll('[data-vis-reset]').forEach(function(btn) {
btn.addEventListener('click', function() {
var card = btn.closest('.admin-card');
if (!card) return;
var keys = Array.from(card.querySelectorAll('[data-ui-key]'))
.map(function(c) { return c.dataset.uiKey; })
.filter(Boolean);
if (!keys.length) return;
var s = window.loadUIVis ? window.loadUIVis() : {};
keys.forEach(function(k) { delete s[k]; });
if (window.saveUIVis) window.saveUIVis(s);
2026-05-31 23:58:26 +09:00
syncAppearanceCheckboxes();
syncPrivacyCheckboxes();
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (window.applyUIVis) window.applyUIVis(s);
2026-05-31 23:58:26 +09:00
});
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
});
2026-05-31 23:58:26 +09:00
}
function syncAppearanceCheckboxes() {
var s = window.loadUIVis ? window.loadUIVis() : {};
var defaultOff = window.UI_VIS_DEFAULT_OFF || new Set();
modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) {
var key = chk.dataset.uiKey;
chk.checked = key in s ? s[key] !== false : !defaultOff.has(key);
});
}
function syncPrivacyCheckboxes() {
modalEl.querySelectorAll('[data-privacy-key="sensitive-blur"]').forEach(function(chk) {
chk.checked = localStorage.getItem('odysseus-sensitive-blur') === 'on';
});
}
/* ═══════════════════════════════════════════
SHORTCUTS TAB
═══════════════════════════════════════════ */
const SHORTCUT_DEFAULTS = {
search: 'ctrl+k',
toggle_sidebar: 'ctrl+b',
new_session: 'ctrl+alt+n',
fav_session: 'ctrl+alt+f',
delete_session: 'ctrl+alt+d',
cancel: 'escape',
tts: 'alt+shift+t',
incognito: 'ctrl+alt+i',
settings: 'ctrl+,',
focus_input: 'ctrl+/',
// Open-tool shortcuts. Calendar is bound by default; the rest are
// unbound (empty) so the user can assign their own in the panel.
open_calendar: 'ctrl+alt+c',
open_compare: '',
open_cookbook: '',
open_research: '',
open_gallery: '',
open_library: '',
open_memory: '',
open_notes: '',
open_tasks: '',
open_theme: '',
};
const SHORTCUT_ICONS = {
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>',
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>',
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>',
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>',
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>',
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>',
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>',
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"/><line x1="8" y1="8" x2="16" y2="16"/></svg>',
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>',
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>',
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>',
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>',
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>',
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>',
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>',
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>',
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>',
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>',
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>',
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>',
};
const SHORTCUT_LABELS = {
search: 'Search conversations',
toggle_sidebar: 'Toggle sidebar',
new_session: 'New session',
fav_session: 'Favorite session',
delete_session: 'Delete session',
cancel: 'Cancel / close',
tts: 'Play/stop TTS',
incognito: 'Toggle incognito',
settings: 'Toggle Window',
focus_input: 'Focus chat input',
open_calendar: 'Open Calendar',
open_compare: 'Open Compare',
open_cookbook: 'Open Cookbook',
open_research: 'Open Deep Research',
open_gallery: 'Open Gallery',
open_library: 'Open Library',
open_memory: 'Open Memory',
open_notes: 'Open Notes',
open_tasks: 'Open Tasks',
open_theme: 'Open Theme',
};
const SHORTCUT_CATEGORIES = [
{ name: 'Navigation', keys: ['search', 'toggle_sidebar', 'focus_input', 'settings'] },
{ name: 'Sessions', keys: ['new_session', 'fav_session', 'delete_session'] },
{ name: 'Tools', keys: ['incognito', 'tts', 'cancel'] },
{ name: 'Open Tools', keys: ['open_calendar', 'open_compare', 'open_cookbook', 'open_research', 'open_gallery', 'open_library', 'open_memory', 'open_notes', 'open_tasks', 'open_theme'] },
];
function _formatKeyCaps(combo) {
return combo.split('+').map(p => {
let label;
if (p === 'ctrl') label = 'Ctrl';
else if (p === 'alt') label = 'Alt';
else if (p === 'shift') label = 'Shift';
else if (p === 'meta') label = 'Cmd';
else if (p === 'escape') label = 'Esc';
else if (p === ',') label = ',';
else if (p === '/') label = '/';
else if (p === 'space') label = 'Space';
else label = p.charAt(0).toUpperCase() + p.slice(1);
return `<kbd>${label}</kbd>`;
}).join('');
}
function _comboFromEvent(e) {
Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts (#825) * Ignore AltGr keystrokes in Ctrl+Alt keyboard shortcuts Browsers report AltGr (right Alt on AZERTY/QWERTZ and most non-US layouts, used to type @ # { } [ ] | \ and the euro sign) as ctrlKey+altKey. The default keybinds map destructive actions to Ctrl+Alt+<letter> (delete_session, new_session, incognito, open_calendar), so a non-US user typing a special character could silently fire them. Guard the shortcut matcher, the editor keydown handler, and the rebind capture with getModifierState('AltGraph'), which is true for AltGr but false for a genuine left Ctrl+Alt. macOS is excluded: there the Option key legitimately sets AltGraph and there is no AltGr/Ctrl+Alt collision to guard against, so the guard would otherwise break Ctrl+Option / Cmd+Option shortcuts (notably in Firefox). The detection lives in one place — isAltGrEvent / IS_MAC in static/js/platform.js — and all three call sites route through it, so the guards can't drift apart. The editor handler only skips the Ctrl+Alt chord block, so layout shortcuts reachable via AltGr (e.g. [ ] brush size = AltGr+5/+8 on AZERTY) keep working. * Require Ctrl+Alt for the AltGr guard and consolidate keybind test marks isAltGrEvent now also checks ctrlKey+altKey so it only suppresses the "AltGr reported as Ctrl+Alt" collision; an event asserting AltGraph on its own (a Linux ISO_Level3_Shift layout, a stray modifier) is left alone. Pin it with test_isaltgr_false_when_altgraph_set_but_not_ctrl_alt. Collapse the 12 per-test node skipif marks into one module-level pytestmark, and note in platform.js why IS_MAC intentionally covers iPad/iPhone and mirrors the isMac checks in calendar.js / sessions.js.
2026-06-02 04:12:54 +02:00
// Drop a stray AltGr keystroke (e.g. AltGr+E to type €) so it isn't recorded
// as a bogus ctrl+alt+<char> binding — onKey ignores empty combos. See
// platform.js for the macOS carve-out and Windows trade-off.
if (isAltGrEvent(e)) return '';
2026-05-31 23:58:26 +09:00
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
const key = e.key.toLowerCase();
if (!['control', 'alt', 'shift', 'meta'].includes(key)) {
parts.push(key === ' ' ? 'space' : key);
}
return parts.join('+');
}
async function initShortcuts() {
const listEl = el('shortcuts-list');
const resetBtn = el('shortcuts-reset-btn');
if (!listEl) return;
// Load saved keybinds
let keybinds = { ...SHORTCUT_DEFAULTS };
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await res.json();
if (settings.keybinds) keybinds = { ...keybinds, ...settings.keybinds };
} catch (e) {}
function _findConflicts() {
const comboMap = {};
for (const [action, combo] of Object.entries(keybinds)) {
if (!comboMap[combo]) comboMap[combo] = [];
comboMap[combo].push(action);
}
const conflicts = new Set();
for (const actions of Object.values(comboMap)) {
if (actions.length > 1) actions.forEach(a => conflicts.add(a));
}
return conflicts;
}
function render() {
listEl.innerHTML = '';
const conflicts = _findConflicts();
for (const cat of SHORTCUT_CATEGORIES) {
const catHeader = document.createElement('div');
catHeader.className = 'shortcut-category';
catHeader.textContent = cat.name;
listEl.appendChild(catHeader);
for (const action of cat.keys) {
if (!(action in keybinds)) continue;
const combo = keybinds[action];
// Unbound shortcuts (empty combo) still render so the user can
// assign one \u2014 they show a "Set" affordance instead of keycaps.
const label = SHORTCUT_LABELS[action] || action;
const icon = SHORTCUT_ICONS[action] || '';
const isCustom = combo !== (SHORTCUT_DEFAULTS[action] || '');
const hasConflict = combo && conflicts.has(action);
const row = document.createElement('div');
row.className = 'shortcut-row' + (hasConflict ? ' shortcut-conflict' : '');
row.dataset.action = action;
const keyContent = combo ? _formatKeyCaps(combo) : '<span class="shortcut-unset">Set</span>';
row.innerHTML = `
<span class="shortcut-label"><span class="shortcut-icon">${icon}</span>${esc(label)}${hasConflict ? '<span class="shortcut-warn" title="Duplicate shortcut">!</span>' : ''}</span>
<div class="shortcut-controls">
<span class="shortcut-hint" hidden></span>
<button class="shortcut-key${combo ? '' : ' shortcut-key-unset'}" data-action="${action}" title="Click to rebind">${keyContent}</button>
<button class="shortcut-action-btn ${isCustom ? 'is-reset' : ''}" data-action="${action}" title="${isCustom ? 'Reset to default' : 'Confirm'}" style="${isCustom ? '' : 'visibility:hidden'}">
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
${isCustom
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
: '\u2713'}
2026-05-31 23:58:26 +09:00
</button>
</div>
`;
listEl.appendChild(row);
}
}
listEl.querySelectorAll('.shortcut-key').forEach(btn => {
btn.addEventListener('click', () => startRebind(btn));
});
listEl.querySelectorAll('.shortcut-action-btn.is-reset').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
keybinds[action] = SHORTCUT_DEFAULTS[action];
saveKeybinds();
render();
});
});
}
function startRebind(btn) {
const action = btn.dataset.action;
const row = btn.closest('.shortcut-row');
const actionBtn = row.querySelector('.shortcut-action-btn');
const hintEl = row.querySelector('.shortcut-hint');
// Remove any other active rebind
listEl.querySelectorAll('.shortcut-key.listening').forEach(b => {
b.classList.remove('listening');
b.innerHTML = _formatKeyCaps(keybinds[b.dataset.action]);
const otherRow = b.closest('.shortcut-row');
const otherAction = otherRow.querySelector('.shortcut-action-btn');
if (otherAction && !otherAction.classList.contains('is-reset')) otherAction.style.visibility = 'hidden';
});
btn.classList.add('listening');
btn.textContent = 'Press keys...';
// Show confirm button
actionBtn.textContent = '\u2713';
actionBtn.classList.remove('is-reset');
actionBtn.style.visibility = 'visible';
actionBtn.title = 'Confirm';
// Hint: tell the user how to commit / cancel the rebind.
if (hintEl) {
hintEl.hidden = false;
hintEl.textContent = 'press a key';
}
let pendingCombo = null;
// Wire confirm button
const confirmHandler = () => {
if (pendingCombo) {
keybinds[action] = pendingCombo;
saveKeybinds();
}
cleanup();
render();
};
actionBtn.addEventListener('click', confirmHandler, { once: true });
function onKey(e) {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
cleanup();
btn.innerHTML = _formatKeyCaps(keybinds[action]);
const isCustom = keybinds[action] !== SHORTCUT_DEFAULTS[action];
if (isCustom) {
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
actionBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
2026-05-31 23:58:26 +09:00
actionBtn.classList.add('is-reset');
actionBtn.title = 'Reset to default';
} else {
actionBtn.style.visibility = 'hidden';
}
return;
}
// Enter commits the previewed combo (same as clicking \u2713). Only acts
// as commit once a combo has been captured \u2014 otherwise it would just
// try to bind Enter itself.
if (e.key === 'Enter' && pendingCombo) {
confirmHandler();
return;
}
const combo = _comboFromEvent(e);
if (!combo || combo === 'ctrl' || combo === 'alt' || combo === 'shift' || combo === 'ctrl+alt' || combo === 'ctrl+shift' || combo === 'alt+shift' || combo === 'ctrl+alt+shift') return;
// Preview the combo, wait for confirm
pendingCombo = combo;
btn.innerHTML = _formatKeyCaps(combo);
// Now that a combo is captured, prompt to commit with Enter.
if (hintEl) hintEl.textContent = '\u21B5 Enter to save';
}
function cleanup() {
btn.classList.remove('listening');
if (hintEl) { hintEl.hidden = true; hintEl.textContent = ''; }
document.removeEventListener('keydown', onKey, true);
actionBtn.removeEventListener('click', confirmHandler);
}
document.addEventListener('keydown', onKey, true);
}
async function saveKeybinds() {
try {
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keybinds }),
});
// Update global keybinds so they take effect immediately
window._odysseusKeybinds = keybinds;
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
} catch (e) {
console.error('Failed to save keybinds:', e);
}
}
if (resetBtn) {
resetBtn.addEventListener('click', async () => {
keybinds = { ...SHORTCUT_DEFAULTS };
render();
await saveKeybinds();
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcuts reset to defaults');
});
}
render();
}
/* ═══════════════════════════════════════════
INIT & REFRESH
═══════════════════════════════════════════ */
function initAccount() {
// Populate user info
fetch('/api/auth/status', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => {
const nameEl = el('settings-account-username');
const roleEl = el('settings-account-role');
const avatarEl = el('settings-account-avatar');
if (nameEl) nameEl.textContent = d.username || 'Unknown';
if (roleEl) roleEl.textContent = d.is_admin ? 'Admin' : 'User';
if (avatarEl) {
const initial = (d.username || '?')[0].toUpperCase();
avatarEl.textContent = initial;
}
}).catch(() => {});
// Update password placeholder and policy from server
fetch('/api/auth/policy', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(policy => {
if (!policy) return;
_authPolicy = policy;
const pwNew = el('settings-pw-new');
if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`;
}).catch(() => {});
2026-05-31 23:58:26 +09:00
// Change password
const saveBtn = el('settings-pw-save');
const msgEl = el('settings-pw-msg');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const cur = el('settings-pw-current').value;
const nw = el('settings-pw-new').value;
const conf = el('settings-pw-confirm').value;
msgEl.style.color = '';
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; }
2026-05-31 23:58:26 +09:00
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
saveBtn.disabled = true;
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: cur, new_password: nw })
});
if (!res.ok) { const d = await res.json(); throw new Error(d.detail || 'Failed'); }
msgEl.style.color = 'var(--green)';
msgEl.textContent = 'Password updated';
el('settings-pw-current').value = '';
el('settings-pw-new').value = '';
el('settings-pw-confirm').value = '';
} catch (e) {
msgEl.style.color = 'var(--red)';
msgEl.textContent = e.message;
} finally {
saveBtn.disabled = false;
}
});
}
// ── Two-Factor Authentication ──
const tfaContent = el('settings-2fa-content');
if (tfaContent) {
async function render2FA() {
try {
const res = await fetch('/api/auth/2fa/status', { credentials: 'same-origin' });
const data = await res.json();
if (data.enabled) {
// 2FA is ON — show disable option
tfaContent.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="color:var(--color-save-green, #4caf50);font-size:12px;font-weight:600;">&#x2713; Enabled</span>
<span style="font-size:11px;opacity:0.5;">Authenticator app required on login</span>
</div>
<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;">
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
<button class="admin-btn-add" id="tfa-disable-btn" style="opacity:0.7;">Disable 2FA</button>
</div>`;
el('tfa-disable-btn').addEventListener('click', async () => {
const pw = el('tfa-disable-pw').value;
const msg = el('tfa-msg');
if (!pw) { msg.textContent = 'Enter your password'; msg.style.color = 'var(--red)'; return; }
try {
const r = await fetch('/api/auth/2fa/disable', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
render2FA();
} catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
});
} else {
// 2FA is OFF — show setup button
tfaContent.innerHTML = `
<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>
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
<button class="admin-btn-add" id="tfa-setup-btn">Set Up 2FA</button>
</div>`;
el('tfa-setup-btn').addEventListener('click', async () => {
const msg = el('tfa-msg');
try {
const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' });
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
const setup = await r.json();
const qrCode = safeRasterDataUrl(setup.qr_code);
2026-05-31 23:58:26 +09:00
// Show QR code + manual secret + verify input
tfaContent.innerHTML = `
<div style="text-align:center;margin-bottom:12px;">
${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''}
2026-05-31 23:58:26 +09:00
</div>
<div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
Scan with your authenticator app, or enter manually:
</div>
<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>
2026-05-31 23:58:26 +09:00
<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;">
<div class="settings-row" style="justify-content:flex-end;">
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
<button class="admin-btn-add" id="tfa-cancel-btn" style="opacity:0.5;">Cancel</button>
<button class="admin-btn-add" id="tfa-verify-btn">Verify & Enable</button>
</div>`;
el('tfa-verify-code').focus();
el('tfa-cancel-btn').addEventListener('click', () => render2FA());
el('tfa-verify-btn').addEventListener('click', async () => {
const code = el('tfa-verify-code').value.trim();
const vmsg = el('tfa-msg');
if (!code) { vmsg.textContent = 'Enter the code'; vmsg.style.color = 'var(--red)'; return; }
try {
const vr = await fetch('/api/auth/2fa/confirm', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code })
});
if (!vr.ok) { const d = await vr.json(); throw new Error(d.detail || 'Invalid code'); }
const result = await vr.json();
// Show backup codes
const codes = result.backup_codes || [];
tfaContent.innerHTML = `
<div style="color:var(--color-save-green, #4caf50);font-size:13px;font-weight:600;margin-bottom:8px;">&#x2713; 2FA Enabled!</div>
<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>
<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>
<button class="admin-btn-add" id="tfa-done-btn">Done</button>`;
el('tfa-done-btn').addEventListener('click', () => render2FA());
} catch (e) { vmsg.textContent = e.message; vmsg.style.color = 'var(--red)'; }
});
} catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; }
});
}
} catch (_) {
tfaContent.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load 2FA status</div>';
}
}
render2FA();
}
// Logout
const logoutBtn = el('settings-logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('mouseenter', () => { logoutBtn.style.opacity = '1'; logoutBtn.style.borderColor = 'var(--red)'; logoutBtn.style.color = 'var(--red)'; });
logoutBtn.addEventListener('mouseleave', () => { logoutBtn.style.opacity = ''; logoutBtn.style.borderColor = ''; logoutBtn.style.color = ''; });
logoutBtn.addEventListener('click', async () => {
try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (_) {}
// SECURITY: wipe all client-side state on logout so the next user that
// signs in on this browser doesn't inherit the previous account's
// session id, last-used model, draft chat input, or any cached lists.
// Keep "odysseus-last-user" so the login form remembers the username
// (if "Remember me" was on). Without this the chat composer pre-loaded
// the previous user's last model into a fresh session, which read as
// cross-account leakage.
try {
const _keepKeys = new Set(['odysseus-last-user']);
const _toRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && !_keepKeys.has(k)) _toRemove.push(k);
}
_toRemove.forEach(k => localStorage.removeItem(k));
sessionStorage.clear();
} catch (_) {}
window.location.href = '/login';
});
}
}
function initAll() {
modalEl = el('settings-modal');
initTabs();
initDrag();
initClose();
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
initOpenPromptModalLink();
2026-05-31 23:58:26 +09:00
initOpacityToggle();
2026-07-08 14:36:41 +02:00
addonsModule.init();
2026-05-31 23:58:26 +09:00
initialized = true;
initDefaultChat();
initTeacherModel();
initUtilityModel();
initImageSettings();
initVisionSettings();
initTtsSettings();
initSttSettings();
initSearchSettings();
initResearchSettings();
initResearchSearchSettings();
initAgentSettings();
initAppearance();
initShortcuts();
initAccount();
initIntegrations();
initEmailSettings();
initEmailAccountsSettings();
initReminderSettings();
initUnifiedIntegrations();
}
function notifyIntegrationsChanged() {
try {
window.dispatchEvent(new CustomEvent('odysseus-integrations-changed'));
} catch (_) {}
}
async function initReminderSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="reminders"]')) return;
// Public URL field (used for deep-links in outgoing alert emails)
const pubUrlIn = el('set-app-public-url');
const pubUrlMsg = el('set-app-public-url-msg');
if (pubUrlIn) {
try {
const r = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const s = await r.json();
pubUrlIn.value = s.app_public_url || '';
} catch (_) {}
let pubDebounce;
pubUrlIn.addEventListener('input', () => {
clearTimeout(pubDebounce);
pubDebounce = setTimeout(async () => {
try {
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
await fetch('/api/auth/settings', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_public_url: val }),
});
if (pubUrlMsg) {
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
setTimeout(() => { pubUrlMsg.textContent = ''; }, 2000);
}
} catch (_) {
if (pubUrlMsg) { pubUrlMsg.textContent = 'Save failed'; pubUrlMsg.style.color = 'var(--red)'; }
}
}, 600);
});
}
const channelSel = el('set-reminder-channel');
const emailOpt = el('set-reminder-channel-email-opt');
const ntfyOpt = el('set-reminder-channel-ntfy-opt');
2026-06-05 16:47:57 -04:00
const webhookOpt = el('set-reminder-channel-webhook-opt');
2026-05-31 23:58:26 +09:00
const hint = el('set-reminder-channel-hint');
const llmToggle = el('set-reminder-llm-toggle');
// "Integrations" link in the channel-hint copy. Jumps to the
// Integrations tab so the user can configure the underlying accounts
// (email, ntfy server) the channel dropdown depends on. Idempotent.
const openIntgBtn = el('set-reminders-open-integrations');
if (openIntgBtn && !openIntgBtn.dataset.wired) {
openIntgBtn.dataset.wired = '1';
openIntgBtn.addEventListener('click', (e) => {
e.preventDefault();
const target = modalEl?.querySelector('[data-settings-tab="integrations"]');
if (target) target.click();
});
}
if (!channelSel || !llmToggle) return;
// Detect configured email accounts. The legacy single-account
// `/api/email/config` endpoint was a no-op stub for most installs;
// the real per-account list lives at `/api/email/accounts` and is
// what the Integrations panel manages. Treat the email channel as
// configured if there's at least one account with SMTP set.
let emailAccounts = [];
try {
const res = await fetch('/api/email/accounts', { credentials: 'same-origin' });
if (res.ok) {
const d = await res.json();
emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password);
}
} catch (_) {}
let smtpConfigured = emailAccounts.length > 0;
if (!smtpConfigured && emailOpt) {
emailOpt.disabled = true;
emailOpt.textContent = 'Email (add an account in Integrations)';
}
// Detect whether ntfy integration exists — try admin endpoint, fall back to
// checking if an ntfy integration was saved in settings (non-admin users).
let ntfyConfigured = false;
try {
const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
ntfyConfigured = (data.integrations || []).some(
i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url
);
}
} catch (_) {}
// If admin check failed, check if ntfy was previously selected (trust the saved setting)
if (!ntfyConfigured) {
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const s = await res.json();
if (s.reminder_channel === 'ntfy') ntfyConfigured = true;
} catch (_) {}
}
if (!ntfyConfigured && ntfyOpt) {
ntfyOpt.disabled = true;
ntfyOpt.textContent = 'ntfy (add in Integrations first)';
}
2026-06-05 16:47:57 -04:00
// Webhook: available whenever at least one integration with a base_url exists.
// The user picks which integration to target and supplies a payload template.
let allIntegrations = [];
let webhookConfigured = false;
try {
const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false);
webhookConfigured = allIntegrations.length > 0;
}
} catch (_) {}
if (!webhookConfigured && webhookOpt) {
webhookOpt.disabled = true;
webhookOpt.textContent = 'Webhook (add an Integration first)';
}
2026-05-31 23:58:26 +09:00
const emailFromRow = el('set-reminder-email-from-row');
const emailAcctSel = el('set-reminder-email-account');
const emailToRow = el('set-reminder-email-to-row');
const emailToIn = el('set-reminder-email-to');
const ntfyTopicRow = el('set-reminder-ntfy-topic-row');
const ntfyTopicIn = el('set-reminder-ntfy-topic');
2026-06-05 16:47:57 -04:00
const webhookIntgRow = el('set-reminder-webhook-intg-row');
const webhookIntgSel = el('set-reminder-webhook-intg');
const webhookTemplateRow = el('set-reminder-webhook-template-row');
const webhookTemplateIn = el('set-reminder-webhook-template');
2026-05-31 23:58:26 +09:00
function populateReminderEmailAccounts(selectedId = '') {
if (!emailAcctSel) return;
emailAcctSel.innerHTML = emailAccounts.map(a =>
`<option value="${a.id}">${esc(a.name || a.from_address || a.imap_user || 'Unnamed')}${a.is_default ? ' (default)' : ''}</option>`
).join('');
const fallback = (emailAccounts.find(a => a.is_default) || emailAccounts[0] || {}).id || '';
emailAcctSel.value = (selectedId && emailAccounts.some(a => a.id === selectedId)) ? selectedId : fallback;
}
2026-06-05 16:47:57 -04:00
function populateWebhookIntegrations(selectedId = '') {
if (!webhookIntgSel) return;
webhookIntgSel.innerHTML = allIntegrations.length
? allIntegrations.map(i => `<option value="${esc(i.id)}">${esc(i.name || i.id)}</option>`).join('')
: '<option value="">No integrations configured</option>';
if (selectedId && allIntegrations.some(i => i.id === selectedId)) webhookIntgSel.value = selectedId;
}
2026-05-31 23:58:26 +09:00
function applyReminderChannelAvailability() {
if (emailOpt) {
emailOpt.disabled = !smtpConfigured;
emailOpt.textContent = smtpConfigured ? 'Email' : 'Email (add an account in Integrations)';
}
if (ntfyOpt) {
ntfyOpt.disabled = !ntfyConfigured;
ntfyOpt.textContent = ntfyConfigured ? 'ntfy' : 'ntfy (add in Integrations first)';
}
2026-06-05 16:47:57 -04:00
if (webhookOpt) {
webhookOpt.disabled = !webhookConfigured;
webhookOpt.textContent = webhookConfigured ? 'Webhook' : 'Webhook (add an Integration first)';
}
2026-05-31 23:58:26 +09:00
}
async function refreshReminderChannelAvailability() {
const currentChannel = channelSel.value || 'browser';
const currentEmailAccount = emailAcctSel?.value || '';
2026-06-05 16:47:57 -04:00
const currentWebhookIntg = webhookIntgSel?.value || '';
2026-05-31 23:58:26 +09:00
try {
const res = await fetch('/api/email/accounts', { credentials: 'same-origin' });
if (res.ok) {
const d = await res.json();
emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password);
}
} catch (_) {}
smtpConfigured = emailAccounts.length > 0;
ntfyConfigured = false;
try {
const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
ntfyConfigured = (data.integrations || []).some(
i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url
);
2026-06-05 16:47:57 -04:00
allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false);
webhookConfigured = allIntegrations.length > 0;
2026-05-31 23:58:26 +09:00
}
} catch (_) {}
if (!ntfyConfigured) {
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const s = await res.json();
if (s.reminder_channel === 'ntfy') ntfyConfigured = true;
} catch (_) {}
}
applyReminderChannelAvailability();
populateReminderEmailAccounts(currentEmailAccount);
2026-06-05 16:47:57 -04:00
populateWebhookIntegrations(currentWebhookIntg);
2026-05-31 23:58:26 +09:00
if (currentChannel === 'email' && !smtpConfigured) channelSel.value = 'browser';
else if (currentChannel === 'ntfy' && !ntfyConfigured) channelSel.value = 'browser';
2026-06-05 16:47:57 -04:00
else if (currentChannel === 'webhook' && !webhookConfigured) channelSel.value = 'browser';
2026-05-31 23:58:26 +09:00
else channelSel.value = currentChannel;
if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || '';
syncChannelRows();
}
// Populate the "Send from" picker with all configured email accounts.
populateReminderEmailAccounts();
function syncChannelRows() {
const isEmail = channelSel.value === 'email';
2026-06-05 16:47:57 -04:00
const isWebhook = channelSel.value === 'webhook';
2026-05-31 23:58:26 +09:00
if (emailFromRow) emailFromRow.style.display = (isEmail && emailAccounts.length > 1) ? 'flex' : 'none';
if (emailToRow) emailToRow.style.display = isEmail ? 'flex' : 'none';
if (ntfyTopicRow) ntfyTopicRow.style.display = channelSel.value === 'ntfy' ? 'flex' : 'none';
2026-06-05 16:47:57 -04:00
if (webhookIntgRow) webhookIntgRow.style.display = isWebhook ? 'flex' : 'none';
if (webhookTemplateRow) webhookTemplateRow.style.display = isWebhook ? 'flex' : 'none';
2026-05-31 23:58:26 +09:00
}
// Browser notifications fire on EVERY reminder (see
// routes/note_routes.py — the in-app notif is always queued
// regardless of channel). The hint should make that clear so
// users don't think they have to choose between channels.
const CHANNEL_HINTS = {
browser: 'Reminders appear as browser notifications inside Odysseus.',
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
email: 'Reminders are emailed and shown as a browser notification.',
2026-05-31 23:58:26 +09:00
ntfy: 'Reminders are pushed via ntfy AND shown as a browser notification.',
2026-06-05 16:47:57 -04:00
webhook: 'Reminders are POSTed to the selected integration AND shown as a browser notification. Use {{title}} and {{message}} in the payload template.',
2026-05-31 23:58:26 +09:00
};
applyReminderChannelAvailability();
if (!channelSel.dataset.integrationRefreshWired) {
channelSel.dataset.integrationRefreshWired = '1';
window.addEventListener('odysseus-integrations-changed', () => {
refreshReminderChannelAvailability().catch(e => console.warn('Failed to refresh reminder channels', e));
});
}
2026-06-05 16:47:57 -04:00
// Default payload templates for known presets — auto-filled when the user
// picks a matching integration so they don't have to write JSON from scratch.
// Defined here (before the load block) so both the load path and the change
// handler can reference it.
const WEBHOOK_PRESET_TEMPLATES = {
discord_webhook: '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}',
};
2026-05-31 23:58:26 +09:00
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const s = await res.json();
let savedChannel = s.reminder_channel || 'browser';
if (savedChannel === 'email' && !smtpConfigured) savedChannel = 'browser';
if (savedChannel === 'ntfy' && !ntfyConfigured) savedChannel = 'browser';
2026-06-05 16:47:57 -04:00
if (savedChannel === 'webhook' && !webhookConfigured) savedChannel = 'browser';
2026-05-31 23:58:26 +09:00
channelSel.value = savedChannel;
llmToggle.checked = !!s.reminder_llm_synthesis;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Persona dropdown — populate from built-in PROMPT_TEMPLATES (characters)
// plus any custom character preset. Selected value persists to
// reminder_llm_persona (backend hook lives in src/notes.py once
// /api/notes/fire-reminder lands).
const personaSel = el('set-reminder-llm-persona');
if (personaSel) {
try {
const presetsMod = await import('./presets.js');
const tpl = presetsMod.PROMPT_TEMPLATES || [];
const chars = tpl.filter(t => t.isCharacter);
for (const c of chars) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
personaSel.appendChild(opt);
}
// Custom character (single-slot preset)
try {
const all = (presetsMod.getAllPresets && presetsMod.getAllPresets()) || {};
if (all.custom && all.custom.character_name) {
const opt = document.createElement('option');
opt.value = 'custom';
opt.textContent = all.custom.character_name + ' (custom)';
personaSel.appendChild(opt);
}
} catch (_) {}
} catch (_) {}
personaSel.value = s.reminder_llm_persona || '';
personaSel.addEventListener('change', () => {
save({ reminder_llm_persona: personaSel.value });
});
}
2026-05-31 23:58:26 +09:00
if (emailToIn) emailToIn.value = s.reminder_email_to || '';
if (ntfyTopicIn) ntfyTopicIn.value = s.reminder_ntfy_topic || 'Reminders';
2026-06-05 16:47:57 -04:00
populateWebhookIntegrations(s.reminder_webhook_integration_id || '');
if (webhookTemplateIn) {
webhookTemplateIn.value = s.reminder_webhook_payload_template || '';
// If an integration is already selected but no template was ever saved,
// auto-fill with the preset default so the first test works out of the box.
if (!webhookTemplateIn.value && webhookIntgSel?.value) {
const intg = allIntegrations.find(i => i.id === webhookIntgSel.value);
const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || '';
if (tpl) { webhookTemplateIn.value = tpl; save({ reminder_webhook_payload_template: tpl }); }
}
}
2026-05-31 23:58:26 +09:00
// Restore the previously-picked email account (if any), otherwise
// default to the account flagged is_default in the integrations
// list. Falls through to the first option if neither exists.
if (emailAcctSel) {
const savedId = s.reminder_email_account_id;
populateReminderEmailAccounts(savedId || '');
if (emailAcctSel.value && emailAcctSel.value !== (savedId || '')) {
save({ reminder_email_account_id: emailAcctSel.value || null });
}
}
if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || '';
syncChannelRows();
} catch (e) { console.warn('Failed to load reminder settings', e); }
async function save(patch) {
try {
await fetch('/api/auth/settings', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
} catch (e) { console.warn('Failed to save reminder settings', e); }
}
channelSel.addEventListener('change', () => {
if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || '';
syncChannelRows();
save({ reminder_channel: channelSel.value });
// Email reminder bell visibility tracks this — broadcast so the
// email library can re-evaluate without waiting for a re-open.
try { window.dispatchEvent(new CustomEvent('odysseus-reminder-channel-changed', { detail: { channel: channelSel.value } })); } catch (_) {}
2026-05-31 23:58:26 +09:00
});
if (emailToIn) {
let emailDebounce;
emailToIn.addEventListener('input', () => {
clearTimeout(emailDebounce);
emailDebounce = setTimeout(() => save({ reminder_email_to: emailToIn.value.trim() }), 600);
});
}
if (emailAcctSel) {
emailAcctSel.addEventListener('change', () => {
save({ reminder_email_account_id: emailAcctSel.value || null });
});
}
if (ntfyTopicIn) {
let topicDebounce;
ntfyTopicIn.addEventListener('input', () => {
clearTimeout(topicDebounce);
topicDebounce = setTimeout(() => save({ reminder_ntfy_topic: ntfyTopicIn.value.trim() || 'reminders' }), 600);
});
}
2026-06-05 16:47:57 -04:00
if (webhookIntgSel) {
webhookIntgSel.addEventListener('change', () => {
save({ reminder_webhook_integration_id: webhookIntgSel.value || '' });
// If the template is empty and we recognise the integration's preset,
// pre-fill with a sensible default so users can test immediately.
if (webhookTemplateIn && !webhookTemplateIn.value.trim()) {
const intg = allIntegrations.find(i => i.id === webhookIntgSel.value);
const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || '';
if (tpl) {
webhookTemplateIn.value = tpl;
save({ reminder_webhook_payload_template: tpl });
}
}
});
}
if (webhookTemplateIn) {
let templateDebounce;
webhookTemplateIn.addEventListener('input', () => {
clearTimeout(templateDebounce);
templateDebounce = setTimeout(() => save({ reminder_webhook_payload_template: webhookTemplateIn.value.trim() }), 600);
});
}
2026-05-31 23:58:26 +09:00
// Dim the whole AI Synthesis card when off (matches Vision/Utility/etc.).
function syncSynthesisDim() {
const card = llmToggle.closest('.admin-card');
if (card) card.style.opacity = llmToggle.checked ? '' : '0.45';
}
syncSynthesisDim();
llmToggle.addEventListener('change', () => {
syncSynthesisDim();
save({ reminder_llm_synthesis: llmToggle.checked });
});
// Test button
const testBtn = el('set-reminder-test-btn');
const testMsg = el('set-reminder-test-msg');
if (testBtn) {
testBtn.addEventListener('click', async () => {
testBtn.disabled = true;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (testMsg) { testMsg.textContent = 'Sending'; testMsg.style.color = 'var(--fg)'; }
// Whirlpool loader right next to the "Sending" text while it sends.
2026-05-31 23:58:26 +09:00
let _testSpin = null;
try {
const _sp = (await import('./spinner.js')).default;
_testSpin = _sp.createWhirlpool(14);
_testSpin.element.style.cssText = 'width:14px;height:14px;margin:0 0 0 7px;display:inline-block;vertical-align:middle;';
(testMsg || testBtn).insertAdjacentElement('afterend', _testSpin.element);
} catch (_) {}
const _stopTestSpin = () => { try { _testSpin && _testSpin.stop(); _testSpin && _testSpin.element.remove(); } catch (_) {} };
try {
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Persona picker is in a different scope (Reminders init), look it up
// by id so we can pass whatever is currently selected on screen.
const personaSel = el('set-reminder-llm-persona');
2026-05-31 23:58:26 +09:00
const res = await fetch('/api/notes/fire-reminder', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
note_id: 'test-' + Date.now(),
title: 'Test Reminder',
body: 'This is a test reminder to verify your settings are working.',
2026-06-05 16:47:57 -04:00
channel: channelSel.value,
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Mirror the in-UI AI Synthesis toggle + persona so the test never
// races a pending save and lets the user preview changes before
// hitting Save.
llm_synthesis: !!(llmToggle && llmToggle.checked),
llm_persona: (personaSel && personaSel.value) || '',
2026-06-05 16:47:57 -04:00
...(channelSel.value === 'webhook' ? {
webhook_integration_id: webhookIntgSel?.value || '',
webhook_payload_template: webhookTemplateIn?.value.trim() || '',
} : {}),
2026-05-31 23:58:26 +09:00
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Server error');
if (channelSel.value === 'email' && !data.email_sent) {
throw new Error(data.email_error || 'Email reminder was not sent');
}
if (channelSel.value === 'ntfy' && !data.ntfy_sent) {
throw new Error(data.ntfy_error || 'ntfy reminder was not sent');
}
2026-06-05 16:47:57 -04:00
if (channelSel.value === 'webhook' && !data.webhook_sent) {
const activeChannel = data.channel ? ` (server used channel: "${data.channel}")` : '';
throw new Error((data.webhook_error || 'Webhook reminder was not sent') + activeChannel);
}
2026-05-31 23:58:26 +09:00
let status = 'Delivered via ' + channelSel.value;
if (data.synthesis) status += ' (AI: "' + data.synthesis.slice(0, 60) + '...")';
if (data.email_sent) status += ' — email sent';
if (data.ntfy_sent) status += ' — ntfy sent';
2026-06-05 16:47:57 -04:00
if (data.webhook_sent) status += ' — webhook sent';
2026-05-31 23:58:26 +09:00
if (testMsg) { testMsg.textContent = status; testMsg.style.color = 'var(--green, #50fa7b)'; }
// Also fire a browser notification so user can see it
if ('Notification' in window && Notification.permission === 'granted') {
try {
new Notification('Test Reminder', {
body: data.synthesis || 'This is a test reminder.',
tag: 'reminder-test',
icon: '/static/favicon.ico',
});
} catch {}
}
} catch (e) {
if (testMsg) { testMsg.textContent = 'Failed: ' + e.message; testMsg.style.color = 'var(--red)'; }
} finally {
_stopTestSpin();
testBtn.disabled = false;
}
});
}
}
async function initEmailAccountsSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
const manageBtn = el('set-email-open-integrations');
if (manageBtn && manageBtn.dataset.bound !== '1') {
manageBtn.dataset.bound = '1';
manageBtn.addEventListener('click', () => open('integrations'));
}
2026-06-01 20:56:11 +09:00
const tasksBtn = el('set-email-open-tasks');
if (tasksBtn && tasksBtn.dataset.bound !== '1') {
tasksBtn.dataset.bound = '1';
tasksBtn.addEventListener('click', async () => {
try {
const mod = await import('./tasks.js');
const openTasks = mod.openTasks || (mod.default && mod.default.openTasks);
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (typeof openTasks === 'function') openTasks(null, { filter: 'Email' });
2026-06-01 20:56:11 +09:00
else document.getElementById('tool-tasks-btn')?.click();
} catch (_) {
document.getElementById('tool-tasks-btn')?.click();
}
});
}
2026-05-31 23:58:26 +09:00
const listEl = el('set-email-accounts-list');
const msgEl = el('set-email-accounts-msg');
const formEl = el('set-email-accounts-form');
const addBtn = el('set-email-accounts-add-btn');
if (!listEl || !addBtn || !formEl) return;
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
async function fetchAccounts() {
const r = await fetch('/api/email/accounts', { credentials: 'same-origin' });
const d = await r.json();
return d.accounts || [];
}
function renderRow(a) {
const imap = a.imap_host ? `${a.imap_host}:${a.imap_port}` : '<no IMAP>';
const badge = a.is_default
? '<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>'
: (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>');
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">
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${esc(a.name)} ${badge}</div>
<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>
</div>
${a.is_default ? '' : `<button class="admin-btn-sm email-acc-default-btn" style="font-size:10px">Make Default</button>`}
<button class="admin-btn-sm email-acc-edit-btn" style="font-size:10px">Edit</button>
<button class="admin-btn-sm email-acc-del-btn" style="font-size:10px;opacity:0.6">Delete</button>
</div>`;
}
async function renderList() {
const accs = await fetchAccounts();
if (!accs.length) {
listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center">No email accounts configured</div>';
return;
}
listEl.innerHTML = accs.map(renderRow).join('');
listEl.querySelectorAll('.email-account-row').forEach(row => {
const id = row.dataset.accId;
row.querySelector('.email-acc-default-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
await fetch(`/api/email/accounts/${id}/set-default`, { method: 'POST', credentials: 'same-origin' });
renderList();
});
row.querySelector('.email-acc-edit-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
showForm(accs.find(a => a.id === id));
});
row.querySelector('.email-acc-del-btn')?.addEventListener('click', async (e) => {
e.stopPropagation();
if (!await window.styledConfirm(`Delete account "${accs.find(a => a.id === id)?.name}"?`, { confirmText: 'Delete', danger: true })) return;
await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
renderList();
});
});
}
function showForm(existing) {
const a = existing || {};
const isEdit = !!existing;
formEl.style.display = '';
// Small `?` indicator next to each label. Hover/focus to read the
// hint via the native `title` tooltip. tabindex makes it
// keyboard-focusable too.
const _hint = (tip) =>
`<span class="eaf-hint" title="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0" `
+ `style="display:inline-block;width:13px;height:13px;border-radius:50%;`
+ `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;`
+ `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
// Provider presets — picking one fills host/port/STARTTLS for both
// IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally
// blank because it may live on another machine (DNS, LAN, Tailscale).
const PROVIDERS = {
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
google_workspace: { label: 'Google Workspace / .edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' },
migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } },
yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } },
2026-05-31 23:58:26 +09:00
};
const _providerOptions = Object.entries(PROVIDERS)
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
.join('');
2026-06-01 23:15:06 -05:00
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
2026-05-31 23:58:26 +09:00
formEl.innerHTML = `
<h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
<div class="settings-col">
<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>
<div id="eaf-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>
2026-05-31 23:58:26 +09:00
<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>
<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="you@example.com" value="${esc(a.from_address || '')}"></div>
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
<div class="settings-row"><label class="settings-label">Display Name${_hint('Your name as it appears in the From: field of emails you send, e.g. Jane Smith. Auto-filled from Google during OAuth.')}</label><input id="eaf-display-name" class="settings-input" placeholder="Your Name" value="${esc(a.display_name || '')}"></div>
<div id="eaf-oauth-section" style="display:none;margin:8px 0;padding:10px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb,var(--accent,#50fa7b) 6%,transparent)">
<div style="font-size:11px;font-weight:600;margin-bottom:6px">Google OAuth2 — required for Workspace / .edu accounts</div>
<div id="eaf-oauth-status" style="font-size:11px;opacity:0.7;margin-bottom:6px">${a.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}</div>
<button type="button" id="eaf-oauth-btn" class="admin-btn-add" style="font-size:11px">${a.oauth_provider === 'google' ? 'Reconnect with Google' : 'Connect with Google'}</button>
</div>
2026-05-31 23:58:26 +09:00
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
<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>
<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>
<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>
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
<div class="eaf-password-section"><div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div></div>
2026-05-31 23:58:26 +09:00
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn 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>
<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>
<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>
<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>
2026-06-01 23:15:06 -05:00
<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>
2026-05-31 23:58:26 +09:00
<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' : ''}><span class="admin-slider"></span></label></div>
<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>
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
2026-05-31 23:58:26 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;">
<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;">
<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>
${isEdit ? 'Save' : 'Create'}
</button>
<span id="eaf-msg" style="font-size:11px;flex:1;margin-left:8px;"></span>
<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;">
<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>
Cancel
</button>
</div>
</div>
`;
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
// Show/hide OAuth section and password fields based on provider selection.
function _syncOauthUI(providerKey) {
const p = PROVIDERS[providerKey];
const isOauth = !!(p && p.oauth);
el('eaf-oauth-section').style.display = isOauth ? '' : 'none';
formEl.querySelectorAll('.eaf-password-section').forEach(r => {
r.style.display = isOauth ? 'none' : '';
});
}
const eafProviderNotes = {
outlook: {
title: 'Outlook / Office 365 needs OAuth',
body: 'Microsoft disables normal password login for IMAP/SMTP in most Outlook and Microsoft 365 accounts. Odysseus does not support Microsoft OAuth/Graph mail yet, so this preset is only a placeholder for future support.',
},
};
const eafNoteEl = el('eaf-provider-note');
const _renderEafProviderNote = (key) => {
const n = eafProviderNotes[key];
if (!eafNoteEl || !n) {
if (eafNoteEl) {
eafNoteEl.style.display = 'none';
eafNoteEl.innerHTML = '';
}
return;
}
eafNoteEl.style.display = '';
eafNoteEl.innerHTML = `<div style="font-weight:600;margin-bottom:3px;">${esc(n.title)}</div><div style="opacity:0.8;">${esc(n.body)}</div>`;
};
2026-05-31 23:58:26 +09:00
// Provider preset → autofill host/port/STARTTLS for both halves.
el('eaf-provider').addEventListener('change', (e) => {
_renderEafProviderNote(e.target.value);
2026-05-31 23:58:26 +09:00
const p = PROVIDERS[e.target.value];
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
if (!p) { _syncOauthUI(''); return; }
2026-05-31 23:58:26 +09:00
el('eaf-imap-host').value = p.imap.host;
el('eaf-imap-port').value = p.imap.port;
el('eaf-imap-starttls').checked = !!p.imap.starttls;
el('eaf-smtp-host').value = p.smtp.host;
el('eaf-smtp-port').value = p.smtp.port;
2026-06-01 23:15:06 -05:00
el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
_syncOauthUI(e.target.value);
});
// Init OAuth UI for accounts already connected via OAuth.
if (a.oauth_provider === 'google') _syncOauthUI('google_workspace');
// "Connect with Google" button — save the account first, then redirect to OAuth.
el('eaf-oauth-btn').addEventListener('click', async () => {
// Must save the account first to get an account_id to pass to the OAuth flow.
const body = {
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
from_address: el('eaf-from').value.trim(),
imap_host: el('eaf-imap-host').value.trim(),
imap_port: parseInt(el('eaf-imap-port').value) || 993,
imap_user: el('eaf-imap-user').value.trim(),
imap_starttls: el('eaf-imap-starttls').checked,
smtp_host: el('eaf-smtp-host').value.trim(),
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
smtp_user: el('eaf-imap-user').value.trim(),
};
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts';
const method = isEdit ? 'PUT' : 'POST';
const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (!d.ok) { el('eaf-msg').textContent = d.error || 'Save failed'; el('eaf-msg').style.color = 'var(--red)'; return; }
const accId = isEdit ? a.id : d.id;
window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`;
2026-05-31 23:58:26 +09:00
});
2026-06-01 23:15:06 -05:00
el('eaf-smtp-security').value = _smtpSecurity(a);
2026-05-31 23:58:26 +09:00
// "Same as IMAP" toggle — hide the SMTP creds rows when on. The save
// handler copies the IMAP user/password into SMTP at submit time.
const _syncSmtpSame = () => {
const same = el('eaf-smtp-same').checked;
formEl.querySelectorAll('.eaf-smtp-creds').forEach(r => {
r.style.display = same ? 'none' : '';
});
};
el('eaf-smtp-same').addEventListener('change', _syncSmtpSame);
_syncSmtpSame();
el('eaf-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('eaf-save').addEventListener('click', async () => {
const body = {
name: el('eaf-name').value.trim(),
from_address: el('eaf-from').value.trim(),
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
display_name: el('eaf-display-name').value.trim(),
2026-05-31 23:58:26 +09:00
imap_host: el('eaf-imap-host').value.trim(),
imap_port: parseInt(el('eaf-imap-port').value) || 993,
imap_user: el('eaf-imap-user').value.trim(),
imap_starttls: el('eaf-imap-starttls').checked,
smtp_host: el('eaf-smtp-host').value.trim(),
smtp_port: parseInt(el('eaf-smtp-port').value) || 465,
2026-06-01 23:15:06 -05:00
smtp_security: el('eaf-smtp-security').value,
2026-05-31 23:58:26 +09:00
smtp_user: el('eaf-smtp-user').value.trim(),
};
if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value;
if (el('eaf-smtp-pass').value) body.smtp_password = el('eaf-smtp-pass').value;
// "Same as IMAP" toggle — copy IMAP username/password into SMTP at
// save time, so the hidden SMTP-creds rows don't matter. We only
// mirror the password if the user actually typed an IMAP one
// (otherwise SMTP keeps whatever it already had on the server).
if (el('eaf-smtp-same').checked) {
body.smtp_user = body.imap_user;
if (body.imap_password) body.smtp_password = body.imap_password;
}
// Name is optional — fall back to the From address so the list view
// still has a label to render. Only refuse if both are blank.
if (!body.name) body.name = body.from_address;
if (!body.name) { el('eaf-msg').textContent = 'Need at least a Name or Email'; el('eaf-msg').style.color = 'var(--red)'; return; }
try {
const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts';
const method = isEdit ? 'PUT' : 'POST';
const r = await fetch(url, {
method, credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (d.ok || d.id) {
el('eaf-msg').textContent = 'Saved';
el('eaf-msg').style.color = 'var(--green,#50fa7b)';
setTimeout(() => { formEl.style.display = 'none'; renderList(); }, 400);
} else {
el('eaf-msg').textContent = d.error || 'Save failed';
el('eaf-msg').style.color = 'var(--red)';
}
} catch (e) {
el('eaf-msg').textContent = 'Error: ' + e.message;
el('eaf-msg').style.color = 'var(--red)';
}
});
}
addBtn.addEventListener('click', () => showForm(null));
await renderList();
}
async function initEmailSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
2026-07-07 00:50:07 +00:00
const styleKey = 'odysseus-email-writing-style';
const styleEl = el('set-email-style');
// The account/CardDAV config endpoints can be slow when remote mail servers
// are cold. Populate the Writing Style box independently so saved prose does
// not appear seconds after the panel opens.
try {
const cachedStyle = localStorage.getItem(styleKey);
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
} catch (_) {}
const loadWritingStyle = async () => {
try {
const res = await fetch('/api/email/style');
const data = await res.json();
const style = data.style || '';
if (styleEl) styleEl.value = style;
try { localStorage.setItem(styleKey, style); } catch (_) {}
} catch (_) {}
};
loadWritingStyle();
2026-05-31 23:58:26 +09:00
// Load current email config
try {
const res = await fetch('/api/email/config');
const cfg = await res.json();
if (el('set-email-imap-host')) el('set-email-imap-host').value = cfg.imap_host || '';
if (el('set-email-imap-port')) el('set-email-imap-port').value = cfg.imap_port || '';
if (el('set-email-imap-user')) el('set-email-imap-user').value = cfg.imap_user || '';
if (el('set-email-imap-pass')) el('set-email-imap-pass').value = ''; // never prefill
if (el('set-email-smtp-host')) el('set-email-smtp-host').value = cfg.smtp_host || '';
if (el('set-email-smtp-port')) el('set-email-smtp-port').value = cfg.smtp_port || '';
if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
} catch (_) {}
// Load contacts config
try {
const res = await fetch('/api/contacts/config');
const cfg = await res.json();
if (el('set-carddav-url')) el('set-carddav-url').value = cfg.url || '';
if (el('set-carddav-user')) el('set-carddav-user').value = cfg.username || '';
if (el('set-carddav-pass')) el('set-carddav-pass').value = '';
} catch (_) {}
// Save email config
el('set-email-save')?.addEventListener('click', async () => {
const msg = el('set-email-msg');
if (msg) msg.textContent = 'Saving...';
const data = {
imap_host: el('set-email-imap-host').value,
imap_port: parseInt(el('set-email-imap-port').value) || 0,
imap_user: el('set-email-imap-user').value,
smtp_host: el('set-email-smtp-host').value,
smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
smtp_user: el('set-email-smtp-user').value,
email_from: el('set-email-from').value,
};
const imapPass = el('set-email-imap-pass').value;
const smtpPass = el('set-email-smtp-pass').value;
if (imapPass) data.imap_password = imapPass;
if (smtpPass) data.smtp_password = smtpPass;
try {
const res = await fetch('/api/email/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await res.json();
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
} catch (e) {
if (msg) msg.textContent = 'Failed';
}
});
// Save CardDAV config
el('set-carddav-save')?.addEventListener('click', async () => {
const msg = el('set-carddav-msg');
if (msg) msg.textContent = 'Saving...';
const data = {
carddav_url: el('set-carddav-url').value,
carddav_username: el('set-carddav-user').value,
};
const pass = el('set-carddav-pass').value;
if (pass) data.carddav_password = pass;
try {
const res = await fetch('/api/contacts/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await res.json();
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
} catch (e) {
if (msg) msg.textContent = 'Failed';
}
});
// Extract writing style
el('set-email-style-extract')?.addEventListener('click', async () => {
const btn = el('set-email-style-extract');
const msg = el('set-email-style-msg');
btn.disabled = true;
// Render whirlpool + label inside the status area (same pattern as
// the "Find" / network-discover button in Add Models).
let wp = null;
if (msg) {
msg.className = '';
msg.innerHTML = '';
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
wp = sp.createWhirlpool(16);
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;margin:0 8px 0 0;';
const wrap = document.createElement('span');
wrap.style.cssText = 'display:inline-flex;align-items:center;';
wrap.appendChild(wp.element);
const txt = document.createElement('span');
txt.textContent = 'Analyzing your sent emails…';
txt.style.cssText = 'font-size:12px;opacity:0.7;';
wrap.appendChild(txt);
msg.appendChild(wrap);
} catch (_) {
msg.textContent = 'Analyzing your sent emails…';
}
}
try {
const res = await fetch('/api/email/extract-style', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sample_count: 15 }),
});
const data = await res.json();
if (data.success && data.style) {
2026-07-07 00:50:07 +00:00
if (styleEl) styleEl.value = data.style;
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
2026-05-31 23:58:26 +09:00
if (msg) msg.textContent = '✓ Style extracted';
} else {
if (msg) msg.textContent = data.error || 'Failed';
}
} catch (e) {
if (msg) msg.textContent = 'Failed to extract';
} finally {
if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} }
btn.disabled = false;
setTimeout(() => { if (msg) msg.textContent = ''; }, 5000);
}
});
// Save writing style manually
el('set-email-style-save')?.addEventListener('click', async () => {
const msg = el('set-email-style-msg');
if (msg) msg.textContent = 'Saving...';
try {
2026-07-07 00:50:07 +00:00
const style = styleEl ? styleEl.value : '';
2026-05-31 23:58:26 +09:00
const res = await fetch('/api/email/style', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
2026-07-07 00:50:07 +00:00
body: JSON.stringify({ style }),
2026-05-31 23:58:26 +09:00
});
const result = await res.json();
2026-07-07 00:50:07 +00:00
if (result.success) {
try { localStorage.setItem(styleKey, style); } catch (_) {}
}
2026-05-31 23:58:26 +09:00
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
} catch (e) {
if (msg) msg.textContent = 'Failed';
}
});
}
async function initIntegrations() {
const listEl = el('integrations-list');
const formCard = el('integration-form-card');
const addBtn = el('intg-add-btn');
if (!listEl || !formCard) return;
const presetSel = el('intg-preset');
const nameIn = el('intg-name');
const urlIn = el('intg-url');
const authTypeSel = el('intg-auth-type');
const authHeaderRow = el('intg-auth-header-row');
const authHeaderIn = el('intg-auth-header');
const keyIn = el('intg-key');
const descIn = el('intg-description');
const saveBtn = el('intg-save-btn');
const cancelBtn = el('intg-cancel-btn');
const testBtn = el('intg-test-btn');
const statusEl = el('intg-status');
const formTitle = el('integration-form-title');
let editingId = null;
let presets = {};
2026-06-05 16:47:57 -04:00
// Presets where the secret is embedded in the URL — no separate key or
// auth header is used, so hiding those fields avoids confusion.
const URL_AUTH_PRESETS = ['discord_webhook'];
// Toggle auth header + key row visibility based on auth type and preset.
2026-05-31 23:58:26 +09:00
function syncAuthRow() {
const v = authTypeSel.value;
authHeaderRow.style.display = (v === 'header' || v === 'query') ? 'flex' : 'none';
if (v === 'query') authHeaderIn.placeholder = 'api_key';
else authHeaderIn.placeholder = 'X-Auth-Token';
2026-06-05 16:47:57 -04:00
const keyRow = keyIn?.closest('.settings-row');
if (keyRow) keyRow.style.display = URL_AUTH_PRESETS.includes(presetSel?.value) ? 'none' : '';
2026-05-31 23:58:26 +09:00
}
authTypeSel.addEventListener('change', syncAuthRow);
// Load presets
try {
const res = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
presets = data.presets || {};
for (const [key, preset] of Object.entries(presets)) {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = preset.name || key;
presetSel.appendChild(opt);
}
}
} catch (e) {}
// Preset auto-fill
presetSel.addEventListener('change', () => {
const p = presets[presetSel.value];
if (!p) return;
nameIn.value = p.name || '';
authTypeSel.value = p.auth_type || 'none';
authHeaderIn.value = p.auth_header || '';
descIn.value = p.description || '';
syncAuthRow();
});
// Render list
async function renderList() {
try {
const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
if (!res.ok) { listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;">Admin access required</div>'; return; }
const data = await res.json();
const items = data.integrations || [];
if (!items.length) {
listEl.innerHTML = '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center;">No integrations configured</div>';
return;
}
listEl.innerHTML = items.map(i => `
<div class="admin-card" style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;">${_esc(i.name || i.id)}</div>
<div style="font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(i.base_url || '')}</div>
</div>
<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="admin-btn-sm intg-edit-btn" data-id="${i.id}" style="font-size:11px;">Edit</button>
<button class="admin-btn-sm intg-del-btn" data-id="${i.id}" style="font-size:11px;opacity:0.6;">Del</button>
</div>
</div>
`).join('');
listEl.querySelectorAll('.intg-edit-btn').forEach(b => b.addEventListener('click', () => startEdit(b.dataset.id)));
listEl.querySelectorAll('.intg-del-btn').forEach(b => b.addEventListener('click', () => doDelete(b.dataset.id)));
} catch (e) { listEl.innerHTML = '<div style="padding:12px;color:var(--red);font-size:12px;">Failed to load</div>'; }
}
function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// Start editing
async function startEdit(id) {
editingId = id;
formTitle.textContent = 'Edit Integration';
// Fetch full data (with unmasked key from a dedicated edit fetch — we'll just load what we have)
try {
const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
const data = await res.json();
const item = (data.integrations || []).find(i => i.id === id);
if (!item) return;
presetSel.value = item.preset || '';
nameIn.value = item.name || '';
urlIn.value = item.base_url || '';
authTypeSel.value = item.auth_type || 'none';
authHeaderIn.value = item.auth_header || '';
keyIn.value = ''; // masked — user re-enters if changing
keyIn.placeholder = item.api_key ? 'Leave blank to keep current' : 'API key or token';
descIn.value = item.description || '';
syncAuthRow();
formCard.style.display = '';
} catch (e) {}
}
// Show add form
addBtn.addEventListener('click', () => {
editingId = null;
formTitle.textContent = 'Add Integration';
presetSel.value = '';
nameIn.value = '';
urlIn.value = '';
authTypeSel.value = 'header';
authHeaderIn.value = '';
keyIn.value = '';
keyIn.placeholder = 'API key or token';
descIn.value = '';
statusEl.textContent = '';
syncAuthRow();
formCard.style.display = '';
});
cancelBtn.addEventListener('click', () => {
formCard.style.display = 'none';
statusEl.textContent = '';
});
// Save
saveBtn.addEventListener('click', async () => {
const payload = {
name: nameIn.value.trim(),
base_url: urlIn.value.trim().replace(/\/+$/, ''),
auth_type: authTypeSel.value,
auth_header: authHeaderIn.value.trim(),
description: descIn.value.trim(),
};
if (presetSel.value) payload.preset = presetSel.value;
if (keyIn.value.trim()) payload.api_key = keyIn.value.trim();
if (!payload.name) { statusEl.textContent = 'Name required'; statusEl.style.color = 'var(--red)'; return; }
if (!payload.base_url) { statusEl.textContent = 'URL required'; statusEl.style.color = 'var(--red)'; return; }
try {
const url = editingId ? `/api/auth/integrations/${editingId}` : '/api/auth/integrations';
const method = editingId ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'same-origin' });
if (res.ok) {
statusEl.textContent = 'Saved';
statusEl.style.color = 'var(--green, #98c379)';
formCard.style.display = 'none';
await renderList();
notifyIntegrationsChanged();
} else {
const err = await res.json().catch(() => ({}));
statusEl.textContent = err.detail || 'Save failed';
statusEl.style.color = 'var(--red)';
}
} catch (e) {
statusEl.textContent = 'Error saving';
statusEl.style.color = 'var(--red)';
}
});
// Test
testBtn.addEventListener('click', async () => {
if (!editingId) { statusEl.textContent = 'Save first, then test'; statusEl.style.color = 'var(--fg)'; return; }
statusEl.textContent = 'Testing...';
statusEl.style.color = 'var(--fg)';
try {
const res = await fetch(`/api/auth/integrations/${editingId}/test`, { method: 'POST', credentials: 'same-origin' });
const data = await res.json();
statusEl.textContent = data.message || (data.ok ? 'OK' : 'Failed');
statusEl.style.color = data.ok ? 'var(--green, #98c379)' : 'var(--red)';
} catch (e) {
statusEl.textContent = 'Connection failed';
statusEl.style.color = 'var(--red)';
}
});
// Delete
async function doDelete(id) {
if (!await window.styledConfirm('Delete this integration?', { confirmText: 'Delete', danger: true })) return;
try {
await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' });
if (editingId === id) { formCard.style.display = 'none'; editingId = null; }
await renderList();
notifyIntegrationsChanged();
} catch (e) {}
}
syncAuthRow();
renderList();
}
/* ══ Unified Integrations ══ */
const INTG_TYPES = {
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>' },
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>' },
2026-06-01 13:35:13 +09:00
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>' },
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>' },
2026-05-31 23:58:26 +09:00
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>' },
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>' },
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>' },
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>' },
2026-05-31 23:58:26 +09:00
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>' },
};
// Config shared by the Codex Agent and Claude Agent forms. Both use the same
// scope-gated /api/codex/* backend; this just parameterizes the UI label,
// default token name, and the per-agent install commands.
const AGENT_CONFIGS = {
codex: {
label: 'Codex Agent',
word: 'Codex',
namePrefix: 'codex agent',
defaultName: 'Codex Agent',
pluginPath: '/api/codex/plugin.zip',
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
setupDescription: 'Downloads a plugin bundle and registers it.',
buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
export ODYSSEUS_API_TOKEN='${token}'
mkdir -p ~/plugins
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
python3 - <<'PY'
import json
from pathlib import Path
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
data = json.loads(p.read_text())
else:
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
data.setdefault("name", "personal")
data.setdefault("interface", {}).setdefault("displayName", "Personal")
plugins = data.setdefault("plugins", [])
entry = {
"name": "odysseus",
"source": {"source": "local", "path": "./plugins/odysseus"},
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
"category": "Productivity",
}
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
p.write_text(json.dumps(data, indent=2) + "\\n")
PY
codex plugin add odysseus@personal
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`,
},
claude: {
label: 'Claude Agent',
word: 'Claude',
namePrefix: 'claude agent',
defaultName: 'Claude Agent',
pluginPath: '/api/claude/plugin.zip',
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
setupDescription: 'Downloads a plugin bundle and registers it.',
buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
export ODYSSEUS_API_TOKEN='${token}'
mkdir -p ~/.claude
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip
python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities`,
},
};
2026-05-31 23:58:26 +09:00
let _unifiedInited = false;
async function initUnifiedIntegrations() {
if (_unifiedInited) return;
_unifiedInited = true;
const listEl = el('unified-integrations-list');
const formEl = el('unified-intg-form');
const addBtn = el('unified-intg-add-btn');
if (!listEl) return;
let integrationNotice = '';
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Hide the "+ Add Integration" button whenever the per-type create form
// is open so it doesn't compete visually with the in-progress form.
// Many call sites toggle formEl.style.display directly; observe instead
// of patching every one of them.
if (formEl && addBtn && addBtn.parentElement && !formEl._addBtnObserved) {
formEl._addBtnObserved = true;
const addBtnWrap = addBtn.parentElement;
const _syncAddBtnWrap = () => {
const formOpen = formEl.style.display && formEl.style.display !== 'none';
addBtnWrap.style.display = formOpen ? 'none' : '';
};
new MutationObserver(_syncAddBtnWrap).observe(formEl, { attributes: true, attributeFilter: ['style'] });
_syncAddBtnWrap();
}
2026-05-31 23:58:26 +09:00
function _openEmailSettings() {
open('email');
}
async function fetchAll() {
const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes, tokenRes, calendarsRes] = await Promise.all([
2026-05-31 23:58:26 +09:00
fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })),
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
fetch('/api/calendar/config/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
2026-05-31 23:58:26 +09:00
fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
fetch('/api/contacts/list', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { contacts: [], count: 0 }).catch(() => ({ contacts: [], count: 0 })),
fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
fetch('/api/tokens', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []),
fetch('/api/calendar/calendars', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { calendars: [] }).catch(() => ({ calendars: [] })),
2026-05-31 23:58:26 +09:00
]);
const items = [];
// API integrations
for (const intg of (apiRes.integrations || [])) {
items.push({ type: 'api', id: intg.id, name: intg.name || 'Unnamed', detail: intg.base_url || '', enabled: intg.enabled !== false, data: intg });
}
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
// CalDAV — one card per account
for (const acc of (calRes.accounts || [])) {
items.push({ type: 'caldav', id: acc.id, name: acc.label || 'Calendar (CalDAV)', detail: acc.url, enabled: true, data: acc });
2026-05-31 23:58:26 +09:00
}
2026-06-01 13:35:13 +09:00
// Contacts import first, then the optional CardDAV sync account.
2026-05-31 23:58:26 +09:00
const contactCount = Number(contactsRes.count || (contactsRes.contacts || []).length || 0);
2026-06-01 13:35:13 +09:00
if (contactCount > 0) {
items.push({
type: 'contacts',
id: '__contacts__',
name: 'Contacts Import',
detail: `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
enabled: true,
data: contactsRes,
});
}
if (cardRes.url) {
2026-05-31 23:58:26 +09:00
items.push({
type: 'carddav',
id: '__carddav__',
2026-06-01 13:35:13 +09:00
name: 'Contacts (CardDAV)',
detail: cardRes.url,
2026-05-31 23:58:26 +09:00
enabled: true,
data: cardRes,
});
}
// Email — one entry per EmailAccount row
for (const acc of (emailAccountsRes.accounts || [])) {
const label = acc.name + (acc.is_default ? ' (default)' : '');
const detail = [acc.from_address || acc.imap_user, acc.imap_host].filter(Boolean).join(' — ');
items.push({ type: 'email', id: acc.id, name: label, detail, enabled: acc.enabled !== false, data: acc });
}
// MCP servers
const mcpList = Array.isArray(mcpRes) ? mcpRes : (mcpRes.servers || []);
for (const srv of mcpList) {
const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected';
items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv });
}
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) {
const scopes = tok.scopes || [];
const lowerName = (tok.name || '').toLowerCase();
let agentType = null;
if (lowerName.startsWith('claude agent')) agentType = 'claude';
else if (lowerName.startsWith('codex agent')) agentType = 'codex';
else if (scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'))) {
// Legacy / un-prefixed scoped tokens fall back to Codex for backwards compat.
agentType = 'codex';
}
if (!agentType) continue;
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`;
items.push({ type: agentType, id: tok.id, name: tok.name || (agentType === 'claude' ? 'Claude Agent' : 'Codex Agent'), detail, enabled: true, data: tok });
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
}
2026-05-31 23:58:26 +09:00
// Vaultwarden removed as an integration option.
return items;
}
function renderCard(item) {
const t = INTG_TYPES[item.type] || INTG_TYPES.api;
// Static enabled/disabled indicator — same dot every integration
// type gets. (The clickable glow-on-test variant for email was
// removed earlier; this matches the API/CalDAV/MCP pattern.)
const statusDot = item.enabled
? '<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>'
2026-05-31 23:58:26 +09:00
: '<span style="width:8px;height:8px;border-radius:50%;background:var(--fg);opacity:0.3;flex-shrink:0" title="Disabled"></span>';
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">
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<span style="color:var(--accent, var(--red));flex-shrink:0">${t.icon}</span>
2026-05-31 23:58:26 +09:00
<div style="flex:1;min-width:0">
<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>
<div style="font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${item.detail || ''}</div>
</div>
${statusDot}
<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, '&quot;')}" 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;">
2026-05-31 23:58:26 +09:00
<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>
</button>
</div>`;
}
async function renderList() {
const items = await fetchAll();
const noticeHtml = integrationNotice ? `
<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;">
<span style="flex:1;line-height:1.35">${integrationNotice}</span>
<button type="button" class="admin-btn-sm intg-open-email-settings" style="white-space:nowrap;">Email settings</button>
</div>` : '';
if (items.length === 0) {
listEl.innerHTML = noticeHtml + '<div style="padding:12px;opacity:0.5;font-size:12px;text-align:center">No integrations configured</div>';
} else {
listEl.innerHTML = noticeHtml + items.map(renderCard).join('');
}
listEl.querySelector('.intg-open-email-settings')?.addEventListener('click', (e) => {
e.stopPropagation();
_openEmailSettings();
});
// Wire edit clicks
listEl.querySelectorAll('.intg-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.intg-del-btn')) return;
const type = card.dataset.intgType;
const id = card.dataset.intgId;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Toggle a class instead of mutating inline borderColor — the
// inline border shorthand made the reset unreliable, leaving
// stale accent borders on previously-clicked cards.
listEl.querySelectorAll('.intg-card.intg-card-active').forEach(c => c.classList.remove('intg-card-active'));
card.classList.add('intg-card-active');
2026-05-31 23:58:26 +09:00
showForm(type, id);
});
});
// Wire delete
listEl.querySelectorAll('.intg-del-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const intgName = btn.dataset.intgName || 'this integration';
if (!await window.styledConfirm(`Remove "${intgName}"?`, { confirmText: 'Remove', danger: true })) return;
2026-05-31 23:58:26 +09:00
const type = btn.dataset.intgType;
const id = btn.dataset.intgId;
try {
if (type === 'api') await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' });
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
else if (type === 'caldav') await fetch(`/api/calendar/config/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
2026-06-01 13:35:13 +09:00
else if (type === 'contacts') {
await fetch('/api/contacts/clear', { method: 'DELETE', credentials: 'same-origin' });
}
2026-05-31 23:58:26 +09:00
else if (type === 'carddav') {
await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carddav_url: '', carddav_username: '', carddav_password: '' }) });
}
else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'codex' || type === 'claude') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' });
2026-05-31 23:58:26 +09:00
else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
} catch (_) {}
formEl.style.display = 'none';
await renderList();
notifyIntegrationsChanged();
});
});
}
function showForm(type, editId) {
formEl.style.display = '';
if (type === 'api') showApiForm(editId);
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
else if (type === 'caldav') showCalDavForm(editId);
2026-06-01 13:35:13 +09:00
else if (type === 'contacts' || type === 'carddav') showCardDavForm();
2026-05-31 23:58:26 +09:00
else if (type === 'email') showEmailForm(editId);
else if (type === 'mcp') showMcpForm(editId);
else if (type === 'codex') showAgentForm('codex', editId);
else if (type === 'claude') showAgentForm('claude', editId);
2026-05-31 23:58:26 +09:00
else if (type === 'vault') showVaultForm();
}
// ── API form ──
async function showApiForm(editId) {
let presets = {};
try {
const r = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' });
if (r.ok) { const d = await r.json(); presets = d.presets || {}; }
} catch (_) {}
const presetEntries = Object.entries(presets);
// Same `?` hint helper as the email form. Native title tooltip,
// tabbable for keyboard users. Inline-styled so it doesn't need
// a CSS dependency.
const _apiHint = (tip) =>
`<span class="uf-hint" title="${esc(tip.replace(/<[^>]+>/g, ''))}" aria-label="${esc(tip.replace(/<[^>]+>/g, ''))}" tabindex="0" `
+ `style="display:inline-block;width:13px;height:13px;border-radius:50%;`
+ `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;`
+ `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
// Real <select> instead of <datalist>: datalists are silently
// suppressed in Firefox when autocomplete="off" is on the input,
// and they're patchy on mobile browsers. A native select renders
// the same everywhere and makes the available options visible
// without needing the user to type.
const sortedPresets = presetEntries.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]));
const selectOpts = sortedPresets
2026-05-31 23:58:26 +09:00
.map(([k, p]) => `<option value="${k}">${esc(p.name || k)}</option>`)
.join('');
// Letter-in-brand-color logo for each API preset; outline plug icon for
// "Custom (no preset)". Matches the email-provider dropdown pattern.
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>`;
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>';
const API_PRESET_LOGO = {
miniflux: _apiLetter('M', '#214c87'),
gitea: _apiLetter('G', '#609926'),
linkding: _apiLetter('L', '#1f2937'),
home_assistant: _apiLetter('H', '#41bdf5'),
ntfy: _apiLetter('n', '#317f43'),
vaultwarden: _apiLetter('V', '#175ddc'),
freshrss: _apiLetter('R', '#ef6c00'),
};
const _apiIconFor = (k) => {
if (!k) return _apiCustomIco;
if (API_PRESET_LOGO[k]) return API_PRESET_LOGO[k];
const first = (presets[k]?.name || k).trim().charAt(0).toUpperCase() || '?';
return _apiLetter(first, '#6b7280');
};
const _apiRows = [['', 'Custom (no preset)'], ...sortedPresets.map(([k, p]) => [k, p.name || k])]
.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('');
2026-05-31 23:58:26 +09:00
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<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>
2026-05-31 23:58:26 +09:00
<div class="settings-col">
<div class="settings-row"><label class="settings-label">Preset</label>
<div style="position:relative;flex:1;min-width:0;">
<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>
<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;">
<span class="ufapi-icon" style="display:inline-flex;align-items:center;">${_apiCustomIco}</span>
<span class="ufapi-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom (no preset)</span>
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
</button>
<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>
</div>
</div>
2026-05-31 23:58:26 +09:00
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div>
<div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div>
<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>
2026-05-31 23:58:26 +09:00
<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>
<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>
<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>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;justify-content:flex-end;gap:6px;">
<span id="uf-api-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
<button class="admin-btn-add" id="uf-api-test" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Test</button>
<button class="admin-btn-add" id="uf-api-save" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">Save</button>
<button class="admin-btn-add" id="uf-api-cancel" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Cancel</button>
</div>
2026-05-31 23:58:26 +09:00
</div>
</div>`;
// Custom preset dropdown wire-up (hidden select stays as data source).
(() => {
const trig = el('uf-api-preset-trigger');
const menu = el('uf-api-preset-menu');
const sel = el('uf-api-preset');
if (!trig || !menu || !sel) return;
const lbl = trig.querySelector('.ufapi-label');
const ico = trig.querySelector('.ufapi-icon');
const _setFromKey = (k) => {
const row = menu.querySelector(`.ufapi-option[data-value="${k}"]`);
const text = row?.querySelector('span')?.textContent || 'Custom (no preset)';
if (lbl) lbl.textContent = text;
if (ico) ico.innerHTML = _apiIconFor(k);
};
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
// Menu is reused (hidden, not recreated). close() hides it and tears down
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _open).
let _close = () => { menu.style.display = 'none'; };
const _open = () => {
menu.style.display = 'block';
const tRect = trig.getBoundingClientRect();
const mRect = menu.getBoundingClientRect();
const below = window.innerHeight - tRect.bottom;
const above = tRect.top;
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
};
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
btn.addEventListener('click', (e) => {
e.stopPropagation();
const k = btn.dataset.value || '';
sel.value = k;
_setFromKey(k);
_close();
sel.dispatchEvent(new Event('change', { bubbles: true }));
});
});
_setFromKey(sel.value || '');
})();
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');
2026-05-31 23:58:26 +09:00
let _editId = editId && editId !== 'new' ? editId : null;
// Load existing
if (_editId) {
try {
const r = await fetch('/api/auth/integrations', { credentials: 'same-origin' });
const d = await r.json();
const item = (d.integrations || []).find(i => i.id === _editId);
if (item) { name.value = item.name || ''; url.value = item.base_url || ''; auth.value = item.auth_type || 'none'; header.value = item.auth_header || ''; }
} catch (_) {}
}
// Native <select>: the option `value` is the preset key directly, so
// no typed-name → key lookup is needed (datalist-era leftover).
const _applyPreset = () => {
const p = presets[preset.value];
const isNtfy = preset.value === 'ntfy' || (p && (p.name || '').toLowerCase() === 'ntfy');
2026-06-05 16:47:57 -04:00
const isUrlAuth = preset.value === 'discord_webhook'; // secret embedded in URL — no key/auth fields needed
if (ntfyHint) {
ntfyHint.style.display = isNtfy ? 'block' : 'none';
if (isNtfy) {
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>.';
}
}
if (url) {
2026-06-05 16:47:57 -04:00
url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : isUrlAuth ? 'https://discord.com/api/webhooks/...' : 'http://localhost:8080';
}
2026-06-05 16:47:57 -04:00
// For presets that embed the secret in the URL, hide auth/key/header rows
// so users aren't confused into thinking they need to fill them in.
const keyRow = key?.closest('.settings-row');
const authRow = auth?.closest('.settings-row');
const headerRow = el('uf-api-header-row');
if (keyRow) keyRow.style.display = isUrlAuth ? 'none' : '';
if (authRow) authRow.style.display = isUrlAuth ? 'none' : '';
if (headerRow) headerRow.style.display = isUrlAuth ? 'none' : '';
2026-05-31 23:58:26 +09:00
if (!p) return;
name.value = p.name || '';
auth.value = p.auth_type || 'none';
header.value = p.auth_header || '';
};
preset.addEventListener('change', _applyPreset);
_applyPreset();
2026-05-31 23:58:26 +09:00
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-api-save').addEventListener('click', async () => {
const presetKey = preset.value || undefined;
const nameValue = name.value.trim();
const urlValue = url.value.trim();
if (!nameValue) { el('uf-api-msg').textContent = 'Name required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
if (!urlValue) { el('uf-api-msg').textContent = 'Base URL required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
const body = { name: nameValue, base_url: urlValue, auth_type: auth.value, auth_header: header.value, preset: presetKey };
2026-05-31 23:58:26 +09:00
if (key.value) body.api_key = key.value;
try {
const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
const m = _editId ? 'PUT' : 'POST';
const r = await fetch(u, { method: m, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) throw new Error();
const saved = await r.json().catch(() => null);
// If this was a create, capture the new ID so Test works
// immediately without needing a form reopen. The POST response
// shape is {ok, integration: {id, ...}} — saved.id at the top
// level would silently miss, leaving Test perpetually stuck on
// "Save first" until the form was reopened.
if (!_editId && saved) _editId = saved.integration?.id || saved.id;
el('uf-api-msg').textContent = 'Saved'; el('uf-api-msg').style.color = 'var(--green,#50fa7b)';
await renderList();
notifyIntegrationsChanged();
} catch (_) { el('uf-api-msg').textContent = 'Failed'; el('uf-api-msg').style.color = 'var(--red)'; }
});
el('uf-api-test').addEventListener('click', async () => {
if (!_editId) { el('uf-api-msg').textContent = 'Save first'; return; }
try {
const r = await fetch(`/api/auth/integrations/${_editId}/test`, { method: 'POST', credentials: 'same-origin' });
const d = await r.json();
// Backend returns {ok: bool, message: str}
if (d.ok) {
el('uf-api-msg').textContent = d.message || 'Connected';
el('uf-api-msg').style.color = 'var(--green,#50fa7b)';
} else {
el('uf-api-msg').textContent = (d.message || d.error || d.detail || `HTTP ${r.status}`).slice(0, 360);
2026-05-31 23:58:26 +09:00
el('uf-api-msg').style.color = 'var(--red)';
}
} catch (e) { el('uf-api-msg').textContent = 'Error: ' + e.message; el('uf-api-msg').style.color = 'var(--red)'; }
});
}
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
// ── CalDAV form (supports add + edit per account) ──
async function showCalDavForm(editId) {
const isNew = !editId || editId === 'new';
2026-05-31 23:58:26 +09:00
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
<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>
2026-05-31 23:58:26 +09:00
<div class="settings-col">
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
<div class="settings-row"><label class="settings-label">Label</label><input id="uf-caldav-label" class="settings-input" placeholder="e.g. Work, Personal"></div>
<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/you@gmail.com/user/"></div>
<div class="settings-row"><label class="settings-label">Username</label><input id="uf-caldav-user" class="settings-input" placeholder="you@example.com"></div>
<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>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;justify-content:flex-end;gap:6px;">
<span id="uf-caldav-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
<button class="admin-btn-add" id="uf-caldav-test" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Test</button>
<button class="admin-btn-add" id="uf-caldav-save" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">Save</button>
<button class="admin-btn-add" id="uf-caldav-cancel" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Cancel</button>
</div>
2026-05-31 23:58:26 +09:00
</div>
</div>`;
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
if (!isNew) {
try {
const r = await fetch('/api/calendar/config/accounts', { credentials: 'same-origin' });
const d = await r.json();
const acc = (d.accounts || []).find(a => a.id === editId);
if (acc) {
el('uf-caldav-label').value = acc.label || '';
el('uf-caldav-url').value = acc.url || '';
el('uf-caldav-user').value = acc.username || '';
}
} catch (_) {}
}
2026-05-31 23:58:26 +09:00
el('uf-caldav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
const _runCalDavTest = async () => {
const body = {
url: el('uf-caldav-url').value.trim(),
username: el('uf-caldav-user').value.trim(),
password: el('uf-caldav-pass').value,
};
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
if (!isNew && !body.password) body.account_id = editId;
2026-05-31 23:58:26 +09:00
try {
const r = await fetch('/api/calendar/test', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await r.json();
} catch (e) {
return { ok: false, error: 'Network error: ' + e.message };
}
};
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
2026-05-31 23:58:26 +09:00
const _setCalDavMsg = (text, ok) => {
const msg = el('uf-caldav-msg');
msg.textContent = text;
msg.style.color = ok ? 'var(--green, #50fa7b)' : 'var(--red)';
};
el('uf-caldav-save').addEventListener('click', async () => {
_setCalDavMsg('Testing…', true);
el('uf-caldav-msg').style.color = '';
const d = await _runCalDavTest();
if (!d.ok) {
_setCalDavMsg(d.error || 'Connection failed — not saved', false);
return;
}
try {
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
const payload = {
label: el('uf-caldav-label').value.trim(),
url: el('uf-caldav-url').value.trim(),
username: el('uf-caldav-user').value.trim(),
password: el('uf-caldav-pass').value,
};
let resp;
if (isNew) {
resp = await fetch('/api/calendar/config/accounts', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} else {
resp = await fetch(`/api/calendar/config/accounts/${editId}`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
_setCalDavMsg(err.detail || 'Save failed', false);
return;
}
2026-05-31 23:58:26 +09:00
_setCalDavMsg('Saved', true);
formEl.style.display = 'none';
await renderList();
notifyIntegrationsChanged();
} catch (_) {
_setCalDavMsg('Save failed', false);
}
});
feat(calendar): support multiple CalDAV accounts (#2942) * feat(calendar): support multiple CalDAV accounts Replaces the single CalDAV credential slot with a named account list so users can sync both a personal and work calendar simultaneously. - Add `account_id` column to `CalendarCal` + startup migration - `_load_caldav_accounts()` in caldav_sync.py reads `caldav_accounts` list from prefs, auto-migrating the legacy single `caldav` key on first use (no user action required) - `sync_caldav()` iterates all accounts and aggregates counts/errors - `writeback_event()` resolves credentials via `CalendarCal.account_id`, falling back to the first account for legacy rows - New REST endpoints: GET/POST/PUT/DELETE `/api/calendar/config/accounts` - Legacy GET/POST `/api/calendar/config` preserved for backward compat - Settings UI: one card per account with Label, URL, Username, Password fields; Test button works for both unsaved (inline creds) and saved (by account_id) accounts; delete removes only that account - Update test_caldav_url_hardening.py mock to include `_save_for_user` and updated `_sync_blocking` signature * fix(calendar): restore #2765 PK scoping and #2819 writeback URL validation Two regressions introduced by the multi-account refactor: 1. PK collision (#2765): _stable_cal_id was back to hashing only the URL, so two users — or one user with two accounts on the same server — would collide on the primary key. Restore owner+account_id in the hash key (format: "{owner}\n{account_id}\n{url}") and thread both values through _sync_blocking → _writeback_blocking → push_event → find_remote_calendar so the hash round-trips correctly on write-back. 2. URL validation dropped (#2819): _load_caldav_accounts imported _save_for_user at function scope, causing an ImportError on test mocks that only provide _load_for_user, which prevented writeback_event from reaching the validate_caldav_url call. Move the import inside the migration branch and wrap in try/except (best-effort save; next call re-migrates from the still-present legacy key). Update fake_writeback_blocking in test_caldav_writeback.py to accept the new owner/account_id optional params.
2026-06-05 14:32:50 -04:00
2026-05-31 23:58:26 +09:00
el('uf-caldav-test').addEventListener('click', async () => {
_setCalDavMsg('Testing…', true);
el('uf-caldav-msg').style.color = '';
const d = await _runCalDavTest();
_setCalDavMsg(d.ok ? 'Connected' : (d.error || 'Failed'), d.ok);
});
}
// ── CardDAV form + contacts manager ──
async function showCardDavForm() {
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<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>
2026-05-31 23:58:26 +09:00
<div class="settings-col">
<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>
<div class="settings-row"><label class="settings-label">Username</label><input id="uf-carddav-user" class="settings-input"></div>
<div class="settings-row"><label class="settings-label">Password</label><input id="uf-carddav-pass" class="settings-input" type="password"></div>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;justify-content:flex-end;gap:6px;">
<span id="uf-carddav-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
<button class="admin-btn-add" id="uf-carddav-save" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">
<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>
Save
</button>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<button class="admin-btn-add" id="uf-carddav-cancel" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">
<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>
Cancel
</button>
</div>
2026-05-31 23:58:26 +09:00
</div>
</div>
<div class="admin-card contacts-manager" style="margin-top:8px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<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>
2026-05-31 23:58:26 +09:00
<button class="admin-btn-sm" id="cm-import-btn" style="margin-left:auto;">Import</button>
<button class="admin-btn-sm" id="cm-export-vcf-btn">Export .vcf</button>
<button class="admin-btn-sm" id="cm-export-csv-btn">Export .csv</button>
<button class="admin-btn-sm" id="cm-add-toggle">+ Add</button>
<input type="file" id="cm-import-file" accept=".vcf,.csv,text/vcard,text/csv" multiple style="display:none">
</div>
<div id="cm-add-row" class="contacts-add-row" style="display:none;flex-direction:column;gap:4px;">
<input id="cm-add-name" class="settings-input" placeholder="Name">
<input id="cm-add-email" class="settings-input" placeholder="email@example.com">
<input id="cm-add-phone" class="settings-input" placeholder="Phone (optional)">
<input id="cm-add-address" class="settings-input" placeholder="Address (optional)">
<div style="display:flex;gap:6px;justify-content:flex-end;"><button class="admin-btn-sm" id="cm-add-save">Save</button></div>
2026-05-31 23:58:26 +09:00
</div>
<input type="text" id="cm-search" class="settings-input" placeholder="Search contacts (name, email, phone, address)" style="margin-top:6px;">
2026-05-31 23:58:26 +09:00
<div id="cm-list" class="contacts-list"><div style="opacity:0.4;font-size:11px;padding:8px 2px;">Loading…</div></div>
</div>`;
try {
const r = await fetch('/api/contacts/config', { credentials: 'same-origin' }); const d = await r.json();
el('uf-carddav-url').value = d.url || ''; el('uf-carddav-user').value = d.username || '';
// Server masks the password as '***' when one is saved (or '' when
// none). Surface that state via the input's placeholder so users
// can tell their password is already on file without us echoing it.
const passInput = el('uf-carddav-pass');
if (passInput && d.password) passInput.placeholder = '(unchanged)';
2026-05-31 23:58:26 +09:00
} catch (_) {}
el('uf-carddav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-carddav-save').addEventListener('click', async () => {
const body = { carddav_url: el('uf-carddav-url').value, carddav_username: el('uf-carddav-user').value };
if (el('uf-carddav-pass').value) body.carddav_password = el('uf-carddav-pass').value;
try {
await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
el('uf-carddav-msg').textContent = 'Saved';
el('uf-carddav-msg').style.color = 'var(--green, #50fa7b)';
// Refresh both the sub-panel (contacts manager) AND the
// outer integrations list so the CardDAV row appears
// immediately instead of waiting for a page reload.
await _renderContactsManager();
await renderList();
notifyIntegrationsChanged();
} catch (_) {
el('uf-carddav-msg').textContent = 'Failed';
el('uf-carddav-msg').style.color = 'var(--red)';
}
});
// Add-row toggle + save
el('cm-add-toggle')?.addEventListener('click', () => {
const row = el('cm-add-row');
const open = row.style.display !== 'none';
row.style.display = open ? 'none' : 'flex';
if (!open) el('cm-add-name')?.focus();
});
el('cm-add-save')?.addEventListener('click', async () => {
const name = el('cm-add-name').value.trim();
const email = el('cm-add-email').value.trim();
const phone = el('cm-add-phone')?.value.trim() || '';
const address = el('cm-add-address')?.value.trim() || '';
// Need at least a name or email; address-only entries without a
// name aren't useful as a contact.
if (!name && !email) { (name ? el('cm-add-email') : el('cm-add-name')).focus(); return; }
2026-05-31 23:58:26 +09:00
try {
await fetch('/api/contacts/add', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, phone, address }) });
2026-05-31 23:58:26 +09:00
} catch (_) {}
el('cm-add-name').value = '';
el('cm-add-email').value = '';
if (el('cm-add-phone')) el('cm-add-phone').value = '';
if (el('cm-add-address')) el('cm-add-address').value = '';
2026-05-31 23:58:26 +09:00
el('cm-add-row').style.display = 'none';
await _renderContactsManager();
});
const _downloadContacts = async (format) => {
const btn = el(format === 'csv' ? 'cm-export-csv-btn' : 'cm-export-vcf-btn');
const orig = btn ? btn.textContent : '';
if (btn) { btn.textContent = 'Exporting...'; btn.disabled = true; }
try {
const res = await fetch(`/api/contacts/export?format=${encodeURIComponent(format)}`, { credentials: 'same-origin' });
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = format === 'csv' ? 'odysseus-contacts.csv' : 'odysseus-contacts.vcf';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (_) {
uiModule.showError ? uiModule.showError('Export failed') : alert('Export failed');
} finally {
if (btn) { btn.textContent = orig; btn.disabled = false; }
}
};
el('cm-export-vcf-btn')?.addEventListener('click', () => _downloadContacts('vcf'));
el('cm-export-csv-btn')?.addEventListener('click', () => _downloadContacts('csv'));
// Import .vcf/.csv — read each selected file as text, concatenate by type,
// then POST. Imported CardDAV contacts immediately feed email autocomplete
// because compose searches /api/contacts/search.
el('cm-import-btn')?.addEventListener('click', () => el('cm-import-file')?.click());
el('cm-import-file')?.addEventListener('change', async (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
const btn = el('cm-import-btn');
const orig = btn ? btn.textContent : '';
if (btn) { btn.textContent = 'Importing…'; btn.disabled = true; }
try {
const texts = await Promise.all(files.map(f => f.text()));
const vcfParts = [];
const csvParts = [];
texts.forEach((text, idx) => {
const name = (files[idx]?.name || '').toLowerCase();
if (name.endsWith('.csv') || !String(text || '').toUpperCase().includes('BEGIN:VCARD')) csvParts.push(text);
else vcfParts.push(text);
});
let imported = 0, total = 0, failed = 0;
const _postImport = async (body) => {
const r = await fetch('/api/contacts/import', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (d.error) throw new Error(d.error);
imported += Number(d.imported || 0);
total += Number(d.total || 0);
failed += Number(d.failed || 0);
};
if (vcfParts.length) await _postImport({ vcf: vcfParts.join('\n') });
if (csvParts.length) await _postImport({ csv: csvParts.join('\n') });
if (!vcfParts.length && !csvParts.length) throw new Error('No contact data found');
const msg = `Imported ${imported}/${total}` + (failed ? ` (${failed} failed)` : '');
uiModule.showToast ? uiModule.showToast(msg) : null;
} catch (err) {
uiModule.showError ? uiModule.showError(err?.message || 'Import failed') : alert(err?.message || 'Import failed');
} finally {
if (btn) { btn.textContent = orig; btn.disabled = false; }
e.target.value = '';
await _renderContactsManager();
}
});
await _renderContactsManager();
}
// Render the contacts list inside the manager card with inline edit +
// delete. Each row: name + emails; pencil flips to editable inputs.
async function _renderContactsManager() {
const list = el('cm-list');
if (!list) return;
let contacts = [];
try {
const r = await fetch('/api/contacts/list', { credentials: 'same-origin' });
const d = await r.json();
contacts = d.contacts || [];
} catch (_) {
list.innerHTML = '<div style="opacity:0.5;font-size:11px;padding:8px 2px;">Failed to load contacts (check CardDAV config above).</div>';
return;
}
const cnt = el('cm-count');
if (cnt) cnt.textContent = contacts.length ? `(${contacts.length})` : '';
if (!contacts.length) {
list.innerHTML = '<div style="opacity:0.4;font-size:11px;padding:8px 2px;">No contacts yet.</div>';
return;
}
// Sort by name for a stable list.
contacts.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
// Live filter — search across name/emails/phones/address.
const searchInput = el('cm-search');
const q = (searchInput?.value || '').trim().toLowerCase();
const filtered = !q ? contacts : contacts.filter(c => {
const hay = [
c.name || '',
(c.emails || []).join(' '),
(c.phones || []).join(' '),
c.address || '',
].join(' ').toLowerCase();
return hay.includes(q);
});
if (cnt) cnt.textContent = contacts.length ? `(${filtered.length}/${contacts.length})` : '';
if (!filtered.length) {
list.innerHTML = `<div style="opacity:0.4;font-size:11px;padding:8px 2px;">${q ? 'No matches.' : 'No contacts yet.'}</div>`;
} else {
list.innerHTML = filtered.map(c => {
const emails = (c.emails || []).join(', ');
const phones = (c.phones || []).join(', ');
const address = c.address || '';
const sub = [emails, phones, address].filter(Boolean).join(' · ');
return `<div class="contact-row" data-uid="${esc(c.uid)}">
<div class="contact-row-view" style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;min-width:0;">
<div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div>
<div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div>
</div>
<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));">
<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>
Edit
</button>
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;">
<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>
Delete
</button>
2026-05-31 23:58:26 +09:00
</div>
<div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;">
<input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name">
<input class="settings-input contact-edit-emails" value="${esc(emails)}" placeholder="email1, email2">
<input class="settings-input contact-edit-phones" value="${esc(phones)}" placeholder="phone1, phone2">
<input class="settings-input contact-edit-address" value="${esc(address)}" placeholder="Address">
<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>
</div>
</div>`;
}).join('');
}
// Wire the search input — debounced so we don't refetch on every key.
if (searchInput && !searchInput._wired) {
searchInput._wired = true;
let _t;
searchInput.addEventListener('input', () => {
clearTimeout(_t);
_t = setTimeout(() => _renderContactsManager(), 80);
});
}
// Stash latest contacts so the search input doesn't have to refetch.
list._lastContacts = contacts;
2026-05-31 23:58:26 +09:00
// Wire each row's edit / delete / save / cancel.
list.querySelectorAll('.contact-row').forEach(row => {
const uid = row.dataset.uid;
const view = row.querySelector('.contact-row-view');
const editForm = row.querySelector('.contact-row-edit');
row.querySelector('.contact-edit')?.addEventListener('click', () => {
view.style.display = 'none';
editForm.style.display = 'flex';
});
row.querySelector('.contact-cancel')?.addEventListener('click', () => {
editForm.style.display = 'none';
view.style.display = 'flex';
});
row.querySelector('.contact-save')?.addEventListener('click', async () => {
const body = {
name: row.querySelector('.contact-edit-name').value.trim(),
emails: row.querySelector('.contact-edit-emails').value.split(',').map(s => s.trim()).filter(Boolean),
phones: row.querySelector('.contact-edit-phones').value.split(',').map(s => s.trim()).filter(Boolean),
address: row.querySelector('.contact-edit-address')?.value.trim() || '',
2026-05-31 23:58:26 +09:00
};
try {
await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch (_) {}
await _renderContactsManager();
});
row.querySelector('.contact-del')?.addEventListener('click', async () => {
const ok = uiModule.styledConfirm
? await uiModule.styledConfirm('Delete this contact?', { confirmText: 'Delete', danger: true })
: window.confirm('Delete this contact?');
if (!ok) return;
try {
await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'DELETE', credentials: 'same-origin' });
} catch (_) {}
await _renderContactsManager();
});
});
}
// ── Email form (multi-account) ──
// When editId is a real account id, edit that row. When editId is falsy or 'new',
// create a fresh account. Posts to /api/email/accounts, never to the legacy
// /api/email/config which would overwrite the default.
async function showEmailForm(editId) {
const isEdit = editId && editId !== 'new' && editId !== '__email__';
let existing = null;
if (isEdit) {
try {
const r = await fetch('/api/email/accounts', { credentials: 'same-origin' });
const d = await r.json();
existing = (d.accounts || []).find(a => a.id === editId) || null;
} catch (_) {}
}
const placeholderPass = (isEdit && existing) ? '(leave blank to keep current)' : '';
// Small `?` indicator next to each label (native title tooltip).
const _hint = (tip) =>
`<span class="uf-hint" title="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0" `
+ `style="display:inline-block;width:13px;height:13px;border-radius:50%;`
+ `border:1px solid currentColor;font-size:9px;line-height:11px;text-align:center;`
+ `opacity:0.45;margin-left:5px;cursor:help;vertical-align:1px;font-weight:600;">?</span>`;
// Provider presets — picking one auto-fills IMAP + SMTP host/port.
// Dovecot is IMAP-only here; the host is intentionally blank because
// it may be remote (DNS, LAN, Tailscale), not localhost.
const PROVIDERS = {
gmail: { label: 'Gmail', emailEx: 'you@gmail.com', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
google_workspace: { label: 'Google Workspace / .edu', emailEx: 'you@yourschool.edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' },
2026-05-31 23:58:26 +09:00
migadu: { label: 'Migadu', emailEx: 'you@yourdomain.com', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
icloud: { label: 'iCloud', emailEx: 'you@icloud.com', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
outlook: { label: 'Outlook / Office 365', emailEx: 'you@outlook.com', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
fastmail: { label: 'Fastmail', emailEx: 'you@fastmail.com', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } },
yahoo: { label: 'Yahoo', emailEx: 'you@yahoo.com', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
dovecot: { label: 'Dovecot IMAP (no SMTP)', emailEx: 'you@example.com', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } },
};
const _providerOptions = Object.entries(PROVIDERS)
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
// Provider logos — small SVGs the custom dropdown renders next to each
// option. Letter-in-brand-color circle for known providers; outline
// envelope for "Custom…". Inline SVG (no external assets, no emoji).
const _letterLogo = (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>`;
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>';
const PROV_LOGO = {
'': _customLogo,
gmail: _letterLogo('G', '#ea4335'),
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
google_workspace: _letterLogo('G', '#ea4335'),
migadu: _letterLogo('M', '#3aa39d'),
icloud: _letterLogo('i', '#3693f3'),
outlook: _letterLogo('O', '#0078d4'),
fastmail: _letterLogo('F', '#4a5fbb'),
yahoo: _letterLogo('Y', '#6001d2'),
dovecot: _letterLogo('D', '#6b7280'),
};
const _provOptionRows = [['', 'Custom…'], ...Object.entries(PROVIDERS).map(([k, v]) => [k, v.label])]
.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('');
2026-06-01 23:15:06 -05:00
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
2026-05-31 23:58:26 +09:00
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
<div class="settings-col">
<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>
<div class="ufp-wrap" style="position:relative;flex:1;min-width:0;">
<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>
<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;">
<span class="ufp-icon" style="display:inline-flex;align-items:center;">${PROV_LOGO['']}</span>
<span class="ufp-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom…</span>
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
</button>
<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>
</div>
</div>
2026-05-31 23:58:26 +09:00
<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>
<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>
<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="you@example.com"></div>
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
<div class="settings-row"><label class="settings-label">Display Name${_hint('Your name as it appears in the From: field of emails you send, e.g. Jane Smith. Auto-filled from Google during OAuth.')}</label><input id="uf-display-name" class="settings-input" placeholder="Your Name"></div>
<div id="uf-oauth-section" style="display:none;margin:8px 0;padding:10px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb,var(--accent,#50fa7b) 6%,transparent)">
<div style="font-size:11px;font-weight:600;margin-bottom:6px">Google OAuth2 — required for Workspace / .edu accounts</div>
<div id="uf-oauth-status" style="font-size:11px;opacity:0.7;margin-bottom:6px">${existing && existing.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}</div>
<button type="button" id="uf-oauth-btn" class="admin-btn-add" style="font-size:11px">${existing && existing.oauth_provider === 'google' ? 'Reconnect with Google' : 'Connect with Google'}</button>
</div>
<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>
2026-05-31 23:58:26 +09:00
<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>
<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>
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
<div class="uf-password-section"><div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password). For Migadu and Fastmail, your mailbox password usually works. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div></div>
2026-05-31 23:58:26 +09:00
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn 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>
<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>
2026-05-31 23:58:26 +09:00
<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>
<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>
2026-06-01 23:15:06 -05:00
<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>
2026-05-31 23:58:26 +09:00
<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>
<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>
<div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
2026-05-31 23:58:26 +09:00
<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>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;justify-content:flex-end;gap:6px;">
<span id="uf-email-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
<button class="admin-btn-add" id="uf-email-test" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">
2026-05-31 23:58:26 +09:00
<span class="uf-email-test-ico" style="display:inline-flex;width:11px;height:11px;align-items:center;justify-content:center;">
<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>
</span>
Test
</button>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<button class="admin-btn-add" id="uf-email-save" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">
<span class="uf-email-save-ico" style="display:inline-flex;width:11px;height:11px;align-items:center;justify-content:center;">
<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>
</span>
<span class="uf-email-save-label">${isEdit ? 'Save' : 'Create'}</span>
</button>
<button class="admin-btn-add" id="uf-email-cancel" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">
2026-05-31 23:58:26 +09:00
<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>
Cancel
</button>
</div>
</div>
</div>`;
// Provider-specific helper notes — surfaces for providers that
// require an app-specific password (Gmail killed basic IMAP auth
// in 2022; iCloud + Yahoo follow the same model). The Generate
// button opens the right page in a new tab and copies the URL for
// mobile / cross-device flows.
const PROVIDER_NOTES = {
gmail: {
title: 'Gmail needs an App Password',
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.',
url: 'https://myaccount.google.com/apppasswords',
},
icloud: {
title: 'iCloud needs an App-Specific Password',
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).',
url: 'https://account.apple.com/account/manage',
},
yahoo: {
title: 'Yahoo needs an App Password',
body: 'Generate an App Password from Yahoo Account Security (requires 2-Step Verification enabled) and paste it as the Password.',
url: 'https://login.yahoo.com/account/security/app-passwords',
},
outlook: {
title: 'Outlook / Office 365 needs OAuth',
body: 'Microsoft disables normal password login for IMAP/SMTP in most Outlook and Microsoft 365 accounts. Odysseus does not support Microsoft OAuth/Graph mail yet, so this preset is only a placeholder for future support.',
url: 'https://learn.microsoft.com/exchange/clients-and-mobile-in-exchange-online/disable-basic-authentication-in-exchange-online',
linkLabel: 'Read Microsoft note',
},
2026-05-31 23:58:26 +09:00
};
const noteEl = el('uf-email-provider-note');
const _copyProviderUrl = async (text) => {
const value = String(text || '');
if (!value) return false;
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch (_) {
// Fall through to the textarea path below.
}
}
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', 'readonly');
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;';
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, value.length);
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
ta.remove();
return ok;
};
if (noteEl && !noteEl._ufProviderCopyWired) {
noteEl._ufProviderCopyWired = true;
noteEl.addEventListener('click', async (e) => {
const copyBtn = e.target.closest?.('.uf-prov-copy');
if (!copyBtn || !noteEl.contains(copyBtn)) return;
e.preventDefault();
e.stopPropagation();
const url = copyBtn.dataset.url || '';
const orig = copyBtn.innerHTML;
const ok = await _copyProviderUrl(url);
if (!ok) {
uiModule.showError?.('Copy failed');
return;
}
uiModule.showToast?.('Copied');
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';
setTimeout(() => {
if (copyBtn.isConnected) copyBtn.innerHTML = orig;
}, 1500);
});
}
const _renderProviderNote = (key) => {
const n = PROVIDER_NOTES[key];
if (!n) { noteEl.style.display = 'none'; noteEl.innerHTML = ''; return; }
noteEl.style.display = '';
noteEl.innerHTML = `
<div style="font-weight:600;margin-bottom:3px;">${esc(n.title)}</div>
<div style="opacity:0.8;margin-bottom:6px;">${esc(n.body)}</div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<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;">
<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>
${esc(n.linkLabel || 'Generate App Password')}
2026-05-31 23:58:26 +09:00
</a>
<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;">
<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>
Copy link
</button>
</div>`;
};
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
// Show/hide the OAuth section and password fields based on provider selection.
function _syncOauthUI(providerKey) {
const p = PROVIDERS[providerKey];
const isOauth = !!(p && p.oauth);
el('uf-oauth-section').style.display = isOauth ? '' : 'none';
formEl.querySelectorAll('.uf-password-section').forEach(r => {
r.style.display = isOauth ? 'none' : '';
});
}
// Custom dropdown wire-up — the native <select> stays in the DOM as the
// data source and accessibility target, but the visible UI is a button +
// popup so each provider row can render with its SVG logo. Selecting an
// option updates select.value and dispatches a `change` event so the
// existing autofill handler below runs unchanged.
(() => {
const trigger = el('uf-email-provider-trigger');
const menu = el('uf-email-provider-menu');
const sel = el('uf-email-provider');
if (!trigger || !menu || !sel) return;
const labelEl = trigger.querySelector('.ufp-label');
const iconEl = trigger.querySelector('.ufp-icon');
const _setFromKey = (k) => {
const row = menu.querySelector(`.ufp-option[data-value="${k}"]`);
const lbl = row?.querySelector('span')?.textContent || 'Custom…';
if (labelEl) labelEl.textContent = lbl;
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
};
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
// Menu is reused (hidden, not recreated). _closeMenu hides it and tears
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
// re-registered fresh on each open (see _openMenu).
let _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => {
menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger.
const tRect = trigger.getBoundingClientRect();
const mRect = menu.getBoundingClientRect();
const below = window.innerHeight - tRect.bottom;
const above = tRect.top;
if (mRect.height > below && above > below) {
menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
} else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
}
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
};
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
btn.addEventListener('click', (e) => {
e.stopPropagation();
const k = btn.dataset.value || '';
sel.value = k;
_setFromKey(k);
_closeMenu();
sel.dispatchEvent(new Event('change', { bubbles: true }));
});
});
_setFromKey(sel.value || '');
})();
2026-05-31 23:58:26 +09:00
// Provider preset → autofill IMAP + SMTP host/port + STARTTLS, set the
// helper note, and update the Email/Username placeholders to a
// provider-specific example so users see the right format at a glance.
el('uf-email-provider').addEventListener('change', (e) => {
const key = e.target.value;
_renderProviderNote(key);
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
_syncOauthUI(key);
2026-05-31 23:58:26 +09:00
const p = PROVIDERS[key];
if (!p) return;
el('uf-imap-host').value = p.imap.host;
el('uf-imap-port').value = p.imap.port;
el('uf-imap-starttls').checked = !!p.imap.starttls;
el('uf-smtp-host').value = p.smtp.host;
el('uf-smtp-port').value = p.smtp.port;
2026-06-01 23:15:06 -05:00
el('uf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
2026-05-31 23:58:26 +09:00
if (p.emailEx) {
el('uf-email-from').placeholder = p.emailEx;
el('uf-imap-user').placeholder = p.emailEx;
el('uf-smtp-user').placeholder = p.emailEx;
}
});
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
// Init OAuth UI for accounts already connected via OAuth.
if (existing && existing.oauth_provider === 'google') _syncOauthUI('google_workspace');
// "Connect with Google" — save the account first, then redirect to OAuth.
el('uf-oauth-btn').addEventListener('click', async () => {
const body = _collectBody();
if (!body.name) body.name = body.from_address;
if (!body.name) { el('uf-email-msg').textContent = 'Enter a Name or Email first'; el('uf-email-msg').style.color = 'var(--red)'; return; }
const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts';
const method = isEdit ? 'PUT' : 'POST';
const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (!(d.ok || d.id)) { el('uf-email-msg').textContent = d.error || 'Save failed'; el('uf-email-msg').style.color = 'var(--red)'; return; }
const accId = isEdit ? editId : d.id;
window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`;
});
2026-05-31 23:58:26 +09:00
// "Same as IMAP" toggle — hide the SMTP creds rows when on.
const _syncSmtpSame = () => {
const same = el('uf-smtp-same').checked;
formEl.querySelectorAll('.uf-smtp-creds').forEach(r => {
r.style.display = same ? 'none' : '';
});
};
el('uf-smtp-same').addEventListener('change', _syncSmtpSame);
_syncSmtpSame();
if (existing) {
el('uf-email-name').value = existing.name || '';
el('uf-email-from').value = existing.from_address || '';
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
el('uf-display-name').value = existing.display_name || '';
2026-05-31 23:58:26 +09:00
el('uf-imap-host').value = existing.imap_host || '';
el('uf-imap-port').value = existing.imap_port || 993;
el('uf-imap-user').value = existing.imap_user || '';
el('uf-imap-starttls').checked = existing.imap_starttls !== false;
el('uf-smtp-host').value = existing.smtp_host || '';
el('uf-smtp-port').value = existing.smtp_port || 465;
2026-06-01 23:15:06 -05:00
el('uf-smtp-security').value = _smtpSecurity(existing);
2026-05-31 23:58:26 +09:00
el('uf-smtp-user').value = existing.smtp_user || '';
el('uf-email-default').checked = !!existing.is_default;
// If the saved SMTP user matches the IMAP user, keep the "Same as
// IMAP" toggle ON (and stay hidden). Otherwise turn it off so the
// separate SMTP credentials are visible for editing.
const sameCreds = !!(existing.imap_user && existing.smtp_user && existing.imap_user === existing.smtp_user);
el('uf-smtp-same').checked = sameCreds || !existing.smtp_user;
_syncSmtpSame();
} else {
el('uf-imap-port').value = 993;
el('uf-smtp-port').value = 465;
2026-06-01 23:15:06 -05:00
el('uf-smtp-security').value = 'ssl';
2026-05-31 23:58:26 +09:00
}
el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
// Reset the Test button to neutral when the user edits any field
// after a test — stale green/red would imply the new values were
// tested too.
const _resetTestBtn = () => {
const btn = el('uf-email-test');
if (!btn) return;
btn.style.background = '';
btn.style.borderColor = '';
btn.style.color = '';
btn.style.boxShadow = '';
btn.style.animation = '';
const ico = btn.querySelector('.uf-email-test-ico');
if (ico && btn.dataset.origIco) ico.innerHTML = btn.dataset.origIco;
};
formEl.querySelectorAll('input, select').forEach(inp => {
if (inp.id === 'uf-email-msg') return;
inp.addEventListener('input', _resetTestBtn);
inp.addEventListener('change', _resetTestBtn);
});
// Collect the current form values + apply the "Same as IMAP" mirror —
// shared by both Save and Test so they agree on what's being sent.
const _collectBody = () => {
const body = {
name: el('uf-email-name').value.trim(),
from_address: el('uf-email-from').value.trim(),
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
display_name: el('uf-display-name').value.trim(),
2026-05-31 23:58:26 +09:00
imap_host: el('uf-imap-host').value.trim(),
imap_port: parseInt(el('uf-imap-port').value) || 993,
imap_user: el('uf-imap-user').value.trim(),
imap_starttls: el('uf-imap-starttls').checked,
smtp_host: el('uf-smtp-host').value.trim(),
smtp_port: parseInt(el('uf-smtp-port').value) || 465,
2026-06-01 23:15:06 -05:00
smtp_security: el('uf-smtp-security').value,
2026-05-31 23:58:26 +09:00
smtp_user: el('uf-smtp-user').value.trim(),
is_default: el('uf-email-default').checked,
};
if (el('uf-imap-pass').value) body.imap_password = el('uf-imap-pass').value;
if (el('uf-smtp-pass').value) body.smtp_password = el('uf-smtp-pass').value;
if (el('uf-smtp-same').checked) {
body.smtp_user = body.imap_user;
if (body.imap_password) body.smtp_password = body.imap_password;
}
return body;
};
// Spinner SVG kept inline so we can swap it back to the original
// checkmark on completion. ~13px to match the button icon size.
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>';
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>';
el('uf-email-test').addEventListener('click', async () => {
const body = _collectBody();
// Edit-mode + blank password = use the saved row's stored creds
// via the account_id shortcut. Other overrides in the body still
// win (server merges).
if (isEdit && !body.imap_password) body.account_id = editId;
const msg = el('uf-email-msg');
const btn = el('uf-email-test');
const ico = btn.querySelector('.uf-email-test-ico');
btn.dataset.origIco = btn.dataset.origIco || ico.innerHTML;
btn.disabled = true;
// Clear any prior green/red while testing.
btn.style.background = '';
btn.style.borderColor = '';
btn.style.color = '';
btn.style.boxShadow = '';
btn.style.animation = '';
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Use the canonical whirlpool spinner so this matches Probe / Test
// elsewhere; fall back to the inline CSS ring if the module fails.
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
const wp = sp.createWhirlpool(11);
wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;position:relative;top:-2px;';
ico.innerHTML = '';
ico.appendChild(wp.element);
} catch (_) { ico.innerHTML = _spinner; }
2026-05-31 23:58:26 +09:00
msg.textContent = '';
msg.style.color = '';
try {
const r = await fetch('/api/email/accounts/test', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (d.ok) {
// Button becomes the indicator — green checkmark with the
// cookbook-style halo + breathing animation. No status text;
// the glow is the signal.
btn.style.background = 'var(--green, #50fa7b)';
btn.style.borderColor = 'var(--green, #50fa7b)';
btn.style.color = '#0b0';
btn.style.boxShadow =
'0 0 0 2px color-mix(in srgb, var(--green, #50fa7b) 25%, transparent),'
+ ' 0 0 10px 2px color-mix(in srgb, var(--green, #50fa7b) 55%, transparent)';
btn.style.animation = 'cookbook-srv-glow-ok 2.4s ease-in-out infinite';
ico.innerHTML = _checkIcon;
} else {
// Failure — red glow, original icon, error detail in status
// text so we can say WHICH half failed (IMAP vs SMTP).
btn.style.background = 'var(--red)';
btn.style.borderColor = 'var(--red)';
btn.style.color = '#fff';
btn.style.boxShadow =
'0 0 0 2px color-mix(in srgb, var(--red) 25%, transparent),'
+ ' 0 0 10px 2px color-mix(in srgb, var(--red) 55%, transparent)';
ico.innerHTML = btn.dataset.origIco;
const imap = d.imap?.ok ? 'IMAP ok' : `IMAP: ${d.imap?.error || 'fail'}`;
const smtp = d.smtp ? (d.smtp.ok ? ' · SMTP ok' : ` · SMTP: ${d.smtp.error || 'fail'}`) : '';
msg.textContent = imap + smtp;
msg.style.color = 'var(--red)';
}
} catch (e) {
btn.style.background = 'var(--red)';
btn.style.borderColor = 'var(--red)';
btn.style.color = '#fff';
ico.innerHTML = btn.dataset.origIco;
msg.textContent = 'Test error: ' + e.message;
msg.style.color = 'var(--red)';
} finally {
btn.disabled = false;
}
});
el('uf-email-save').addEventListener('click', async () => {
const body = _collectBody();
// Name is optional — fall back to Email so the list still has a label.
if (!body.name) body.name = body.from_address;
if (!body.name) { el('uf-email-msg').textContent = 'Need at least a Name or Email'; el('uf-email-msg').style.color = 'var(--red)'; return; }
const saveBtn = el('uf-email-save');
saveBtn.disabled = true;
const saveIcoEl = saveBtn.querySelector('.uf-email-save-ico');
const saveLblEl = saveBtn.querySelector('.uf-email-save-label');
const prevIco = saveIcoEl.innerHTML;
const prevLbl = saveLblEl.textContent;
saveIcoEl.innerHTML = _spinner;
saveLblEl.textContent = 'Saving…';
try {
const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts';
const method = isEdit ? 'PUT' : 'POST';
const r = await fetch(url, {
method, credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (!(d.ok || d.id)) {
el('uf-email-msg').textContent = d.error || 'Failed';
el('uf-email-msg').style.color = 'var(--red)';
return;
}
el('uf-email-msg').textContent = 'Saved';
el('uf-email-msg').style.color = 'var(--green,#50fa7b)';
integrationNotice = 'Email account saved. For more settings, go to Settings > Email.';
2026-05-31 23:58:26 +09:00
formEl.style.display = 'none';
await renderList();
notifyIntegrationsChanged();
} catch (e) {
el('uf-email-msg').textContent = 'Error: ' + e.message;
el('uf-email-msg').style.color = 'var(--red)';
} finally {
saveBtn.disabled = false;
saveIcoEl.innerHTML = prevIco;
saveLblEl.textContent = prevLbl;
}
});
}
// ── Vaultwarden form ──
async function showVaultForm() {
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">Vaultwarden (Password Vault)</h2>
<div id="uf-vault-status" style="font-size:11px;opacity:0.7;margin-bottom:8px">Loading...</div>
<div class="settings-col">
<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>
<div class="settings-row"><label class="settings-label">Email</label><input id="uf-vault-email" class="settings-input" placeholder="you@example.com"></div>
<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>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap;">
<span id="uf-vault-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
<button class="admin-btn-add" id="uf-vault-save" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">Save Config</button>
<button class="admin-btn-add" id="uf-vault-login" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Login</button>
<button class="admin-btn-add" id="uf-vault-unlock" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Unlock</button>
<button class="admin-btn-add" id="uf-vault-lock" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Lock</button>
<button class="admin-btn-add" id="uf-vault-logout" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Logout</button>
<button class="admin-btn-add" id="uf-vault-cancel" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Cancel</button>
2026-05-31 23:58:26 +09:00
</div>
<div style="font-size:10px;opacity:0.5;margin-top:6px;line-height:1.4">
<strong>Login</strong> registers this device with your Vaultwarden account (once per account).<br>
<strong>Unlock</strong> decrypts the vault — required after restart or Lock. Session is saved so the assistant can read passwords.
</div>
</div>
</div>`;
const msg = (text, color) => {
const m = el('uf-vault-msg');
m.textContent = text || '';
m.style.color = color || '';
};
async function refreshStatus() {
try {
const r = await fetch('/api/vault/config', { credentials: 'same-origin' });
const d = await r.json();
el('uf-vault-url').value = d.server_url || '';
el('uf-vault-email').value = d.email || '';
const installed = d.bw_installed;
const parts = [];
parts.push(installed ? 'bw CLI: installed' : 'bw CLI: NOT installed (install nodejs-bitwarden-cli)');
parts.push(d.unlocked ? 'Status: UNLOCKED' : 'Status: locked');
if (d.unlocked_at) parts.push(`Last unlock: ${d.unlocked_at.replace('T',' ').slice(0,19)}`);
const statusEl = el('uf-vault-status');
statusEl.textContent = parts.join(' — ');
statusEl.style.color = !installed ? 'var(--red)' : d.unlocked ? 'var(--green,#50fa7b)' : '';
} catch (_) {
el('uf-vault-status').textContent = 'Failed to load vault status';
}
}
await refreshStatus();
el('uf-vault-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-vault-save').addEventListener('click', async () => {
msg('Saving...');
try {
const r = await fetch('/api/vault/config', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_url: el('uf-vault-url').value, email: el('uf-vault-email').value }),
});
const d = await r.json();
if (d.ok) { msg('Saved', 'var(--green,#50fa7b)'); await refreshStatus(); await renderList(); }
else msg(d.error || 'Failed', 'var(--red)');
} catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
});
el('uf-vault-login').addEventListener('click', async () => {
const email = el('uf-vault-email').value.trim();
const pass = el('uf-vault-pass').value;
if (!email || !pass) { msg('Email + master password required', 'var(--red)'); return; }
msg('Logging in...');
try {
const r = await fetch('/api/vault/login', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, master_password: pass }),
});
const d = await r.json();
if (d.ok) {
msg(d.already ? 'Already logged in — use Unlock' : 'Logged in', 'var(--green,#50fa7b)');
el('uf-vault-pass').value = '';
await refreshStatus(); await renderList();
} else msg(d.error || 'Login failed', 'var(--red)');
} catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
});
el('uf-vault-unlock').addEventListener('click', async () => {
const pass = el('uf-vault-pass').value;
if (!pass) { msg('Master password required', 'var(--red)'); return; }
msg('Unlocking...');
try {
const r = await fetch('/api/vault/unlock', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ master_password: pass }),
});
const d = await r.json();
if (d.ok) {
msg('Vault unlocked', 'var(--green,#50fa7b)');
el('uf-vault-pass').value = '';
await refreshStatus(); await renderList();
} else msg(d.error || 'Unlock failed', 'var(--red)');
} catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
});
el('uf-vault-lock').addEventListener('click', async () => {
msg('Locking...');
try {
await fetch('/api/vault/lock', { method: 'POST', credentials: 'same-origin' });
msg('Locked', 'var(--green,#50fa7b)');
await refreshStatus(); await renderList();
} catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
});
el('uf-vault-logout').addEventListener('click', async () => {
if (!await window.styledConfirm('Log out of Bitwarden CLI? You\'ll need to re-enter your master password to log back in.', { confirmText: 'Log out' })) return;
msg('Logging out...');
try {
await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
msg('Logged out', 'var(--green,#50fa7b)');
await refreshStatus(); await renderList();
} catch (e) { msg('Error: ' + e.message, 'var(--red)'); }
});
}
// ── MCP form — full management view ──
async function showMcpForm(editId) {
feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033) * feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
2026-06-05 05:40:52 +05:00
// Toggle an in-flight loading state on a button (disabled + dimmed + label).
function _setBtnLoading(btn, loading, label) {
if (!btn) return;
btn.disabled = loading;
btn.style.opacity = loading ? '0.6' : '';
btn.style.cursor = loading ? 'progress' : '';
if (label != null) btn.textContent = label;
}
function _showMcpPasteback(id) {
const msg = el('uf-mcp-msg'); if (!msg) return;
if (el('uf-mcp-pasteback')) return; // already shown
msg.innerHTML =
'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' +
'<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' +
'<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>';
const pasteGo = el('uf-mcp-paste-go');
if (pasteGo) pasteGo.addEventListener('click', async () => {
const cb = el('uf-mcp-pasteback').value.trim();
if (!cb) return;
const pf = new FormData(); pf.append('callback_url', cb);
_setBtnLoading(pasteGo, true, 'Submitting…');
try {
await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf });
} finally {
_setBtnLoading(pasteGo, false, 'Submit');
}
});
}
// Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag),
// opens it once, then resolves on connected/error.
async function _handleMcpAuth(id, initialAuthUrl, tries = 90) {
let opened = false;
const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } };
openAuth(initialAuthUrl);
const msg = el('uf-mcp-msg');
let fails = 0;
for (let i = 0; i < tries; i++) {
await new Promise(res => setTimeout(res, 2000));
try {
const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const list = await r.json();
fails = 0;
const s = Array.isArray(list) ? list.find(x => x.id === id) : null;
if (!s) continue;
if (s.auth_url) openAuth(s.auth_url);
if (s.status === 'connected') {
if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`;
await renderList(); return;
}
if (s.status === 'error') {
if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return;
}
} catch (e) {
// Tolerate a single blip, but surface persistent failures instead of
// silently polling until timeout.
if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) — still retrying…`;
}
}
if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.';
}
2026-05-31 23:58:26 +09:00
if (editId && editId !== 'new') {
// Show management view for existing server
formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>';
try {
const res = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
const servers = await res.json();
const srv = servers.find(s => (s.id || s.name) === editId);
if (!srv) { formEl.innerHTML = '<div class="admin-card" style="margin-top:8px">Server not found</div>'; return; }
const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');
const statusColor = srv.needs_oauth ? '#e5a33a' : srv.status === 'connected' ? 'var(--green,#50fa7b)' : srv.status === 'error' ? 'var(--red)' : 'var(--fg)';
const toolInfo = srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : '';
const statusText = srv.needs_oauth ? 'Needs authorization' : srv.status === 'connected' ? `Connected (${toolInfo})` : srv.status === 'error' ? `Error: ${esc(srv.error || 'unknown')}` : 'Disconnected';
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${esc(srv.name)}</h2>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${statusColor}"></span>
<span style="font-size:11px;opacity:0.7">${statusText}</span>
</div>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:8px;justify-content:flex-end;">
<span id="uf-mcp-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
${srv.needs_oauth ? `<a href="/api/mcp/oauth/authorize/${srv.id}" target="_blank" class="admin-btn-add" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));text-decoration:none;font-weight:600;">Authorize</a>` : ''}
<button class="admin-btn-add" id="uf-mcp-reconnect" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Reconnect</button>
<button class="admin-btn-add" id="uf-mcp-toggle" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">${srv.is_enabled ? 'Disable' : 'Enable'}</button>
<button class="admin-btn-add" id="uf-mcp-cancel" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Close</button>
2026-05-31 23:58:26 +09:00
</div>
<div id="uf-mcp-tools-panel"></div>
</div>`;
// Reconnect
el('uf-mcp-reconnect').addEventListener('click', async () => {
const msg = el('uf-mcp-msg'); msg.textContent = 'Reconnecting...';
try {
const r = await fetch(`/api/mcp/servers/${srv.id}/reconnect`, { method: 'POST', credentials: 'same-origin' });
const d = await r.json();
msg.textContent = d.connected ? `Connected (${d.tool_count} tools)` : `Failed: ${d.error || 'unknown'}`;
await renderList();
showMcpForm(editId); // refresh this view
} catch (e) { msg.textContent = 'Failed'; }
});
// Toggle enable/disable
el('uf-mcp-toggle').addEventListener('click', async () => {
const fd = new FormData(); fd.append('is_enabled', String(!srv.is_enabled));
await fetch(`/api/mcp/servers/${srv.id}`, { method: 'PATCH', body: fd, credentials: 'same-origin' });
await renderList();
showMcpForm(editId);
});
el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
// Load tools list
if (srv.status === 'connected' && srv.tool_count > 0) {
const panel = el('uf-mcp-tools-panel');
try {
const tr = await fetch(`/api/mcp/servers/${srv.id}/tools`, { credentials: 'same-origin' });
const tools = await tr.json();
if (tools.length) {
const disabled = new Set(tools.filter(t => t.is_disabled).map(t => t.name));
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' : ''}><span><strong>${esc(t.name)}</strong> <span style="opacity:0.5">— ${esc((t.description||'').slice(0,80))}</span></span></label>`).join('')}</div>`;
const saveFn = async () => {
const dis = [];
panel.querySelectorAll('input[type=checkbox]').forEach(cb => { if (!cb.checked) dis.push(cb.dataset.mcpToolName); });
await fetch(`/api/mcp/servers/${srv.id}/tools`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ disabled: dis }) });
const cnt = panel.querySelector('.mcp-tools-count');
if (cnt) cnt.textContent = `${tools.length - dis.length}/${tools.length} enabled`;
};
panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.addEventListener('change', saveFn));
el('uf-mcp-all')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = true); saveFn(); });
el('uf-mcp-none')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false); saveFn(); });
}
} catch (_) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px">Failed to load tools</span>'; }
}
} catch (_) { formEl.innerHTML = '<div class="admin-card" style="margin-top:8px">Failed to load server</div>'; }
} else {
// Add new MCP server form
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">Add MCP Server</h2>
<div class="settings-col">
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div>
feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033) * feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
2026-06-05 05:40:52 +05:00
<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>
2026-05-31 23:58:26 +09:00
<div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;">
<div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div>
<div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div>
<div class="settings-row"><label class="settings-label">Env</label><input id="uf-mcp-env" class="settings-input" placeholder='{"KEY": "value"}'></div>
</div>
<div id="uf-mcp-sse-fields" style="display:none;flex-direction:column;gap:6px;">
<div class="settings-row"><label class="settings-label">URL</label><input id="uf-mcp-url" class="settings-input" placeholder="http://localhost:3001/sse"></div>
</div>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;justify-content:flex-end;gap:6px;">
<span id="uf-mcp-msg" style="font-size:11px;flex:1;margin-right:8px"></span>
<button class="admin-btn-add" id="uf-mcp-save" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">Save</button>
<button class="admin-btn-add" id="uf-mcp-cancel" style="background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">Cancel</button>
</div>
2026-05-31 23:58:26 +09:00
</div>
</div>`;
el('uf-mcp-transport').addEventListener('change', () => {
feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033) * feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
2026-06-05 05:40:52 +05:00
const v = el('uf-mcp-transport').value;
const isUrl = (v === 'sse' || v === 'http');
el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex';
el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none';
const urlInput = el('uf-mcp-url');
if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse';
2026-05-31 23:58:26 +09:00
});
el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-mcp-save').addEventListener('click', async () => {
const transport = el('uf-mcp-transport').value;
// routes/mcp_routes.py uses FastAPI Form(...) — send multipart, not JSON.
const fd = new FormData();
fd.append('name', el('uf-mcp-name').value);
fd.append('transport', transport);
2026-05-31 23:58:26 +09:00
if (transport === 'stdio') {
fd.append('command', el('uf-mcp-cmd').value);
let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {}
let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {}
fd.append('args', args);
fd.append('env', env);
2026-05-31 23:58:26 +09:00
} else {
fd.append('url', el('uf-mcp-url').value);
2026-05-31 23:58:26 +09:00
}
feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033) * feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
2026-06-05 05:40:52 +05:00
const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel');
const _origLabel = saveBtn.textContent;
_setBtnLoading(saveBtn, true, 'Saving…'); if (cancelBtn) cancelBtn.disabled = true;
2026-05-31 23:58:26 +09:00
try {
const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd });
feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033) * feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
2026-06-05 05:40:52 +05:00
const data = await r.json().catch(() => ({}));
if (r.ok && data.needs_auth) {
el('uf-mcp-msg').textContent = 'Preparing authorization…';
_handleMcpAuth(data.id, data.auth_url);
} else if (r.ok && (data.connected || data.status === 'connected')) {
el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`;
formEl.style.display = 'none'; await renderList();
} else if (r.ok) {
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
} else {
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
}
2026-05-31 23:58:26 +09:00
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
feat(mcp): add Streamable HTTP transport with OAuth 2.0 (#1033) * feat(mcp): add Streamable HTTP transport with OAuth 2.0 Odysseus could only reach MCP servers over stdio and SSE, so modern remote servers like https://mcp.higgsfield.ai/mcp (Streamable HTTP, gated behind OAuth) could not be connected. Add an `http` transport that connects via the SDK's streamablehttp_client and authenticates with the SDK's OAuthClientProvider: RFC 9728 protected-resource discovery, RFC 8414 authorization-server metadata, Dynamic Client Registration, authorization-code + PKCE, and token refresh. A small bridge (src/mcp_oauth.py) connects the SDK's blocking callback to the existing web callback route via an asyncio.Future keyed by the OAuth `state`, and the dynamic client registration plus tokens persist per-server in a new encrypted `oauth_tokens` column. The connect runs as a bounded background task so the "Add server" request returns immediately; redirect_handler publishes needs_auth + auth_url to connection state as soon as discovery/DCR completes (which can exceed the bounded wait), and the UI polls until connected. Remote users finish via the existing paste-back flow. The Google OAuth path is left unchanged. - core/database.py: encrypted oauth_tokens column + migration - src/mcp_oauth.py: OAuth provider, DB-backed TokenStorage, state registry - src/mcp_manager.py: http dispatch, background connect, _connect_http - routes/mcp_routes.py: http validation, needs_auth/auth_url, callback bridge - static/js/settings.js: Streamable HTTP option + OAuth flow with polling - tests: 5 new unit tests (transport dispatch, registry, token storage) Verified against the live Higgsfield server: discovery, DCR (client_id issued), loopback redirect accepted, and a PKCE authorization URL with needs_auth status. No regressions (full suite delta is only the 5 added passing tests). * fix(mcp): address PR #1033 review feedback - mcp_oauth: derive redirect URI from OAUTH_REDIRECT_BASE_URL/APP_PUBLIC_URL (default http://localhost:7000) instead of hardcoding the port - mcp_oauth: leave OAuth scope unset so the SDK derives it from the server's WWW-Authenticate/protected-resource metadata; hardcoding an OIDC scope broke non-OpenID MCP servers (verified: Higgsfield still gets its server-derived scope) - mcp_oauth: prune abandoned OAuth flows (_prune_stale + _pending_ts) so the module-level registries can't grow unbounded - mcp_oauth: persist tokens/client-info in a single DB session/commit (_update) instead of a load+save double round-trip - mcp_manager: cancel and drop the background connect task in disconnect_server so a deleted server stops publishing status - database: document why the oauth_tokens migration uses TEXT while the model declares EncryptedText (encryption is applied at the Python layer) - settings.js: surface persistent OAuth-poll failures and an explicit timeout message instead of silently swallowing errors - tests: cover the stale-flow pruning * static/js/settings.js now shows an in-flight loading state on the buttons that fire requests:
2026-06-05 05:40:52 +05:00
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
2026-05-31 23:58:26 +09:00
});
}
}
async function showAgentForm(kind, editId) {
const cfg = AGENT_CONFIGS[kind] || AGENT_CONFIGS.codex;
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
let tokens = [];
try {
const tokRes = await fetch('/api/tokens', { credentials: 'same-origin' });
if (tokRes.ok) tokens = await tokRes.json();
} catch (_) {}
const toolScopes = [
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
{ key: 'documents:read', label: 'Documents', detail: 'Read documents when a document API is enabled' },
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
{ key: 'email:read', label: 'Email', detail: 'Read email when an email API is enabled' },
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts without sending' },
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events when enabled' },
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
{ key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' },
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner: * Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file. * `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400. * `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt). Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll. `serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes. Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop. Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles. `adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd. Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
{ key: 'cookbook:read', label: 'Cookbook', detail: 'List cookbook tasks + tail their tmux output (debug a model serve from outside the UI)' },
{ 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/...)' },
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
];
// Strict name-prefix match keeps Codex and Claude tokens in their own forms.
const agentTokens = (Array.isArray(tokens) ? tokens : []).filter(tok =>
(tok.name || '').toLowerCase().startsWith(cfg.namePrefix)
);
const current = agentTokens.find(t => String(t.id) === String(editId));
const _scopeIcons = {
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>',
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>',
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>',
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>',
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>',
cookbook agent debug loop: persistent log files, auto-adopt orphan tmux, Codex/Claude skill parity Three converging fixes so the chat agent + external Codex/Claude skills can actually debug a crashed serve instead of staring at a post-crash neofetch banner: * Serves now `tee` to /tmp/odysseus-tmux/SESSION.log on the host running them. Runner saves fds 3/4 before the tee and restores them right before `exec ${SHELL}`, so the post-crash interactive zsh banner does NOT pollute the log file. * `tail_serve_output` (chat agent) and `/api/codex/cookbook/output/{sid}` (Codex+Claude skills) both prefer the persistent log file over the tmux pane. Pane is fallback for sessions predating the tee runner. Default tail bumped 150 -> 400. * `list_served_models` "recent log" snippet seeks to the Traceback line instead of showing the last 6 lines (which was always the bash prompt). Cookbook auto-adoption sweep on `/api/cookbook/tasks/status`: every 20s (rate-limited) the cookbook SSHes each configured server, finds `serve-*` / `cookbook-*` tmux sessions running an actual model process (vllm/python/llama-server/etc., filtered via `pane_current_command`), and writes them into state.tasks. So when the agent falls back to raw ssh+tmux, the session appears in the Cookbook UI on the next poll. `serve_model` error path now reads `data["detail"]` in addition to `data["error"]` so the FastAPI HTTPException message ("Invalid characters in cmd") actually reaches the agent instead of being swallowed as a generic "Serve failed". Tool description updated to warn against `cd …`/`source …`/`&&` prefixes. Intent-without-action supervisor in agent_loop: when the model writes "Let me tail the output" / "I'll check the logs" / "Let me investigate" and ends the turn without emitting a tool call, the loop injects a sharp system nudge ("You said you would X — DO IT NOW") and continues. Capped at 2 nudges per chat so a model that genuinely cannot use the tool does not pin the loop. Codex/Claude skill parity: adds `/cookbook/cached`, `/cookbook/presets`, `/cookbook/preset/{name}`, `/cookbook/adopt` so external agents have the same surface as the chat agent. SKILL.md docs + odysseus_api.py wrapper updated for both bundles. `adopt_served_model` promoted to the always-on tool set so the agent has a documented fallback when serve_model rejects a cmd. Also various cookbook UI tweaks accumulated alongside the above (cookbook.js, cookbookRunning.js, cookbookServe.js, cookbook-diagnosis.js, settings.js, style.css).
2026-06-04 23:27:18 +09:00
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>',
};
const _scopeNiceLabel = (label) => label.replace(/\s+(write|drafts?|send)$/i, '');
const _scopeAction = (key) => (key.split(':')[1] || '').toLowerCase();
const _pillStyle = (action) => {
if (action === 'read') return 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);';
return 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
};
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const scopeToggles = (t) => {
const scopes = new Set(t.scopes || []);
return toolScopes.map(scope => {
const tool = scope.key.split(':')[0];
const action = _scopeAction(scope.key);
const icon = _scopeIcons[tool] || '';
const niceLabel = _scopeNiceLabel(scope.label);
return `
<label class="settings-row" style="align-items:center;gap:8px;display:flex;min-height:30px;padding:2px 0;">
<span style="opacity:0.7;display:inline-flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;">${icon}</span>
<span class="settings-label" style="width:75px;flex-shrink:0;padding:0;">${esc(niceLabel)}</span>
<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>
<span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(scope.detail)}</span>
<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' : ''}><span class="admin-slider"></span></label>
</label>`;
}).join('');
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
};
const origin = window.location.origin || '';
const setupForToken = (token) => cfg.buildSetup(origin, token);
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Inline editor for the existing token the user clicked into (current).
// Shows the rename input, the prefix/last-used, and scope toggles that
// PATCH /api/tokens/{id} on change. The integration row's trash button
// handles revoke, so no Revoke button in here.
const editExistingHtml = current ? `
<div style="border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-bottom:8px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<input type="text" id="uf-codex-existing-rename" data-token-id="${esc(current.id)}" value="${esc(current.name || cfg.defaultName)}" style="font-size:12px;font-weight:600;padding:3px 6px;flex:1;background:transparent;border:1px solid transparent;border-radius:4px;" title="Click to rename">
<span style="font-size:10px;opacity:0.55;">${esc(current.token_prefix || 'token')}...${current.last_used_at ? ` · Last used ${new Date(current.last_used_at).toLocaleDateString()}` : ' · Never used'}</span>
</div>
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;">Permissions</div>
${scopeToggles(current)}
<div id="uf-codex-existing-msg" style="font-size:11px;min-height:14px;margin-top:4px;"></div>
</div>` : '';
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<div class="settings-col">
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
${editExistingHtml}
<div id="uf-codex-prompt" style="display:${current ? 'none' : 'block'};padding:6px 0;">
<div style="font-size:11px;opacity:0.7;margin-bottom:6px;">Name this ${esc(cfg.word)} agent so you can tell it apart from other ones (e.g. "${esc(cfg.defaultName)} — laptop").</div>
<input type="text" id="uf-codex-name-input" class="settings-select" placeholder="${esc(cfg.defaultName)}" style="width:100%;font-size:12px;padding:6px 8px;">
</div>
<div id="uf-codex-pending" style="display:none;align-items:center;gap:8px;padding:6px 0;font-size:11px;opacity:0.7;"></div>
<div id="uf-codex-reveal" style="display:none;width:100%;box-sizing:border-box;">
<div style="font-weight:600;font-size:12px;margin-bottom:6px;">Token</div>
<div style="font-size:11px;opacity:0.62;margin-bottom:4px;">Copy this token, it won't be shown again.</div>
<div style="position:relative;">
<code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;padding:6px 30px 6px 8px;background:rgba(0,0,0,0.08);border-radius:4px;"></code>
<button type="button" class="admin-btn-sm" id="uf-codex-copy-token" title="Copy token" aria-label="Copy token" style="position:absolute;right:4px;top:50%;transform:translateY(-50%);padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
</button>
</div>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Quickstart &mdash; simply paste directly in your terminal.</div>
<div style="font-size:11px;opacity:0.62;margin-bottom:6px;">${cfg.setupDescription}</div>
<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>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div style="margin-top:14px;display:flex;align-items:center;gap:8px;">
<span style="font-weight:600;font-size:11px;">Configure access</span>
<span style="flex:1"></span>
<button type="button" class="admin-btn-sm" id="uf-codex-copy-setup" title="Copy setup" aria-label="Copy setup" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:5px;opacity:0.85;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
<span>Copy</span>
</button>
<button type="button" class="admin-btn-sm" id="uf-codex-toggle-config" aria-expanded="false" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:5px;opacity:0.85;">
<svg id="uf-codex-toggle-config-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg>
<span>Configure</span>
</button>
</div>
<div id="uf-codex-config-body" style="display:none;">
<div style="font-size:11px;opacity:0.62;margin:4px 0 6px;">Toggle which Odysseus tools this agent can use. New agents start with chat only.</div>
<div id="uf-codex-inline-scopes"></div>
</div>
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
</div>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="settings-row" style="margin-top:10px;align-items:center;gap:6px;">
<button class="admin-btn-add" id="uf-codex-cancel" style="display:inline-flex;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">
<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>
Cancel
</button>
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<span id="uf-codex-msg" style="font-size:11px;flex:1;text-align:center;"></span>
<button class="admin-btn-add" id="uf-codex-revoke" style="display:none;align-items:center;gap:5px;background:color-mix(in srgb, var(--color-error) 10%, transparent);color:var(--color-error);border:1px solid var(--color-error);font-weight:600;">
<svg width="11" height="11" 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="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Revoke
</button>
<button class="admin-btn-add" id="uf-codex-create-btn" style="display:${current ? 'none' : 'inline-flex'};align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));">
<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"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>
Create token
</button>
<button class="admin-btn-add" id="uf-codex-save" style="display:none;align-items:center;gap:5px;background:transparent;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 45%, var(--border));font-weight:600;">
<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>
Save
</button>
</div>
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
</div>
</div>`;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Editing an existing token: surface Revoke alongside Cancel, and stash
// the id so the Revoke handler knows what to DELETE.
if (current) {
formEl.dataset.createdTokenId = String(current.id);
const revokeBtn = el('uf-codex-revoke');
if (revokeBtn) revokeBtn.style.display = 'inline-flex';
// Inline rename + per-scope PATCH on change.
const renameInput = el('uf-codex-existing-rename');
if (renameInput) {
const original = renameInput.value;
const commit = async () => {
const name = (renameInput.value || '').trim();
if (!name || name === original) return;
try {
const r = await fetch(`/api/tokens/${renameInput.dataset.tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) throw new Error('Save failed');
notifyIntegrationsChanged();
} catch (_) { renameInput.value = original; }
};
renameInput.addEventListener('blur', commit);
renameInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); renameInput.blur(); } });
}
formEl.querySelectorAll('.uf-codex-scope').forEach(cb => {
cb.addEventListener('change', async () => {
const msg = el('uf-codex-existing-msg');
const scopes = ['chat'].concat(
Array.from(formEl.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope)
);
try {
const r = await fetch(`/api/tokens/${cb.dataset.tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; setTimeout(() => { msg.textContent = ''; }, 1200); }
notifyIntegrationsChanged();
} catch (err) {
cb.checked = !cb.checked;
if (msg) { msg.textContent = (err && err.message) || 'Failed'; msg.style.color = 'var(--red)'; }
}
});
});
}
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
el('uf-codex-cancel')?.addEventListener('click', () => { formEl.style.display = 'none'; });
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Configure access — collapsed by default so the reveal panel doesn't
// dump 13 toggles at once. Click reveals + rotates the caret.
el('uf-codex-toggle-config')?.addEventListener('click', () => {
const body = el('uf-codex-config-body');
const btn = el('uf-codex-toggle-config');
const caret = el('uf-codex-toggle-config-caret');
if (!body || !btn) return;
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
});
el('uf-codex-save')?.addEventListener('click', async () => {
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const msg = el('uf-codex-msg');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
const tokenId = formEl.dataset.createdTokenId;
if (!tokenId) { formEl.style.display = 'none'; return; }
const scopes = ['chat'].concat(
Array.from(formEl.querySelectorAll('#uf-codex-inline-scopes .uf-codex-scope:checked'))
.map(input => input.dataset.scope)
);
try {
const r = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
await renderList();
setTimeout(() => { formEl.style.display = 'none'; }, 350);
} catch (err) {
if (msg) { msg.textContent = err?.message || 'Save failed'; msg.style.color = 'var(--red)'; }
}
});
// Revoke = delete this agent token entirely. Confirmation prompt keeps
// it from being a one-click footgun. Closes the form on success.
el('uf-codex-revoke')?.addEventListener('click', async () => {
const tokenId = formEl.dataset.createdTokenId;
if (!tokenId) return;
const ok = window.styledConfirm
? await window.styledConfirm(`Revoke this ${cfg.word} agent token? Integrations using it will lose access.`, { confirmText: 'Revoke', danger: true })
: confirm(`Revoke this ${cfg.word} agent token? Integrations using it will lose access.`);
if (!ok) return;
const msg = el('uf-codex-msg');
try {
const r = await fetch(`/api/tokens/${tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) throw new Error('Revoke failed');
if (msg) { msg.textContent = 'Revoked'; msg.style.color = 'var(--color-error)'; }
await renderList();
setTimeout(() => { formEl.style.display = 'none'; }, 350);
} catch (err) {
if (msg) { msg.textContent = err?.message || 'Revoke failed'; msg.style.color = 'var(--red)'; }
}
});
const _autoCreateCodex = async () => {
const msg = el('uf-codex-msg');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
const prompt = el('uf-codex-prompt');
const pending = el('uf-codex-pending');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
const createBtn = el('uf-codex-create-btn');
if (prompt) prompt.style.display = 'none';
if (createBtn) createBtn.style.display = 'none';
// Whirlpool spinner while the POST is in flight.
let _wp = null;
if (pending) {
pending.innerHTML = '';
pending.style.display = 'flex';
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
_wp = sp.createWhirlpool(14);
_wp.element.style.cssText = 'display:inline-flex;width:14px;height:14px;margin:0 4px 0 0;';
pending.appendChild(_wp.element);
pending.appendChild(document.createTextNode('Creating token…'));
} catch (_) {
pending.textContent = 'Creating token…';
}
}
const existingNames = new Set(agentTokens.map(t => (t.name || '').trim()));
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
const nameInput = el('uf-codex-name-input');
// User-typed name wins. Empty / whitespace falls back to the default,
// auto-suffixed with " 2", " 3"… so two tokens never collide.
let name = (nameInput && nameInput.value || '').trim() || cfg.defaultName;
if (existingNames.has(name)) {
let n = 2;
const base = name;
while (existingNames.has(name)) { name = `${base} ${n++}`; }
}
// Minimum scope on creation so the token isn't effectively saved
// with everything granted before the user has clicked Save. The
// UI toggles below are pre-checked as a preview of what *will*
// be granted; nothing else is persisted server-side until Save.
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const fd = new FormData();
fd.append('name', name);
fd.append('scopes', 'chat');
try {
const r = await fetch('/api/tokens', { method: 'POST', credentials: 'same-origin', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (_wp) { try { _wp.destroy(); } catch (_) {} }
if (pending) pending.style.display = 'none';
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
el('uf-codex-token').textContent = d.token || '';
el('uf-codex-reveal').style.display = '';
const setupBtn = el('uf-codex-copy-setup');
if (setupBtn) setupBtn.dataset.token = d.token || '';
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const setupCode = el('uf-codex-setup-code');
if (setupCode) setupCode.textContent = setupForToken(d.token || '');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Populate inline scope toggles for the just-created token with
// ALL scopes pre-checked as a UI preview — the underlying token
// still only has 'chat' until the user clicks Save below.
const uiToken = { id: d.id, scopes: ['chat'].concat(toolScopes.map(s => s.key)) };
const inlineEl = el('uf-codex-inline-scopes');
if (inlineEl) {
inlineEl.innerHTML = `
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
<div class="uf-codex-token" data-token-id="${esc(uiToken.id)}">
${scopeToggles(uiToken)}
<div class="uf-codex-scope-msg" data-token-id="${esc(uiToken.id)}" style="font-size:11px;min-height:14px;"></div>
</div>`;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// No auto-PATCH: scope toggles only persist on Save click below.
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Now that the token exists, surface the Save button.
const saveBtn = el('uf-codex-save');
if (saveBtn) saveBtn.style.display = 'inline-flex';
// Remember the created token id so Save can PATCH its scopes.
formEl.dataset.createdTokenId = String(uiToken.id);
if (msg) {
msg.textContent = `Created "${name}".`;
msg.style.color = 'var(--green, #50fa7b)';
}
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
await renderList();
} catch (err) {
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (_wp) { try { _wp.destroy(); } catch (_) {} }
if (pending) pending.style.display = 'none';
if (msg) {
msg.textContent = err?.message || 'Failed';
msg.style.color = 'var(--red)';
}
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
}
};
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Bind the explicit Create button; no auto-creation.
el('uf-codex-create-btn')?.addEventListener('click', () => { _autoCreateCodex(); });
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const _copyCodexToken = async (text) => {
const value = String(text || '');
if (!value) return false;
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch (_) {}
}
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', 'readonly');
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;';
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, value.length);
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
ta.remove();
return ok;
};
const _selectTextFallback = (text, containerId) => {
const code = document.createElement('pre');
code.textContent = text;
code.style.cssText = 'white-space:pre-wrap;word-break:break-word;font-size:10px;margin:6px 0 0;';
el(containerId)?.appendChild(code);
const range = document.createRange();
range.selectNodeContents(code);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
};
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>';
const CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
el('uf-codex-copy-setup')?.addEventListener('click', async () => {
const token = el('uf-codex-copy-setup')?.dataset.token || '';
const btn = el('uf-codex-copy-setup');
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (!token) return;
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
const setup = setupForToken(token);
const ok = await _copyCodexToken(setup);
if (!btn) return;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (ok) {
btn.innerHTML = CHECK_ICON;
btn.style.color = 'var(--accent, var(--red))';
btn.style.opacity = '1';
} else {
_selectTextFallback(setup, 'uf-codex-reveal');
}
setTimeout(() => {
const latest = el('uf-codex-copy-setup');
if (latest) { latest.innerHTML = COPY_ICON; latest.style.color = ''; latest.style.opacity = '0.7'; }
}, 1600);
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
});
el('uf-codex-copy-token')?.addEventListener('click', async () => {
const token = el('uf-codex-token')?.textContent || '';
const ok = await _copyCodexToken(token);
const btn = el('uf-codex-copy-token');
if (!btn) return;
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
if (ok) {
btn.innerHTML = CHECK_ICON;
btn.style.color = 'var(--accent, var(--red))';
btn.style.opacity = '1';
} else {
_selectTextFallback(token, 'uf-codex-reveal');
}
setTimeout(() => {
const latest = el('uf-codex-copy-token');
if (latest) { latest.innerHTML = COPY_ICON; latest.style.color = ''; latest.style.opacity = '0.7'; }
}, 1600);
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
});
function _wireScopeChange(scope) {
scope.querySelectorAll('.uf-codex-scope').forEach(cb => {
if (cb.dataset.wired === '1') return;
cb.dataset.wired = '1';
cb.addEventListener('change', async () => {
const tokenId = cb.dataset.tokenId;
const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`);
const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`);
const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope);
try {
const r = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
await renderList();
} catch (err) {
cb.checked = !cb.checked;
if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; }
}
});
});
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// Note: don't call _wireScopeChange(formEl) here. The existing-token
// editor (current) already wires its own change handler that PATCHes
// immediately. The inline scopes for a *just-created* token should
// remain unwired so they only persist on Save click below.
Codex Agent integration: HTTP surface + plugin bundle + Settings UI This persists work that had been living only in the cookbook docker container's writable layer — never committed to the host source. Brought back to git intact, app.py registration re-applied surgically on top of current main (not the older container copy, which would have regressed the Windows MIME fix, asynccontextmanager lifespan, and webhook auth exempts). routes/codex_routes.py (new): - GET /api/codex/capabilities — what this Odysseus exposes. - GET /api/codex/plugin.zip — downloads integrations/codex as a zip. - GET /api/codex/todos — scope-gated todos:read|write. - POST /api/codex/todos — scope-gated todos:write. - GET /api/codex/emails — scope-gated email:read|draft|send. - GET /api/codex/emails/{uid} — single-message fetch. - _scope_owner() enforces api_token scopes before touching user data. routes/api_token_routes.py (+103 lines): - Adds Codex-token-specific issuance + revocation paths. integrations/codex/ (new bundle, shipped via /api/codex/plugin.zip): - README.md — install instructions. - .codex-plugin/plugin.json — Codex plugin manifest. - scripts/odysseus_api.py — Python client used by the skill. - skills/odysseus/SKILL.md — Codex skill definition. static/js/settings.js (+253 lines): - New "Codex Agent" option in the Integrations dropdown. - Add / edit panel with plugin-bundle download link + curl-with-token install instructions per agent. app.py: - 7-line surgical change: capture email_router = setup_email_routes() and register setup_codex_routes(email_router=email_router) after the email module so the Codex routes can borrow its helpers.
2026-06-03 22:38:05 +09:00
}
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
// ── Add button now drops a type-picker menu directly anchored to itself ──
2026-05-31 23:58:26 +09:00
if (addBtn) {
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
const _typeOptions = [
['api', 'API Service'],
['caldav', 'CalDAV Calendar'],
['claude', 'Claude Agent'],
['codex', 'Codex Agent'],
['carddav', 'Contacts (CardDAV)'],
['contacts', 'Contacts Import'],
['email', 'Email (IMAP/SMTP)'],
['mcp', 'MCP Tool Server'],
];
const _iconFor = (k) => (INTG_TYPES[k]?.icon || '').replace(/width="14"/, 'width="16"').replace(/height="14"/, 'height="16"');
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('');
// Anchor wrapper so the absolutely-positioned menu lands directly under
// the add button. The button is the wrapper's only sibling.
if (!addBtn.parentElement.classList.contains('uf-add-anchor')) {
addBtn.parentElement.style.position = 'relative';
addBtn.parentElement.classList.add('uf-add-anchor');
}
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
// Menu is created per open and removed on close. _closeMenu routes through
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
// listener + Escape-stack entry are torn down alongside the node removal.
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
let _menuEl = null;
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
let _closeMenu = () => {};
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (_menuEl) { _closeMenu(); return; }
const menu = document.createElement('div');
menu.className = 'uf-add-menu';
menu.innerHTML = _rowsHtml;
menu.style.cssText = 'position:absolute;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);min-width:220px;';
addBtn.parentElement.appendChild(menu);
_menuEl = menu;
// Drop-up when there isn't enough room below the button (modal near
// the viewport bottom, mobile keyboard up, etc.).
const tRect = addBtn.getBoundingClientRect();
const mRect = menu.getBoundingClientRect();
const below = window.innerHeight - tRect.bottom;
const above = tRect.top;
if (mRect.height > below && above > below) {
menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
} else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
}
menu.querySelectorAll('.uf-type-option').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
const k = btn.dataset.value;
_closeMenu();
Settings overhaul + UI polish pass Two months of iteration on the Settings panel, integration forms, and small visual nudges across the app. Highlights: Settings restructure - Add Models: split into separate Local + API cards (no more in-card tabs); each fuses Type/Provider with the URL input. - Added Models: new dedicated sidebar tab, with Probe + Clear-offline pulled into its header; Local/API sub-section icons accent-tinted. - Search: Web Search and a new Deep Research card (Model + tuning), with a cross-link to AI Defaults. Provider hints use real clickable anchors; Web Search Test button shows a whirlpool spinner. - AI Defaults: Image Generation card returns; Research Model card carries only Endpoint+Model with a cross-link to Search; Vision / Default / Utility fallbacks unified under one numbered-row design matching Search's chain. - API Permissions (was 'API Tokens'): per-row rename, inline Permissions toggle that expands the scope-edit panel, in-field copy icons (icon→check on success). Empty state accent-tinted. - Integrations: + Add Integration drops a type-picker menu directly under the button (drop-up on tight viewports); each integration form (API, CalDAV, CardDAV, Email, Codex/Claude, Vault, MCP) uses the same accent-outlined Save/Test/Cancel buttons right-aligned. - Danger Zone: Wipe→Delete with trash icons; new 'Delete everything' row at the bottom that loops every category. AI Synthesis (Reminders) - Persona dropdown sourced from PROMPT_TEMPLATES + custom preset. - src/reminder_personas.py mirrors the five built-ins for the server-side synthesis path. - dispatch_reminder() reads reminder_llm_persona and uses the persona's system prompt; empty/unknown falls back to warm-neutral. Esc handling - Kebab menus and the provider picker intercept Esc in capture phase so dismissing a popup no longer closes the whole Settings modal. Accent tinting - Scoped CSS rule across data-settings-panel=ai/services/added-models/ search/integrations/reminders for card h2 icons + the Added Models sub-section icons. Codex/Claude integration form - No more auto-creation on form open — explicit Create token button. - New tokens start with every scope granted; existing tokens move out of the integration form into the API Permissions card. - Setup reveal: copy buttons inline inside the token + setup code blocks; shorter subtitle wording. Misc visual polish - Save/Test/Cancel uniformly accent-outlined and right-aligned on every integration form. - Provider logos render inline next to the search fallback selects and the Deep Research Search dropdown. - Trash icons in fallback rows bumped to 20x20 so they fill the 32px button. - Image generation default flipped to off.
2026-06-10 15:15:13 +09:00
formEl.style.display = '';
showForm(k, 'new');
});
2026-05-31 23:58:26 +09:00
});
fix(ui): route transient dropdown menus through escMenuStack to stop listener leaks (#4684) The app's ad-hoc dropdown/context menus each wire their own document-level outside-click listener, but that listener only removes itself on an *outside* click. Every other dismissal path -- clicking a menu item (which calls el.remove() directly), a Cancel button, Escape, or the "close the previously-open menu" reopen sweep -- tears the node down without unregistering the listener, orphaning it on `document`. The stranded listener then lingers and can break the next menu interaction: the recurring "the button stops working until I refresh the page" class of bug (e.g. delete an email, then the kebab/more button is dead on the other rows). Route all 16 of these menus through the existing escMenuStack helper (bindMenuDismiss / dismissOrRemove), exactly as documentLibrary.js _showLibDropdown, cookbookRunning.js, and research/panel.js already do: a single idempotent close() owns the teardown and is released on every dismissal path, reopen sweeps use dismissOrRemove() instead of a bare .remove(), and Escape flows through the central LIFO esc-stack arbiter. Net -49 lines. Menus migrated: cookbook _showDepMenu; document export menu and _openDocAiReplyChoice; emailInbox _showEmailMenu; emailLibrary _showReaderMoreMenu / _showCardMenu / _showBulkActionsMenu; gallery _showGalleryBulkMenu; notes _pickCustomDate / _openNoteCornerMenu; settings (3 unified-integrations dropdowns); skills _openSkillMenu; tasks _showTaskDropdown; compare _toggleExportMenu. Per-menu semantics preserved (anchor-as-inside tests, the tasks 250ms ghost-click guard, emailLibrary's reader-more-active anchor class and the bulk-Cancel select-mode reset, settings' reused-vs-recreated lifecycles). Six menus with custom lifecycles (notes _openReminderMenu, sessions long-press, document markdown-toolbar, emojiPicker, compare model selector) are intentionally left for a follow-up -- each needs individual review. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:56 +02:00
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
2026-05-31 23:58:26 +09:00
});
}
await renderList();
}
/* ── Admin visibility sync ── */
function syncAdminVisibility() {
if (!modalEl) return;
const isAdmin = !!window._isAdmin;
modalEl.querySelectorAll('.admin-only').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
/* ═══════════════════════════════════════════
PUBLIC API
═══════════════════════════════════════════ */
export function open(tab) {
if (!initialized) initAll();
syncAppearanceCheckboxes();
if (modalEl.classList.contains('hidden')) {
resetWindowPlacement();
}
2026-05-31 23:58:26 +09:00
modalEl.classList.remove('hidden');
syncAdminVisibility();
const content = modalEl.querySelector('.settings-modal-content');
if (tab) {
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
}
// Auto-init admin data if showing an admin tab
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
syncAppearanceOpacity(activeTab === 'appearance');
if (activeTab === 'ai') refreshAiModelEndpoints();
2026-07-08 14:36:41 +02:00
if (activeTab === 'addons') addonsModule.open();
2026-05-31 23:58:26 +09:00
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}
}
export function close() {
if (!modalEl) return;
// Always clear the appearance-tab body class so the rest of the app
// doesn't keep its dimmed state if the modal got closed mid-tab.
document.body.classList.remove('settings-appearance-open');
syncAppearanceOpacity(false); // clear any opacity-slider fade
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modalEl.classList.add('hidden');
}
}
feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP (#237) * feat(email): add Google OAuth2 for Google Workspace / .edu IMAP & SMTP Google deprecated basic-auth (password) access for Google Workspace accounts in May 2025. This means any .edu or org Google email account could no longer connect via IMAP/SMTP with a username + password — the email feature was silently broken for a large class of users. This PR adds full OAuth2 (XOAUTH2) support for Google accounts so Workspace / .edu emails work out of the box. ## What changed ### Backend - `core/database.py`: add `oauth_provider`, `oauth_access_token`, `oauth_refresh_token`, `oauth_token_expiry`, and `display_name` columns to `EmailAccount` + idempotent migration - `routes/email_helpers.py`: XOAUTH2 auth in `_imap_connect()` and `_send_smtp_message()`, automatic token refresh, OAuth fields in `_get_email_config()` - `routes/email_routes.py`: OAuth authorize + callback routes, `_smtp_ready()` fix, OAuth fields through `_deliver()` closure, `display_name` in `From:` header ### Frontend - `static/js/settings.js`: "Google Workspace / .edu" provider preset, "Connect with Google" button, success/error banner, display name field - `static/js/document.js`: `_accountCanSend()` recognises OAuth accounts as SMTP-capable * security: sign OAuth state, scope callback by owner, fix quotes & logs Addresses reviewer feedback on the email OAuth2 PR: - OAuth state is now HMAC-SHA256 signed (keyed with the app secret from secret_storage) encoding account_id + owner + a random nonce, and is verified with constant-time comparison in the callback before any token write. Replaces the bare account_id state, closing the CSRF / state-guessing gap. - Callback extracts the owner from the verified state and re-checks it against EmailAccount.owner before writing tokens, matching the ownership guards used elsewhere in the email routes. Single-user mode (owner == "") still accepts any account, consistent with _assert_owns_account. - Replaced curly/smart quotes in the Name/Email/Display Name input rows with plain ASCII so getElementById lookups and event wiring work. - Stripped account name, SMTP host/user, owner, and raw provider error text from send-config and OAuth logs; failures now surface as generic error codes in the redirect instead of raw exception strings. * test(email): add OAuth2 state, _smtp_ready, and XOAUTH2 tests Move the OAuth state sign/verify helpers out of the setup_email_routes closure into module-level make_oauth_state/verify_oauth_state in email_helpers.py so they can be unit-tested, then add tests/test_email_oauth.py: - signed state round-trips account_id + owner, nonce is unique per call - tampered account_id, forged signature, and garbage states are rejected - _smtp_ready treats an OAuth account (no password) as send-capable, and still rejects host+user-only accounts with neither password nor OAuth - _xoauth2_string / _xoauth2_bytes produce the correct SASL XOAUTH2 framing 14 new tests; existing test_security_regressions.py still passes (28). * refactor(email): single XOAUTH2 frame helper, use RuntimeError Polish from self-review before merge: - Collapse the XOAUTH2 framing to one source of truth: _xoauth2_raw() returns the unencoded SASL string used by both the SMTP and IMAP auth callbacks (each library base64-encodes it), and _xoauth2_bytes() is just its .encode(). Removes the unused base64 _xoauth2_string helper and the duplicated inline frame in _send_smtp_message. - Raise RuntimeError (not bare Exception) for the "OAuth token unavailable" path, matching the convention used across src/. - Update tests accordingly. All 14 OAuth tests + 28 security regressions pass; SMTP/IMAP XOAUTH2 verified live against a real Workspace account. * tests(email-oauth): cover the security-sensitive OAuth paths before merge The previous tests only exercised pure helpers (state signing, _smtp_ready, XOAUTH2 framing). This adds coverage for the actual token-custody and ownership behaviour, pinning the real route handlers rather than re-implementations of their logic. Real OAuth callback route (pulled live from setup_email_routes()): - missing code -> generic missing_code redirect, no account id / owner in URL - provider error -> generic google_error redirect, raw error not echoed - tampered/invalid state -> invalid_state redirect, auth code never leaked - signed state with owner mismatch -> token write refused (ownership_error), DB row left untouched - signed state with matching owner -> tokens written encrypted, and only to the intended account (a second account stays untouched) Real accounts-list route: - exposes oauth_provider status but never the access/refresh token values, encrypted or otherwise Token storage / refresh helpers (isolated in-memory SQLite, mocked HTTP): - refreshed access token stored encrypted; expiry is a timestamp, not a token - fresh token uses cache (no refresh call); expired token triggers refresh - refresh HTTP failure returns None silently, no exception or secret surfaced - missing client credentials short-circuits to None Password-account regression: - password IMAP accounts call conn.login(); OAuth accounts call XOAUTH2 authenticate() and never login() 28 tests pass (14 prior + 14 new). * fix(email-oauth): drop raw exception text from token-refresh log Google token refresh failures now log the account id only, matching the conservative logging used elsewhere on the OAuth path — no raw provider/exception details surfacing in logs. * fix(email-oauth): bring OAuth UI parity to the Integrations email form The Google Workspace / .edu provider preset, Display Name field, and Connect-with-Google flow were only wired into the Email-tab account form. The Integrations-tab form (a separate code path for the same account type) was missing all three, so the OAuth option was invisible from that entry point. Mirrors the same PROVIDERS entry, OAuth section, and connect handler so both forms behave identically. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-15 12:02:58 -04:00
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
(function _handleOauthRedirect() {
const sp = new URLSearchParams(window.location.search);
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
// Strip params from URL without a page reload.
const clean = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', clean);
const success = sp.has('email_oauth_success');
const errMsg = sp.get('email_oauth_error') || '';
// Open settings → integrations after the app has initialised.
function _tryOpen() {
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
window.settingsModule.open('integrations');
// Brief toast-style banner.
const banner = document.createElement('div');
banner.textContent = success
? '✓ Google account connected — email is ready'
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
Object.assign(banner.style, {
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
});
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 4000);
} else {
setTimeout(_tryOpen, 100);
}
}
_tryOpen();
})();
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
2026-05-31 23:58:26 +09:00
export default settingsModule;