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' ;
2026-06-01 15:26:10 +10:00
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' ;
2026-05-31 23:58:26 +09:00
let initialized = false ;
let modalEl = null ;
2026-06-16 02:52:15 -05:00
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 ) ; }
2026-06-04 20:51:23 +02:00
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' ) ;
2026-06-01 15:26:10 +10:00
if ( tab === 'ai' ) refreshAiModelEndpoints ( ) ;
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' ,
2026-06-05 18:07:08 +03:00
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 ( ) ;
} ) ;
}
/ * ─ ─ A p p e a r a n c e - t a b o p a c i t y s l i d e r ─ ─
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
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
2026-06-01 15:26:10 +10:00
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 = '' ;
}
2026-06-13 23:00:16 +09:00
_syncEndpointLogo ( selectEl ) ;
2026-06-01 15:26:10 +10: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
// 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 ) ;
}
}
2026-06-13 23:00:16 +09:00
// 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 ) ;
}
}
2026-06-01 15:26:10 +10:00
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 ) ;
2026-06-01 15:26:10 +10:00
}
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
/ * S h a r e d f a l l b a c k - c h a i n w i d g e t — m i r r o r s t h e D e f a u l t C h a t M o d e l f a l l b a c k U I
* 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 ) {
2026-06-01 15:26:10 +10:00
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 ( ) ; } ,
2026-06-01 15:26:10 +10:00
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 ; } ) ;
2026-06-01 15:26:10 +10:00
_fillModelSelect ( selectEl , ep ? ep . models : [ ] , selected , false ) ;
2026-05-31 23:58:26 +09:00
}
try {
2026-06-01 15:26:10 +10:00
_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 ) ; }
2026-06-01 15:26:10 +10:00
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 ) ; }
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)' ; }
}
epSel . addEventListener ( 'change' , function ( ) { refreshModels ( '' ) ; saveDefault ( ) ; } ) ;
modelSel . addEventListener ( 'change' , saveDefault ) ;
if ( addFbBtn ) addFbBtn . addEventListener ( 'click' , function ( ) {
var first = enabledEndpoints ( ) [ 0 ] ;
_fallbacks . push ( { endpoint _id : first ? first . id : '' , model : '' } ) ;
renderFallbacks ( ) ;
saveDefault ( ) ;
} ) ;
2026-06-01 15:26:10 +10:00
_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 = [ ] ;
2026-06-01 15:26:10 +10:00
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 {
2026-06-01 15:26:10 +10:00
_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 ; } ) ;
2026-06-01 15:26:10 +10:00
_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 || '' ) ;
2026-06-01 15:26:10 +10:00
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 ) ;
2026-06-01 15:26:10 +10:00
_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 {
2026-06-01 15:26:10 +10:00
_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 ; } ) ;
2026-06-01 15:26:10 +10:00
_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 ) ;
2026-06-01 15:26:10 +10:00
_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 ) ;
} ) ;
} ) ;
2026-06-01 15:26:10 +10:00
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 = [ ] ;
2026-06-01 15:26:10 +10:00
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 ( ) ;
2026-06-01 15:26:10 +10:00
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 ) ) {
2026-06-01 15:26:10 +10:00
visionModels . push ( mid ) ;
2026-05-31 23:58:26 +09:00
}
} ) ;
} ) ;
2026-06-01 15:26:10 +10: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 {
2026-06-01 15:26:10 +10:00
_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 ;
2026-06-01 15:26:10 +10:00
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 ( ) ; } ) ;
2026-06-01 15:26:10 +10:00
_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' ) ;
2026-06-02 06:55:15 +03:00
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 ) ;
}
2026-06-02 06:55:15 +03:00
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 ;
2026-06-02 06:55:15 +03:00
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 ) ; }
2026-06-02 06:55:15 +03:00
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 ;
2026-06-02 06:55:15 +03:00
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 ,
2026-06-02 06:55:15 +03:00
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 , '"' ) + '">' +
'<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' ) ;
2026-06-02 13:57:57 +02:00
var runTimeoutInput = el ( 'set-researchRunTimeout' ) ;
2026-05-31 23:58:26 +09:00
var msg = el ( 'set-researchMsg' ) ;
2026-06-01 15:26:10 +10:00
var endpoints = [ ] ;
2026-05-31 23:58:26 +09:00
try {
2026-06-01 15:26:10 +10:00
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 ) ; }
2026-06-01 15:26:10 +10:00
function refreshModels ( selectedModel ) {
2026-05-31 23:58:26 +09:00
var epId = epSel . value ;
2026-06-01 15:26:10 +10:00
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 ;
2026-06-01 15:26:10 +10:00
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 ;
2026-06-02 13:57:57 +02:00
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 ) ;
}
2026-06-02 13:57:57 +02:00
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 ) ;
2026-06-02 04:50:03 +02:00
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 ;
2026-06-02 13:57:57 +02:00
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 ( ) {
2026-06-01 15:26:10 +10:00
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 ) ;
2026-06-02 13:57:57 +02:00
runTimeoutInput . addEventListener ( 'change' , saveResearch ) ;
2026-06-01 15:26:10 +10:00
_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' ) ;
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 ;
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 ) { }
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 ( ) {
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' } ,
2026-06-04 22:36:05 +02:00
body : JSON . stringify ( payload )
2026-05-31 23:58:26 +09:00
} ) ;
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 ) ;
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 ;
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-06-11 08:50:06 +09:00
// Standalone Email Safety toggle (separate card on the AI Defaults tab).
// Default to ON if the setting isn't present so a fresh install is safe.
var emailConfirm = el ( 'set-agentEmailConfirm' ) ;
if ( emailConfirm ) {
try {
var s = await fetch ( '/api/auth/settings' , { credentials : 'same-origin' } ) . then ( r => r . json ( ) ) ;
emailConfirm . checked = s . agent _email _confirm !== false ;
} catch ( _ ) { }
emailConfirm . addEventListener ( 'change' , async ( ) => {
try {
await fetch ( '/api/auth/settings' , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { agent _email _confirm : ! ! emailConfirm . checked } ) ,
} ) ;
} catch ( _ ) { }
} ) ;
}
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 } < / s p a n > $ { e s c ( l a b e l ) } $ { h a s C o n f l i c t ? ' < s p a n c l a s s = " s h o r t c u t - w a r n " t i t l e = " D u p l i c a t e s h o r t c u t " > ! < / s p a n > ' : ' ' } < / s p a n >
< div class = "shortcut-controls" >
< span class = "shortcut-hint" hidden > < / s p a n >
< button class = "shortcut-key${combo ? '' : ' shortcut-key-unset'}" data - action = "${action}" title = "Click to rebind" > $ { keyContent } < / b u t t o n >
< 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
< / b u t t o n >
< / d i v >
` ;
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 ( ( ) => { } ) ;
2026-06-16 02:52:15 -05:00
// 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 ; }
2026-06-16 02:52:15 -05:00
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 < / s p a n >
< span style = "font-size:11px;opacity:0.5;" > Authenticator app required on login < / s p a n >
< / d i v >
< 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;" > < / s p a n >
< button class = "admin-btn-add" id = "tfa-disable-btn" style = "opacity:0.7;" > Disable 2 FA < / b u t t o n >
< / d i v > ` ;
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 . ) < / d i v >
< div class = "settings-row" style = "justify-content:flex-end;" >
< span id = "tfa-msg" style = "font-size:11px;margin-right:auto;" > < / s p a n >
< button class = "admin-btn-add" id = "tfa-setup-btn" > Set Up 2 FA < / b u t t o n >
< / d i v > ` ;
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 ( ) ;
2026-06-04 20:51:23 +02:00
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;" >
2026-06-04 20:51:23 +02:00
$ { qrCode ? ` <img src=" ${ esc ( qrCode ) } " alt="QR Code" style="border-radius:8px;max-width:200px;"> ` : '' }
2026-05-31 23:58:26 +09:00
< / d i v >
< div style = "font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;" >
Scan with your authenticator app , or enter manually :
< / d i v >
2026-06-04 20:51:23 +02:00
< 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 ) } < / d i v >
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;" > < / s p a n >
< button class = "admin-btn-add" id = "tfa-cancel-btn" style = "opacity:0.5;" > Cancel < / b u t t o n >
< button class = "admin-btn-add" id = "tfa-verify-btn" > Verify & Enable < / b u t t o n >
< / d i v > ` ;
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 ; 2 FA Enabled ! < / d i v >
< 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 : < / d i v >
< 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 ( '' ) } < / d i v >
< button class = "admin-btn-add" id = "tfa-done-btn" > Done < / b u t t o n > ` ;
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 ( ) ;
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' ) ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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)' ;
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ) ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ;
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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)' ;
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 || '' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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
) ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ) ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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.' ,
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ) ) ;
} ) ;
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 } ) ;
2026-06-10 23:26:53 +09:00
// 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 ) ;
} ) ;
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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.' ,
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ) || '' ,
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ) ;
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ;
2026-06-01 13:01:33 +09:00
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 , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ;
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 } < / d i v >
< 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 ) } < / d i v >
< / d i v >
$ { 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 < / b u t t o n >
< button class = "admin-btn-sm email-acc-del-btn" style = "font-size:10px;opacity:0.6" > Delete < / b u t t o n >
< / d i v > ` ;
}
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' } < / h 3 >
< 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.' ) } < / l a b e l > < s e l e c t i d = " e a f - p r o v i d e r " c l a s s = " s e t t i n g s - s e l e c t " > < o p t i o n v a l u e = " " > C u s t o m … < / o p t i o n > $ { _ p r o v i d e r O p t i o n s } < / s e l e c t > < / d i v >
2026-06-08 10:32:16 -04:00
< 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);" > < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " e a f - n a m e " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " ( o p t i o n a l — l e a v e b l a n k t o u s e e m a i l ) " v a l u e = " $ { e s c ( a . n a m e | | ' ' ) } " > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " e a f - f r o m " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " y o u @ e x a m p l e . c o m " v a l u e = " $ { e s c ( a . f r o m _ a d d r e s s | | ' ' ) } " > < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " e a f - d i s p l a y - n a m e " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " Y o u r N a m e " v a l u e = " $ { e s c ( a . d i s p l a y _ n a m e | | ' ' ) } " > < / d i v >
< 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 < / d i v >
< 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' } < / d i v >
< 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' } < / b u t t o n >
< / d i v >
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 ) < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " e a f - i m a p - h o s t " c l a s s = " s e t t i n g s - i n p u t " v a l u e = " $ { e s c ( a . i m a p _ h o s t | | ' ' ) } " > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " e a f - i m a p - p o r t " c l a s s = " s e t t i n g s - i n p u t " t y p e = " n u m b e r " v a l u e = " $ { e s c ( a . i m a p _ p o r t | | 9 9 3 ) } " s t y l e = " m a x - w i d t h : 1 0 0 p x " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Username$ { _hint ( 'Usually your full email address.' ) } < / l a b e l > < i n p u t i d = " e a f - i m a p - u s e r " c l a s s = " s e t t i n g s - i n p u t " v a l u e = " $ { e s c ( a . i m a p _ u s e r | | ' ' ) } " > < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " e a f - i m a p - p a s s " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " p l a c e h o l d e r = " $ { i s E d i t & & a . h a s _ i m a p _ p a s s w o r d ? ' ( u n c h a n g e d ) ' : ' ' } " > < / d i v > < / d i v >
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.' ) } < / l a b e l > < l a b e l c l a s s = " a d m i n - s w i t c h " > < i n p u t t y p e = " c h e c k b o x " i d = " e a f - i m a p - s t a r t t l s " $ { a . i m a p _ s t a r t t l s ! = = f a l s e ? ' c h e c k e d ' : ' ' } > < s p a n c l a s s = " a d m i n - s l i d e r " > < / s p a n > < / l a b e l > < / d i v >
< 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 < / s p a n > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " e a f - s m t p - h o s t " c l a s s = " s e t t i n g s - i n p u t " v a l u e = " $ { e s c ( a . s m t p _ h o s t | | ' ' ) } " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Port$ { _hint ( '465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.' ) } < / l a b e l > < i n p u t i d = " e a f - s m t p - p o r t " c l a s s = " s e t t i n g s - i n p u t " t y p e = " n u m b e r " v a l u e = " $ { e s c ( a . s m t p _ p o r t | | 4 6 5 ) } " s t y l e = " m a x - w i d t h : 1 0 0 p x " > < / d i v >
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.' ) } < / l a b e l > < s e l e c t i d = " e a f - s m t p - s e c u r i t y " c l a s s = " s e t t i n g s - s e l e c t " > < o p t i o n v a l u e = " s s l " > S S L < / o p t i o n > < o p t i o n v a l u e = " s t a r t t l s " > S T A R T T L S < / o p t i o n > < o p t i o n v a l u e = " n o n e " > N o n e < / o p t i o n > < / s e l e c t > < / d i v >
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.' ) } < / l a b e l > < l a b e l c l a s s = " a d m i n - s w i t c h " > < i n p u t t y p e = " c h e c k b o x " i d = " e a f - s m t p - s a m e " $ { ( ! i s E d i t | | ( a . s m t p _ u s e r & & a . i m a p _ u s e r & & a . s m t p _ u s e r = = = a . i m a p _ u s e r ) ) ? ' c h e c k e d ' : ' ' } > < s p a n c l a s s = " a d m i n - s l i d e r " > < / s p a n > < / l a b e l > < / d i v >
< div class = "settings-row eaf-smtp-creds" > < label class = "settings-label" > Username$ { _hint ( 'Usually the same as your IMAP username (your email address).' ) } < / l a b e l > < i n p u t i d = " e a f - s m t p - u s e r " c l a s s = " s e t t i n g s - i n p u t " v a l u e = " $ { e s c ( a . s m t p _ u s e r | | ' ' ) } " > < / d i v >
2026-06-08 10:32:16 -04:00
< 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.' ) } < / l a b e l > < i n p u t i d = " e a f - s m t p - p a s s " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " p l a c e h o l d e r = " $ { i s E d i t & & a . h a s _ s m t p _ p a s s w o r d ? ' ( u n c h a n g e d ) ' : ' ' } " > < / d i v >
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" / > < / s v g >
$ { isEdit ? 'Save' : 'Create' }
< / b u t t o n >
< span id = "eaf-msg" style = "font-size:11px;flex:1;margin-left:8px;" > < / s p a n >
< 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" / > < / s v g >
Cancel
< / b u t t o n >
< / d i v >
< / d i v >
` ;
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' : '' ;
} ) ;
}
2026-06-08 10:32:16 -04:00
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 ) => {
2026-06-08 10:32:16 -04:00
_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 ;
// 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 ( _ ) { }
// Load writing style
try {
const res = await fetch ( '/api/email/style' ) ;
const data = await res . json ( ) ;
if ( el ( 'set-email-style' ) ) el ( 'set-email-style' ) . value = data . style || '' ;
} 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 ) {
if ( el ( 'set-email-style' ) ) el ( 'set-email-style' ) . value = data . style ;
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 {
const res = await fetch ( '/api/email/style' , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { style : el ( 'set-email-style' ) . value } ) ,
} ) ;
const result = await res . json ( ) ;
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 = { } ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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' ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ) } < / d i v >
< div style = "font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" > $ { _esc ( i . base _url || '' ) } < / d i v >
< / d i v >
< 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 < / b u t t o n >
< button class = "admin-btn-sm intg-del-btn" data - id = "${i.id}" style = "font-size:11px;opacity:0.6;" > Del < / b u t t o n >
< / d i v >
< / d i v >
` ).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>' } ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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>' } ,
} ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
// 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.' ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
buildSetup : ( origin , token ) => ` export ODYSSEUS_URL= ${ origin }
export ODYSSEUS _API _TOKEN = '${token}'
mkdir - p ~ / p l u g i n s
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 ~ / p l u g i n s
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 ~ / p l u g i n s / o d y s s e u s / s c r i p t s / o d y s s e u s _ a p i . p y c a p a b i l i t i e s ` ,
} ,
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.' ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
buildSetup : ( origin , token ) => ` export ODYSSEUS_URL= ${ origin }
export ODYSSEUS _API _TOKEN = '${token}'
mkdir - p ~ / . c l a u d e
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 ~ / . c l a u d e / s k i l l s / o d y s s e u s / s c r i p t s / o d y s s e u s _ a p i . p y c a p a b i l i t i e s ` ,
} ,
} ;
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 ( ) {
2026-06-05 15:11:08 +01:00
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 : [ ] } ) ) ,
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 ( ( ) => ( { } ) ) ,
2026-06-03 22:38:05 +09:00
fetch ( '/api/tokens' , { credentials : 'same-origin' } ) . then ( r => r . ok ? r . json ( ) : [ ] ) . catch ( ( ) => [ ] ) ,
2026-06-05 15:11:08 +01:00
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 } ) ;
}
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 } ) ;
}
2026-06-03 22:38:05 +09:00
for ( const tok of ( Array . isArray ( tokenRes ) ? tokenRes : [ ] ) ) {
const scopes = tok . scopes || [ ] ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 ;
2026-06-03 22:38:05 +09:00
const detail = ` ${ tok . token _prefix || 'token' } ... - ${ scopes . join ( ', ' ) || 'chat' } ` ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
items . push ( { type : agentType , id : tok . id , name : tok . name || ( agentType === 'claude' ? 'Claude Agent' : 'Codex Agent' ) , detail , enabled : true , data : tok } ) ;
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
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
? '<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>' ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 } < / s p a n >
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 } < / s p a n > < / d i v >
< div style = "font-size:11px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" > $ { item . detail || '' } < / d i v >
< / d i v >
$ { statusDot }
2026-06-05 15:11:08 +01:00
< button class = "admin-btn-sm intg-del-btn" data - intg - id = "${item.id}" data - intg - type = "${item.type}" data - intg - name = "${(item.name || '').replace(/" / g , '"' ) } " title=" Remove " style=" background : none ; border : none ; padding : 4 px ; 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" / > < / s v g >
< / b u t t o n >
< / d i v > ` ;
}
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 } < / s p a n >
< button type = "button" class = "admin-btn-sm intg-open-email-settings" style = "white-space:nowrap;" > Email settings < / b u t t o n >
< / d i v > ` : ' ' ;
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 ( ) ;
2026-06-05 15:11:08 +01:00
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' } ) ;
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' } ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 ) ;
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 ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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.
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 ( '' ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
// 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" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09: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;" > < 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" / > < / s v g > A P I I n t e g r a t i o n < / h 2 >
2026-05-31 23:58:26 +09:00
< div class = "settings-col" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< div class = "settings-row" > < label class = "settings-label" > Preset < / l a b e l >
< 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 ) < / o p t i o n > $ { s e l e c t O p t s } < / s e l e c t >
< 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 } < / s p a n >
< span class = "ufapi-label" style = "flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" > Custom ( no preset ) < / s p a n >
< span aria - hidden = "true" style = "position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;" > ▾ < / s p a n >
< / b u t t o n >
< 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 } < / d i v >
< / d i v >
< / d i v >
2026-05-31 23:58:26 +09:00
< div class = "settings-row" > < label class = "settings-label" > Name < / l a b e l > < i n p u t i d = " u f - a p i - n a m e " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " M y S e r v i c e " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Base URL < /label><input id="uf-api-url" class="settings-input" placeholder="http:/ / localhost : 8080 " > < / d i v >
2026-06-01 10:00:15 +09:00
< div id = "uf-api-ntfy-hint" style = "display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;" > < / d i v >
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.' ) } < / l a b e l > < s e l e c t i d = " u f - a p i - a u t h " c l a s s = " s e t t i n g s - i n p u t " > < o p t i o n v a l u e = " b e a r e r " > B e a r e r ( m o s t c o m m o n ) < / o p t i o n > < o p t i o n v a l u e = " h e a d e r " > H e a d e r < / o p t i o n > < o p t i o n v a l u e = " b a s i c " > B a s i c < / o p t i o n > < o p t i o n v a l u e = " n o n e " > N o n e < / o p t i o n > < / s e l e c t > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - a p i - h e a d e r " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " X - A u t h - T o k e n " > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - a p i - k e y " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " p l a c e h o l d e r = " T o k e n / k e y " > < / d i v >
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" > < / s p a n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
< / d i v > ` ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
// 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 ) ;
} ;
const _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' ; }
const onDoc = ( ev ) => { if ( ! menu . contains ( ev . target ) && ev . target !== trig ) { _close ( ) ; document . removeEventListener ( 'click' , onDoc , true ) ; } } ;
setTimeout ( ( ) => document . addEventListener ( 'click' , onDoc , true ) , 0 ) ;
} ;
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 || '' ) ;
} ) ( ) ;
2026-06-01 10:00:15 +09:00
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 ] ;
2026-06-01 10:00:15 +09:00
const isNtfy = preset . value === 'ntfy' || ( p && ( p . name || '' ) . toLowerCase ( ) === 'ntfy' ) ;
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:47:57 -04:00
const isUrlAuth = preset . value === 'discord_webhook' ; // secret embedded in URL — no key/auth fields needed
2026-06-01 10:00:15 +09:00
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 ) {
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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-01 10:00:15 +09:00
}
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
Replaces any Discord-specific reminder channel with a generic outbound
webhook channel. Users pick any saved Integration as the target and
supply a JSON payload template with {{title}} and {{message}}
placeholders — values are JSON-escaped before substitution. Works with
Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a
POST with a JSON body.
- `src/settings.py` — reminder_webhook_integration_id +
reminder_webhook_payload_template defaults
- `routes/note_routes.py` — webhook delivery block; Integration lookup,
template rendering, auth wiring; built-in preset defaults so
discord_webhook works out of the box without a configured template;
settings_override kwarg avoids test-button race condition
- `routes/auth_routes.py` — discord_webhook preset test handler
- `src/integrations.py` — discord_webhook preset with description +
example templates; hides auth/key fields in the Integration form
- `src/builtin_actions.py` — webhook_sent delivery check
- `src/tool_implementations.py` — webhook aliases + enum updated
- `static/index.html` — Webhook channel option; Integration picker +
payload template textarea
- `static/js/settings.js` — Integration list, populateWebhookIntegrations,
syncChannelRows, hints, load/save, auto-fill preset templates,
test-button override payload, hide auth/key for URL-auth presets
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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 ) ;
2026-06-01 10:00:15 +09:00
_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 ;
2026-06-15 12:10:36 +05:30
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 {
2026-06-01 10:00:15 +09:00
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)' ; }
} ) ;
}
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" >
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" / > < / s v g > $ { i s N e w ? ' A d d C a l D A V C a l e n d a r ' : ' E d i t C a l D A V C a l e n d a r ' } < / h 2 >
2026-05-31 23:58:26 +09:00
< div class = "settings-col" >
2026-06-05 14:32:50 -04:00
< div class = "settings-row" > < label class = "settings-label" > Label < / l a b e l > < i n p u t i d = " u f - c a l d a v - l a b e l " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " e . g . W o r k , P e r s o n a l " > < / d i v >
< 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 / " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Username < / l a b e l > < i n p u t i d = " u f - c a l d a v - u s e r " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " y o u @ e x a m p l e . c o m " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Password < / l a b e l > < i n p u t i d = " u f - c a l d a v - p a s s " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " p l a c e h o l d e r = " $ { i s N e w ? ' ' : ' L e a v e b l a n k t o k e e p e x i s t i n g ' } " > < / d i v >
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" > < / s p a n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
< / d i v > ` ;
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 ,
} ;
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 } ;
}
} ;
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 {
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 ) ;
}
} ) ;
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" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09: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;" > < path d = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" / > < circle cx = "12" cy = "7" r = "4" / > < / s v g > C o n t a c t s ( C a r d D A V ) < / h 2 >
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 / " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Username < / l a b e l > < i n p u t i d = " u f - c a r d d a v - u s e r " c l a s s = " s e t t i n g s - i n p u t " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Password < / l a b e l > < i n p u t i d = " u f - c a r d d a v - p a s s " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " > < / d i v >
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" > < / s p a n >
< 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;" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< 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" / > < / s v g >
Save
< / b u t t o n >
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));" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27: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" / > < / s v g >
Cancel
< / b u t t o n >
< / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
< / d i v >
< div class = "admin-card contacts-manager" style = "margin-top:8px" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:6px;" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< 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" / > < / s v g > C o n t a c t s I m p o r t < s p a n i d = " c m - c o u n t " s t y l e = " o p a c i t y : 0 . 5 ; f o n t - w e i g h t : n o r m a l ; f o n t - s i z e : 1 1 p x ; " > < / s p a n > < / h 2 >
2026-05-31 23:58:26 +09:00
< button class = "admin-btn-sm" id = "cm-import-btn" style = "margin-left:auto;" > Import < / b u t t o n >
< button class = "admin-btn-sm" id = "cm-export-vcf-btn" > Export . vcf < / b u t t o n >
< button class = "admin-btn-sm" id = "cm-export-csv-btn" > Export . csv < / b u t t o n >
< button class = "admin-btn-sm" id = "cm-add-toggle" > + Add < / b u t t o n >
< input type = "file" id = "cm-import-file" accept = ".vcf,.csv,text/vcard,text/csv" multiple style = "display:none" >
< / d i v >
2026-06-11 09:23:14 +09:00
< 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 < / b u t t o n > < / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
2026-06-11 09:23:14 +09:00
< 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 … < / d i v > < / d i v >
< / d i v > ` ;
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 || '' ;
2026-06-11 09:47:28 +09:00
// 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 ( ) ;
2026-06-11 09:23:14 +09:00
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 {
2026-06-11 09:23:14 +09:00
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 ( _ ) { }
2026-06-11 09:23:14 +09:00
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 || '' ) ) ;
2026-06-11 09:23:14 +09:00
// 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)' ) } < / d i v >
< div class = "contact-sub" style = "font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" > $ { esc ( sub ) } < / d i v >
< / d i v >
< 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" / > < / s v g >
Edit
< / b u t t o n >
< 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" / > < / s v g >
Delete
< / b u t t o n >
2026-05-31 23:58:26 +09:00
< / d i v >
2026-06-11 09:23:14 +09:00
< 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 < / b u t t o n > < b u t t o n c l a s s = " a d m i n - b t n - s m c o n t a c t - c a n c e l " s t y l e = " o p a c i t y : 0 . 7 ; " > C a n c e l < / b u t t o n > < / d i v >
< / d i v >
< / d i v > ` ;
} ) . 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 ) ,
2026-06-11 09:23:14 +09:00
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 ( '' ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
// 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' ) ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 < / h 2 >
< div class = "settings-col" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< 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.' ) } < / l a b e l >
< 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 … < / o p t i o n > $ { _ p r o v i d e r O p t i o n s } < / s e l e c t >
< 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 [ '' ] } < / s p a n >
< span class = "ufp-label" style = "flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" > Custom … < / s p a n >
< span aria - hidden = "true" style = "position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;" > ▾ < / s p a n >
< / b u t t o n >
< 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 } < / d i v >
< / d i v >
< / d i v >
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);" > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - e m a i l - n a m e " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " ( o p t i o n a l — l e a v e b l a n k t o u s e e m a i l ) " > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - e m a i l - f r o m " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " y o u @ e x a m p l e . c o m " > < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " u f - d i s p l a y - n a m e " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " Y o u r N a m e " > < / d i v >
< 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 < / d i v >
< 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' } < / d i v >
< 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' } < / b u t t o n >
< / d i v >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< 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" / > < / s v g > I M A P ( R e c e i v i n g ) < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " u f - i m a p - h o s t " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " i m a p . e x a m p l e . c o m " > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - i m a p - p o r t " c l a s s = " s e t t i n g s - i n p u t " t y p e = " n u m b e r " p l a c e h o l d e r = " 9 9 3 " s t y l e = " m a x - w i d t h : 1 0 0 p x " > < / d i v >
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - i m a p - u s e r " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " y o u @ e x a m p l e . c o m " > < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " u f - i m a p - p a s s " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " p l a c e h o l d e r = " $ { p l a c e h o l d e r P a s s } " > < / d i v > < / d i v >
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.' ) } < / l a b e l > < l a b e l c l a s s = " a d m i n - s w i t c h " s t y l e = " m a r g i n - l e f t : 0 " > < i n p u t t y p e = " c h e c k b o x " i d = " u f - i m a p - s t a r t t l s " c h e c k e d > < s p a n c l a s s = " a d m i n - s l i d e r " > < / s p a n > < / l a b e l > < / d i v >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< 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" / > < / s v g > S M T P ( S e n d i n g ) < s p a n s t y l e = " f o n t - w e i g h t : n o r m a l ; o p a c i t y : 0 . 7 " > — o p t i o n a l , l e a v e b l a n k f o r r e a d - o n l y < / s p a n > < / d i v >
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.' ) } < / l a b e l > < i n p u t i d = " u f - s m t p - h o s t " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " s m t p . e x a m p l e . c o m " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Port$ { _hint ( '465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.' ) } < / l a b e l > < i n p u t i d = " u f - s m t p - p o r t " c l a s s = " s e t t i n g s - i n p u t " t y p e = " n u m b e r " p l a c e h o l d e r = " 4 6 5 " s t y l e = " m a x - w i d t h : 1 0 0 p x " > < / d i v >
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.' ) } < / l a b e l > < s e l e c t i d = " u f - s m t p - s e c u r i t y " c l a s s = " s e t t i n g s - s e l e c t " > < o p t i o n v a l u e = " s s l " > S S L < / o p t i o n > < o p t i o n v a l u e = " s t a r t t l s " > S T A R T T L S < / o p t i o n > < o p t i o n v a l u e = " n o n e " > N o n e < / o p t i o n > < / s e l e c t > < / d i v >
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.' ) } < / l a b e l > < l a b e l c l a s s = " a d m i n - s w i t c h " s t y l e = " m a r g i n - l e f t : 0 " > < i n p u t t y p e = " c h e c k b o x " i d = " u f - s m t p - s a m e " c h e c k e d > < s p a n c l a s s = " a d m i n - s l i d e r " > < / s p a n > < / l a b e l > < / d i v >
< div class = "settings-row uf-smtp-creds" > < label class = "settings-label" > Username$ { _hint ( 'Usually the same as your IMAP username (your email address).' ) } < / l a b e l > < i n p u t i d = " u f - s m t p - u s e r " c l a s s = " s e t t i n g s - i n p u t " > < / d i v >
2026-06-08 10:32:16 -04:00
< 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.' ) } < / l a b e l > < i n p u t i d = " u f - s m t p - p a s s " c l a s s = " s e t t i n g s - i n p u t " t y p e = " p a s s w o r d " p l a c e h o l d e r = " $ { p l a c e h o l d e r P a s s } " > < / d i v >
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.' ) } < / l a b e l > < l a b e l c l a s s = " a d m i n - s w i t c h " s t y l e = " m a r g i n - l e f t : 0 " > < i n p u t t y p e = " c h e c k b o x " i d = " u f - e m a i l - d e f a u l t " > < s p a n c l a s s = " a d m i n - s l i d e r " > < / s p a n > < / l a b e l > < s p a n s t y l e = " f o n t - s i z e : 1 0 p x ; o p a c i t y : 0 . 5 ; m a r g i n - l e f t : 6 p x " > U s e d w h e n n o t h i n g e l s e i s s e l e c t e d < / s p a n > < / d i v >
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" > < / s p a n >
< 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" / > < / s v g >
< / s p a n >
Test
< / b u t t o n >
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" / > < / s v g >
< / s p a n >
< span class = "uf-email-save-label" > $ { isEdit ? 'Save' : 'Create' } < / s p a n >
< / b u t t o n >
< 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" / > < / s v g >
Cancel
< / b u t t o n >
< / d i v >
< / d i v >
< / d i v > ` ;
// 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' ,
} ,
2026-06-08 10:32:16 -04:00
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 ) } < / d i v >
< div style = "opacity:0.8;margin-bottom:6px;" > $ { esc ( n . body ) } < / d i v >
< 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" / > < / s v g >
2026-06-08 10:32:16 -04:00
$ { 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" / > < / s v g >
Copy link
< / b u t t o n >
< / d i v > ` ;
} ;
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' : '' ;
} ) ;
}
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
// 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 ;
} ;
const _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' ;
}
const onDoc = ( ev ) => { if ( ! menu . contains ( ev . target ) && ev . target !== trigger ) { _closeMenu ( ) ; document . removeEventListener ( 'click' , onDoc , true ) ; } } ;
setTimeout ( ( ) => document . addEventListener ( 'click' , onDoc , true ) , 0 ) ;
} ;
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)' ;
2026-06-01 13:01:33 +09:00
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 ) < / h 2 >
< div id = "uf-vault-status" style = "font-size:11px;opacity:0.7;margin-bottom:8px" > Loading ... < / d i v >
< 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 " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Email < / l a b e l > < i n p u t i d = " u f - v a u l t - e m a i l " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " y o u @ e x a m p l e . c o m " > < / d i v >
< 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 " > < / d i v >
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" > < / s p a n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
2026-05-31 23:58:26 +09:00
< / d i v >
< div style = "font-size:10px;opacity:0.5;margin-top:6px;line-height:1.4" >
< strong > Login < / s t r o n g > r e g i s t e r s t h i s d e v i c e w i t h y o u r V a u l t w a r d e n a c c o u n t ( o n c e p e r a c c o u n t ) . < b r >
< strong > Unlock < / s t r o n g > d e c r y p t s t h e v a u l t — r e q u i r e d a f t e r r e s t a r t o r L o c k . S e s s i o n i s s a v e d s o t h e a s s i s t a n t c a n r e a d p a s s w o r d s .
< / d i v >
< / d i v >
< / d i v > ` ;
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 , '&' ) . replace ( /</g , '<' ) ;
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 ) } < / h 2 >
< 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}" > < / s p a n >
< span style = "font-size:11px;opacity:0.7" > $ { statusText } < / s p a n >
< / d i v >
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" > < / s p a n >
$ { 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 < / b u t t o n >
< 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' } < / b u t t o n >
< 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 < / b u t t o n >
2026-05-31 23:58:26 +09:00
< / d i v >
< div id = "uf-mcp-tools-panel" > < / d i v >
< / d i v > ` ;
// 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 < / h 2 >
< div class = "settings-col" >
< div class = "settings-row" > < label class = "settings-label" > Name < / l a b e l > < i n p u t i d = " u f - m c p - n a m e " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " S e r v e r n a m e " > < / d i v >
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 < / l a b e l > < s e l e c t i d = " u f - m c p - t r a n s p o r t " c l a s s = " s e t t i n g s - i n p u t " > < o p t i o n v a l u e = " s t d i o " > s t d i o < / o p t i o n > < o p t i o n v a l u e = " s s e " > S S E < / o p t i o n > < o p t i o n v a l u e = " h t t p " > S t r e a m a b l e H T T P < / o p t i o n > < / s e l e c t > < / d i v >
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 < / l a b e l > < i n p u t i d = " u f - m c p - c m d " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = " n p x " > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Args < / l a b e l > < i n p u t i d = " u f - m c p - a r g s " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = ' [ " - y " , " @ m o d e l c o n t e x t p r o t o c o l / s e r v e r - f i l e s y s t e m " ] ' > < / d i v >
< div class = "settings-row" > < label class = "settings-label" > Env < / l a b e l > < i n p u t i d = " u f - m c p - e n v " c l a s s = " s e t t i n g s - i n p u t " p l a c e h o l d e r = ' { " K E Y " : " v a l u e " } ' > < / d i v >
< / d i v >
< 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 " > < / d i v >
< / d i v >
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" > < / s p a n >
< 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 < / b u t t o n >
< 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 < / b u t t o n >
< / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
< / d i v > ` ;
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 ;
2026-06-01 12:23:05 +08:00
// 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' ) {
2026-06-01 12:23:05 +08:00
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 {
2026-06-01 12:23:05 +08:00
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 {
2026-06-01 12:23:05 +08:00
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 ) {
2026-06-01 12:23:05 +08:00
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
} ) ;
}
}
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
async function showAgentForm ( kind , editId ) {
const cfg = AGENT _CONFIGS [ kind ] || AGENT _CONFIGS . codex ;
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' } ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
{ key : 'documents:read' , label : 'Documents' , detail : 'Read documents when a document API is enabled' } ,
2026-06-03 22:38:05 +09:00
{ key : 'documents:write' , label : 'Documents write' , detail : 'Create and update draft documents' } ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
{ key : 'email:read' , label : 'Email' , detail : 'Read email when an email API is enabled' } ,
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/...)' } ,
2026-06-03 22:38:05 +09:00
] ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +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>' ,
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
} ;
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));' ;
} ;
2026-06-03 22:38:05 +09:00
const scopeToggles = ( t ) => {
const scopes = new Set ( t . scopes || [ ] ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 } < / s p a n >
< span class = "settings-label" style = "width:75px;flex-shrink:0;padding:0;" > $ { esc ( niceLabel ) } < / s p a n >
< 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 ) } < / s p a n >
< span style = "font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;" > $ { esc ( scope . detail ) } < / s p a n >
< 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" > < / s p a n > < / l a b e l >
< / l a b e l > ` ;
} ) . join ( '' ) ;
2026-06-03 22:38:05 +09:00
} ;
const origin = window . location . origin || '' ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
const setupForToken = ( token ) => cfg . buildSetup ( origin , token ) ;
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' } < / s p a n >
< / d i v >
< div style = "font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;" > Permissions < / d i v >
$ { scopeToggles ( current ) }
< div id = "uf-codex-existing-msg" style = "font-size:11px;min-height:14px;margin-top:4px;" > < / d i v >
< / d i v > ` : ' ' ;
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" ) . < / d i v >
< input type = "text" id = "uf-codex-name-input" class = "settings-select" placeholder = "${esc(cfg.defaultName)}" style = "width:100%;font-size:12px;padding:6px 8px;" >
< / d i v >
< div id = "uf-codex-pending" style = "display:none;align-items:center;gap:8px;padding:6px 0;font-size:11px;opacity:0.7;" > < / d i v >
< 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 < / d i v >
< div style = "font-size:11px;opacity:0.62;margin-bottom:4px;" > Copy this token , it won ' t be shown again . < / d i v >
< 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;" > < / c o d e >
< 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" / > < / s v g >
< / b u t t o n >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< / d i v >
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 . < / d i v >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< div style = "font-size:11px;opacity:0.62;margin-bottom:6px;" > $ { cfg . setupDescription } < / d i v >
< 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" > < / c o d e > < / p r e >
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 < / s p a n >
< span style = "flex:1" > < / s p a n >
< 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" / > < / s v g >
< span > Copy < / s p a n >
< / b u t t o n >
< 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" / > < / s v g >
< span > Configure < / s p a n >
< / b u t t o n >
< / d i v >
< 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 . < / d i v >
< div id = "uf-codex-inline-scopes" > < / d i v >
< / d i v >
2026-06-03 22:38:05 +09:00
< / d i v >
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));" >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27: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" / > < / s v g >
Cancel
< / b u t t o n >
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;" > < / s p a n >
< 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" / > < / s v g >
Revoke
< / b u t t o n >
< 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" / > < / s v g >
Create token
< / b u t t o n >
< 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" / > < / s v g >
Save
< / b u t t o n >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< / d i v >
2026-06-03 22:38:05 +09:00
< / d i v >
< / d i v > ` ;
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)' ; }
}
} ) ;
} ) ;
}
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 ( ) => {
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)' ; }
}
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
} ) ;
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' ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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…' ;
}
}
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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.
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 ( _ ) { } }
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
if ( pending ) pending . style . display = 'none' ;
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' ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
if ( setupBtn ) setupBtn . dataset . token = d . token || '' ;
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 ) ) } ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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;" > < / d i v >
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
< / d i v > ` ;
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.
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27: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
// 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 ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
if ( msg ) {
msg . textContent = ` Created " ${ name } ". ` ;
msg . style . color = 'var(--green, #50fa7b)' ;
}
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 ( _ ) { } }
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
if ( pending ) pending . style . display = 'none' ;
if ( msg ) {
msg . textContent = err ? . message || 'Failed' ;
msg . style . color = 'var(--red)' ;
}
2026-06-03 22:38:05 +09:00
}
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27: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
// Bind the explicit Create button; no auto-creation.
el ( 'uf-codex-create-btn' ) ? . addEventListener ( 'click' , ( ) => { _autoCreateCodex ( ) ; } ) ;
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>' ;
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 ;
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 ) ;
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 ) ;
2026-06-03 22:38:05 +09:00
} ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +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.
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' ) ;
}
let _menuEl = null ;
const _closeMenu = ( ) => { if ( _menuEl ) { _menuEl . remove ( ) ; _menuEl = null ; } } ;
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' ;
}
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 ( ) ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
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 = '' ;
feat: Claude Agent integration + cookbook reconnect + UI polish
- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
setup_claude_routes + integrations/claude/ skill bundle. Wired in
app.py alongside the existing Codex integration; same scope-gated
/api/codex/* backend; agent form has new description so users know
it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
from task UI, scheduler defaults, registry, tool schema, clear-cache
route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
diagnosis runs _reconnectTask after probing has-session. 30s confirm
window before marking a download "done" — kills the Finished/Downloading
flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
Update button morphs to Archive when no text was edited; bell icon
accent-colored; chip-trashing notif pills fade so only the icon
rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
dropdowns, custom drop-up-aware menus, accent sub-header icons
(IMAP/SMTP), consistent card styling between list + edit, contacts
Edit/Delete icons, agent form description copy.
2026-06-04 08:27:26 +09:00
showForm ( k , 'new' ) ;
} ) ;
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
const onDoc = ( ev ) => { if ( ! menu . contains ( ev . target ) && ev . target !== addBtn ) { _closeMenu ( ) ; document . removeEventListener ( 'click' , onDoc , true ) ; } } ;
setTimeout ( ( ) => document . addEventListener ( 'click' , onDoc , true ) , 0 ) ;
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 ( ) ;
2026-06-07 17:12:42 +02:00
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' ) ;
2026-06-01 15:26:10 +10:00
if ( activeTab === 'ai' ) refreshAiModelEndpoints ( ) ;
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 ( ) ;
} ) ( ) ;
2026-06-01 15:26:10 +10:00
const settingsModule = { open , close , initIntegrations , initUnifiedIntegrations , syncAdminVisibility , refreshAiModelEndpoints } ;
2026-05-31 23:58:26 +09:00
export default settingsModule ;