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' ;
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 ;
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 ) ) ;
}
/* ── 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 ;
// 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 = '' ;
}
}
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 = '' ;
}
}
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' ;
rm . innerHTML = '×' ;
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' ;
rm . innerHTML = '×' ;
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 ;
if ( enabledToggle ) enabledToggle . checked = settings . image _gen _enabled !== false ;
} 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' } ,
body : JSON . stringify ( { image _gen _enabled : enabledToggle ? enabledToggle . checked : true , image _model : modelSel . value , image _quality : qualSel . 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)' ; }
}
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 ;
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
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
var _searchProviderHints = {
searxng : 'Self-hosted SearXNG instance. Leave URL empty to use the SEARXNG_INSTANCE env var.' ,
duckduckgo : 'Free search — no API key required. Works out of the box.' ,
brave : 'Get your API key from brave.com/search/api' ,
google _pse : 'Requires a Google API key and a Programmable Search Engine ID (CX). Create one at programmablesearchengine.google.com' ,
tavily : 'AI-optimized search. 1,000 free credits/month at tavily.com' ,
serper : 'Google results via API. 2,500 free queries at serper.dev' ,
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' ;
hint . textContent = _searchProviderHints [ prov ] || '' ;
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 ) ; } ) ;
}
function _renderFallbackChain ( ) {
if ( ! fbWrap ) return ;
var chain = ( _settings . search _fallback _chain || [ ] ) . slice ( ) ;
var esc = function ( s ) { return String ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( /"/g , '"' ) ; } ;
var chipsHtml = chain . map ( function ( p , i ) {
var label = _searchLabels [ p ] || p ;
var logo = _SEARCH _PROVIDER _LOGOS [ p ] || '' ;
return '<span class="search-fb-chip" draggable="true" data-idx="' + i + '" data-value="' + esc ( p ) + '">' +
'<span class="search-fb-grip" title="Drag to reorder">⋮⋮</span>' +
'<span class="search-fb-logo">' + logo + '</span>' +
'<span>' + esc ( label ) + '</span>' +
'<button type="button" class="search-fb-remove" data-value="' + esc ( p ) + '" title="Remove">×</button>' +
'</span>' ;
} ) . join ( '' ) ;
var addOptions = _availableFallbackOptions ( ) ;
var addSelect = addOptions . length
? '<select class="search-fb-add" id="search-fb-add"><option value="">+ Add</option>' +
addOptions . map ( function ( o ) { return '<option value="' + esc ( o . value ) + '">' + esc ( o . label ) + '</option>' ; } ) . join ( '' ) +
'</select>'
: '' ;
fbWrap . innerHTML = chipsHtml + addSelect ;
// Wire chip remove + drag-reorder + add
fbWrap . querySelectorAll ( '.search-fb-remove' ) . forEach ( function ( btn ) {
btn . addEventListener ( 'click' , function ( ) {
var next = ( _settings . search _fallback _chain || [ ] ) . filter ( function ( p ) { return p !== btn . dataset . value ; } ) ;
_saveFallbackChain ( next ) ;
} ) ;
} ) ;
var addSel = el ( 'search-fb-add' ) ;
if ( addSel ) {
addSel . addEventListener ( 'change' , function ( ) {
if ( ! addSel . value ) return ;
var next = ( _settings . search _fallback _chain || [ ] ) . slice ( ) ;
if ( ! next . includes ( addSel . value ) ) next . push ( addSel . value ) ;
_saveFallbackChain ( next ) ;
} ) ;
}
// Drag-reorder
var dragging = null ;
fbWrap . querySelectorAll ( '.search-fb-chip' ) . forEach ( function ( chip ) {
chip . addEventListener ( 'dragstart' , function ( ) {
dragging = chip ; chip . classList . add ( 'dragging' ) ;
} ) ;
chip . addEventListener ( 'dragend' , function ( ) {
if ( dragging ) dragging . classList . remove ( 'dragging' ) ;
dragging = null ;
// Persist new order
var order = Array . from ( fbWrap . querySelectorAll ( '.search-fb-chip' ) ) . map ( function ( c ) { return c . dataset . value ; } ) ;
_saveFallbackChain ( order ) ;
} ) ;
chip . addEventListener ( 'dragover' , function ( e ) {
e . preventDefault ( ) ;
if ( ! dragging || dragging === chip ) return ;
var rect = chip . getBoundingClientRect ( ) ;
var after = ( e . clientX - rect . left ) > rect . width / 2 ;
chip . parentNode . insertBefore ( dragging , after ? chip . nextSibling : chip ) ;
} ) ;
} ) ;
}
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 ;
var orig = testBtn . textContent ;
testBtn . textContent = 'Testing...' ;
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 {
testBtn . disabled = false ; testBtn . textContent = orig ;
}
} ) ;
}
}
// 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' ) ;
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 ) ;
} 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)' ; }
}
searchSel . addEventListener ( 'change' , saveResearchSearch ) ;
}
/* ── 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-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 }
} ) ) ;
} ) ;
} ) ;
var resetBtn = el ( 'set-uiVisResetBtn' ) ;
if ( resetBtn ) {
resetBtn . addEventListener ( 'click' , function ( ) {
localStorage . removeItem ( 'odysseus-ui-visibility' ) ;
syncAppearanceCheckboxes ( ) ;
syncPrivacyCheckboxes ( ) ;
window . applyUIVis ( { } ) ;
} ) ;
}
}
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'}" >
$ { isCustom ? '\u21A9' : '\u2713' }
< / 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 ) {
actionBtn . textContent = '\u21A9' ;
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 ( ( ) => { } ) ;
// Change password
const saveBtn = el ( 'settings-pw-save' ) ;
const msgEl = el ( 'settings-pw-msg' ) ;
if ( saveBtn ) {
saveBtn . addEventListener ( 'click' , async ( ) => {
const cur = el ( 'settings-pw-current' ) . value ;
const nw = el ( 'settings-pw-new' ) . value ;
const conf = el ( 'settings-pw-confirm' ) . value ;
msgEl . style . color = '' ;
if ( ! cur || ! nw ) { msgEl . textContent = 'Fill in all fields' ; msgEl . style . color = 'var(--red)' ; return ; }
if ( nw . length < 8 ) { msgEl . textContent = 'Min 8 characters' ; msgEl . style . color = 'var(--red)' ; return ; }
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 ( ) ;
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.' ,
email : 'Reminders are emailed AND shown as a browser notification.' ,
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 ;
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 } ) ;
} ) ;
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 ;
if ( testMsg ) { testMsg . textContent = 'Sending…' ; testMsg . style . color = 'var(--fg)' ; }
// Whirlpool loader right next to the "Sending…" text while it sends.
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 {
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 ,
... ( 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 ) ;
if ( typeof openTasks === 'function' ) openTasks ( ) ;
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 = {
gmail : { label : 'Gmail' , imap : { host : 'imap.gmail.com' , port : 993 , starttls : false } , smtp : { host : 'smtp.gmail.com' , port : 465 } } ,
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 } } ,
} ;
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 >
< 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 >
2026-06-08 10:32:16 -04:00
< 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 >
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 >
` ;
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 ] ;
if ( ! p ) return ;
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' ) ;
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 ( ) ,
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' ,
setupDescription : 'Downloads the plugin bundle and registers it with Codex. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the plugin from <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>, and runs <code>codex plugin add odysseus@personal</code>.' ,
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' ,
setupDescription : 'Downloads the skill bundle into <code>~/.claude/skills/odysseus/</code>. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the skill from <a href="/api/claude/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>. Claude Code auto-loads the skill on next start.' ,
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 = '' ;
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">
2026-05-31 23:58:26 +09:00
< span style = "opacity:0.6;flex-shrink:0" > $ { t . icon } < / s p a n >
< 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 ;
const items2 = listEl . querySelectorAll ( '.intg-card' ) ;
items2 . forEach ( c => c . style . borderColor = '' ) ;
card . style . borderColor = 'var(--accent)' ;
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 >
< div class = "settings-row" style = "margin-top:4px" > < button class = "admin-btn-sm" id = "uf-api-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 " i d = " u f - a p i - t e s t " s t y l e = " o p a c i t y : 0 . 7 " > T e s t < / 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 " i d = " u f - a p i - 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 > < s p a n i d = " u f - a p i - m s g " s t y l e = " f o n t - s i z e : 1 1 p x " > < / s p a n > < / d i v >
< / 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 ;
const body = { name : name . value , base _url : url . value , auth _type : auth . value , auth _header : header . value , preset : presetKey } ;
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 >
< div class = "settings-row" style = "margin-top:4px" > < button class = "admin-btn-sm" id = "uf-caldav-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 " i d = " u f - c a l d a v - t e s t " s t y l e = " o p a c i t y : 0 . 7 " > T e s t < / 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 " i d = " u f - c a l d a v - 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 > < s p a n i d = " u f - c a l d a v - m s g " s t y l e = " f o n t - s i z e : 1 1 p x ; m a r g i n - l e f t : 6 p x " > < / s p a 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 >
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" style = "margin-top:8px;align-items:center;" >
< button class = "admin-btn-add" id = "uf-carddav-save" style = "background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;" >
< 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 >
< span id = "uf-carddav-msg" style = "font-size:11px;flex:1;margin-left:8px" > < / s p a n >
< button class = "admin-btn-add" id = "uf-carddav-cancel" style = "opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;" >
< 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 >
< div id = "cm-add-row" class = "contacts-add-row" style = "display:none;" >
< input id = "cm-add-name" class = "settings-input" placeholder = "Name" style = "flex:1;min-width:0;" >
< input id = "cm-add-email" class = "settings-input" placeholder = "email@example.com" style = "flex:1;min-width:0;" >
< button class = "admin-btn-sm" id = "cm-add-save" > Save < / b u t t o n >
< / d i v >
< 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 || '' ;
} 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 ( ) ;
if ( ! email ) { el ( 'cm-add-email' ) . focus ( ) ; return ; }
try {
await fetch ( '/api/contacts/add' , { method : 'POST' , credentials : 'same-origin' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { name , email } ) } ) ;
} catch ( _ ) { }
el ( 'cm-add-name' ) . value = '' ; el ( 'cm-add-email' ) . value = '' ;
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 || '' ) ) ;
list . innerHTML = contacts . map ( c => {
const emails = ( c . emails || [ ] ) . join ( ', ' ) ;
const phones = ( c . phones || [ ] ) . join ( ', ' ) ;
const sub = [ emails , phones ] . 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 >
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
< 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 >
< 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" >
< 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 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 ) ,
} ;
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 } } ,
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' ) ,
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: 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 >
2026-06-08 10:32:16 -04:00
< 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 >
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 >
< div class = "settings-row" style = "margin-top:10px;align-items:center;" >
< button class = "admin-btn-add" id = "uf-email-save" style = "background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;" >
< 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-test" style = "display:inline-flex;align-items:center;gap:5px;opacity:0.85;" >
< 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 >
< span id = "uf-email-msg" style = "font-size:11px;flex:1;margin-left:8px" > < / s p a n >
< button class = "admin-btn-add" id = "uf-email-cancel" style = "opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;" >
< 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: 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 ) ;
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 ;
}
} ) ;
// "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 || '' ;
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 ( ) ,
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 = '' ;
ico . innerHTML = _spinner ;
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 >
< div class = "settings-row" style = "margin-top:4px;flex-wrap:wrap;gap:4px" >
< button class = "admin-btn-sm" id = "uf-vault-save" > Save Config < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-vault-login" > Login < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-vault-unlock" > Unlock < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-vault-lock" style = "opacity:0.7" > Lock < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-vault-logout" style = "opacity:0.7" > Logout < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-vault-cancel" style = "opacity:0.7" > Cancel < / b u t t o n >
< span id = "uf-vault-msg" style = "font-size:11px;margin-left:4px" > < / s p a n >
< / 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 >
< div style = "display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px" >
$ { srv . needs _oauth ? ` <a href="/api/mcp/oauth/authorize/ ${ srv . id } " target="_blank" class="admin-btn-sm" style="background:var(--red);color:#fff;text-decoration:none">Authorize</a> ` : '' }
< button class = "admin-btn-sm" id = "uf-mcp-reconnect" > Reconnect < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-mcp-toggle" > $ { srv . is _enabled ? 'Disable' : 'Enable' } < / b u t t o n >
< button class = "admin-btn-sm" id = "uf-mcp-cancel" style = "opacity:0.7" > Close < / b u t t o n >
< span id = "uf-mcp-msg" style = "font-size:11px" > < / s p a n >
< / 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 >
< div class = "settings-row" style = "margin-top:4px" > < button class = "admin-btn-sm" id = "uf-mcp-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 " i d = " u f - m c p - 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 > < s p a n i d = " u f - m c p - m s g " s t y l e = " f o n t - s i z e : 1 1 p x " > < / s p a n > < / d i v >
< / 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
} ;
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 tokenRows = agentTokens . length ? agentTokens . map ( t => `
2026-06-03 22:38:05 +09:00
< div class = "uf-codex-token" data - token - id = "${esc(t.id)}" style = "border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-top:8px;" >
< div style = "display:flex;align-items:center;gap:8px;margin-bottom:8px;" >
< div style = "flex:1;min-width:0;" >
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
< input type = "text" class = "uf-codex-rename settings-input" data - token - id = "${esc(t.id)}" value = "${esc(t.name || cfg.defaultName)}" placeholder = "${esc(cfg.defaultName)} (e.g. ${esc(cfg.word)} on laptop)" style = "font-size:12px;font-weight:600;padding:3px 6px;width:100%;background:transparent;border:1px solid transparent;border-radius:4px;" title = "Click to rename this agent" >
< div style = "font-size:10px;opacity:0.52;margin-top:2px;" > $ { esc ( t . token _prefix || 'token' ) } ... $ { t . last _used _at ? ` · Last used ${ new Date ( t . last _used _at ) . toLocaleDateString ( ) } ` : ' · Never used' } < / d i v >
2026-06-03 22:38:05 +09:00
< / 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
< button class = "admin-btn-sm uf-codex-copy-prefix" data - token - prefix = "${esc(t.token_prefix || '')}" title = "Copy token prefix (full token only shown once, at creation)" style = "opacity:0.7" > Copy < / b u t t o n >
2026-06-03 22:38:05 +09:00
< button class = "admin-btn-delete uf-codex-revoke" data - token - id = "${esc(t.id)}" > Revoke < / b u t t o n >
< / d i v >
< div style = "font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;" > Tool access < / d i v >
$ { scopeToggles ( t ) }
< div class = "uf-codex-scope-msg" data - token - id = "${esc(t.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 > ` ) . j o i n ( ' ' ) : ` < d i v s t y l e = " o p a c i t y : 0 . 4 5 ; f o n t - s i z e : 1 1 p x ; p a d d i n g : 8 p x 0 ; " > N o $ { e s c ( c f g . w o r d ) } t o k e n s y e t . < / d i v > ` ;
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
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" > $ { esc ( cfg . label ) } < / h 2 >
< div style = "font-size:11px;opacity:0.65;line-height:1.45;margin:-2px 0 8px;" > Generates a scoped token + setup commands so $ { esc ( cfg . word ) } on your own machine can read / write your Odysseus data ( todos , email , calendar , etc . ) . The agent runs in your terminal — it isn ' t streamed inside Odysseus . < / d i v >
2026-06-03 22:38:05 +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 id = "uf-codex-pending" style = "display:${current ? 'none' : 'block'};font-size:11px;opacity:0.6;padding:6px 0;" > Creating agent ... < / d i v >
< div id = "uf-codex-reveal" style = "display:none;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.04);width:100%;box-sizing:border-box;" >
< div style = "font-weight:600;font-size:12px;margin-bottom:6px;" > $ { esc ( cfg . word ) } setup < / d i v >
< div style = "font-size:11px;opacity:0.62;margin-bottom:4px;" > Copy this token now & mdash ; it will not be shown again . < / d i v >
< code id = "uf-codex-token" style = "display:block;word-break:break-all;font-size:11px;padding:6px 8px;background:rgba(0,0,0,0.08);border-radius:4px;" > < / c o d e >
< div style = "margin-top:6px;" >
< button class = "admin-btn-sm" id = "uf-codex-copy-token" > Copy token < / b u t t o n >
< / d i v >
< div style = "margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;" > Quickstart & mdash ; or copy setup directly in your terminal < / d i v >
< 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 >
< div style = "margin-top:6px;" >
< button class = "admin-btn-sm" id = "uf-codex-copy-setup" > Copy setup < / b u t t o n >
< / d i v >
< div style = "margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;" > Configure access < / d i v >
< div style = "font-size:11px;opacity:0.62;margin-bottom:6px;" > Toggle which Odysseus tools this agent can use . New agents start with chat only . < / d i v >
< div id = "uf-codex-inline-scopes" > < / d i v >
2026-06-03 22:38:05 +09:00
< / 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.62;margin-top:10px;" > $ { agentTokens . length ? 'Existing agents' : 'Agents' } < / d i v >
2026-06-03 22:38:05 +09:00
< div id = "uf-codex-token-list" > $ { tokenRows } < / 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 class = "settings-row" style = "margin-top:10px;align-items:center;" >
< button class = "admin-btn-add" id = "uf-codex-save" style = "background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;" >
< 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 >
< span id = "uf-codex-msg" style = "font-size:11px;flex:1;margin-left:8px" > < / s p a n >
< button class = "admin-btn-add" id = "uf-codex-cancel" style = "opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;" >
< 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-06-03 22:38:05 +09:00
< / d i v >
< / d i v > ` ;
el ( 'uf-codex-cancel' ) ? . addEventListener ( 'click' , ( ) => { formEl . style . display = '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
el ( 'uf-codex-save' ) ? . addEventListener ( 'click' , ( ) => {
2026-06-03 22:38:05 +09:00
const msg = el ( 'uf-codex-msg' ) ;
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 = 'Saved' ; msg . style . color = 'var(--green, #50fa7b)' ; }
setTimeout ( ( ) => { formEl . style . display = 'none' ; } , 350 ) ;
} ) ;
const _autoCreateCodex = async ( ) => {
const msg = el ( 'uf-codex-msg' ) ;
const pending = el ( 'uf-codex-pending' ) ;
const existingNames = new Set ( agentTokens . map ( t => ( t . name || '' ) . trim ( ) ) ) ;
let name = cfg . defaultName ;
let n = 2 ;
while ( existingNames . has ( name ) ) { name = ` ${ cfg . defaultName } ${ n ++ } ` ; }
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' ) ;
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 || '' ) ;
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
// Populate inline scope toggles for the just-created token (Configure access already open)
const newToken = { id : d . id , name , scopes : d . scopes || [ 'chat' ] } ;
const inlineEl = el ( 'uf-codex-inline-scopes' ) ;
if ( inlineEl ) {
inlineEl . innerHTML = `
< div class = "uf-codex-token" data - token - id = "${esc(newToken.id)}" >
$ { scopeToggles ( newToken ) }
< div class = "uf-codex-scope-msg" data - token - id = "${esc(newToken.id)}" style = "font-size:11px;min-height:14px;" > < / d i v >
< / d i v > ` ;
_wireScopeChange ( inlineEl ) ;
}
if ( msg ) {
msg . textContent = ` Created " ${ name } ". ` ;
msg . style . color = 'var(--green, #50fa7b)' ;
}
2026-06-03 22:38:05 +09:00
await renderList ( ) ;
} catch ( err ) {
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
} ;
if ( ! current ) _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 ) ;
} ;
el ( 'uf-codex-copy-setup' ) ? . addEventListener ( 'click' , async ( ) => {
const token = el ( 'uf-codex-copy-setup' ) ? . dataset . token || '' ;
const btn = el ( 'uf-codex-copy-setup' ) ;
if ( ! token ) {
if ( btn ) {
btn . textContent = 'Add agent first' ;
setTimeout ( ( ) => { const latest = el ( 'uf-codex-copy-setup' ) ; if ( latest ) latest . textContent = 'Copy setup' ; } , 1600 ) ;
}
return ;
}
const setup = setupForToken ( token ) ;
const ok = await _copyCodexToken ( setup ) ;
if ( ! btn ) return ;
btn . textContent = ok ? 'Copied setup' : 'Select setup' ;
if ( ! ok ) _selectTextFallback ( setup , 'uf-codex-reveal' ) ;
setTimeout ( ( ) => { const latest = el ( 'uf-codex-copy-setup' ) ; if ( latest ) latest . textContent = 'Copy setup' ; } , 1600 ) ;
} ) ;
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 ;
btn . textContent = ok ? 'Copied token' : 'Select token' ;
if ( ! ok ) _selectTextFallback ( token , 'uf-codex-reveal' ) ;
setTimeout ( ( ) => { const latest = el ( 'uf-codex-copy-token' ) ; if ( latest ) latest . textContent = 'Copy token' ; } , 1600 ) ;
} ) ;
formEl . querySelectorAll ( '.uf-codex-revoke' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
2026-06-09 09:48:59 +09:00
if ( ! await window . styledConfirm ( ` Revoke this ${ cfg . word } token? Integrations using it will lose access. ` , { confirmText : 'Revoke' , danger : true } ) ) return ;
2026-06-03 22:38:05 +09:00
await fetch ( ` /api/tokens/ ${ btn . dataset . tokenId } ` , { 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
formEl . style . display = 'none' ;
2026-06-03 22:38:05 +09:00
await renderList ( ) ;
} ) ;
} ) ;
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
// Rename: PATCH the token's name when the user blurs the input (or hits Enter).
formEl . querySelectorAll ( '.uf-codex-rename' ) . forEach ( input => {
const original = input . value ;
const commit = async ( ) => {
const name = ( input . value || '' ) . trim ( ) ;
if ( ! name || name === original ) return ;
2026-06-03 22:38:05 +09:00
try {
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 r = await fetch ( ` /api/tokens/ ${ input . dataset . tokenId } ` , {
2026-06-03 22:38:05 +09:00
method : 'PATCH' ,
credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
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
body : JSON . stringify ( { name } ) ,
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
if ( ! r . ok ) throw new Error ( 'Save failed' ) ;
input . style . borderColor = 'var(--green, #50fa7b)' ;
setTimeout ( ( ) => { input . style . borderColor = 'transparent' ; } , 800 ) ;
2026-06-03 22:38:05 +09:00
await renderList ( ) ;
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
} catch ( _ ) {
input . value = original ;
input . style . borderColor = 'var(--red)' ;
setTimeout ( ( ) => { input . style . borderColor = 'transparent' ; } , 1200 ) ;
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
} ;
input . addEventListener ( 'blur' , commit ) ;
input . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; input . blur ( ) ; } } ) ;
} ) ;
// Copy token prefix (full token irrecoverable after the one-time creation reveal).
formEl . querySelectorAll ( '.uf-codex-copy-prefix' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
const prefix = btn . dataset . tokenPrefix || '' ;
if ( ! prefix ) return ;
try {
if ( navigator . clipboard && window . isSecureContext ) {
await navigator . clipboard . writeText ( prefix ) ;
} else {
const ta = document . createElement ( 'textarea' ) ;
ta . value = prefix ;
ta . style . cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;' ;
document . body . appendChild ( ta ) ;
ta . select ( ) ;
try { document . execCommand ( 'copy' ) ; } catch ( _ ) { }
ta . remove ( ) ;
}
const label = btn . textContent ;
btn . textContent = 'Copied prefix' ;
setTimeout ( ( ) => { btn . textContent = label ; } , 1400 ) ;
} catch ( _ ) { }
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)' ; }
}
} ) ;
} ) ;
}
_wireScopeChange ( formEl ) ;
2026-06-03 22:38:05 +09:00
}
2026-05-31 23:58:26 +09:00
// ── Add button with type picker ──
if ( addBtn ) {
addBtn . addEventListener ( 'click' , ( ) => {
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
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 ( '' ) ;
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 d d 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" >
< div class = "settings-row" > < label class = "settings-label" > Type < / l a b e l >
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 = "position:relative;flex:1;min-width:0;" >
< button type = "button" id = "uf-type-trigger" class = "settings-select" style = "display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;" >
< span class = "uf-type-icon" style = "display:inline-flex;color:var(--accent, var(--red));" > < / s p a n >
< span class = "uf-type-label" style = "flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.65;" > Select ... < / 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-type-menu" style = "display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);" > $ { _rowsHtml } < / d i v >
< / d i v >
2026-05-31 23:58:26 +09:00
< / d i v >
< / 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
const trigger = el ( 'uf-type-trigger' ) ;
const menu = el ( 'uf-type-menu' ) ;
const labelEl = trigger . querySelector ( '.uf-type-label' ) ;
const iconEl = trigger . querySelector ( '.uf-type-icon' ) ;
const _closeMenu = ( ) => { menu . style . display = 'none' ; } ;
const _openMenu = ( ) => {
menu . style . display = 'block' ;
// Drop-up when there's not enough room below the trigger (mobile
// landscape / docked keyboard / long lists near the bottom of screen).
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 ( '.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' ; } ) ;
btn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const k = btn . dataset . value ;
const lbl = btn . querySelector ( 'span:last-child' ) ? . textContent || '' ;
if ( labelEl ) { labelEl . textContent = lbl ; labelEl . style . opacity = '1' ; }
if ( iconEl ) iconEl . innerHTML = _iconFor ( k ) ;
_closeMenu ( ) ;
showForm ( k , 'new' ) ;
} ) ;
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' ) ;
}
}
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 ;