diff --git a/static/panels.js b/static/panels.js
index 3025f60a..e8599331 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -119,6 +119,11 @@ function _beginSettingsPanelSession() {
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
_settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default';
_pendingSettingsTargetPanel = null;
+ if (_settingsAppearanceAutosaveTimer) {
+ clearTimeout(_settingsAppearanceAutosaveTimer);
+ _settingsAppearanceAutosaveTimer = null;
+ }
+ _settingsAppearanceAutosaveRetryPayload = null;
_resetSettingsPanelState();
}
@@ -2485,6 +2490,8 @@ let _settingsFontSizeOnOpen = null; // track font size at open time for discard
let _settingsHermesDefaultModelOnOpen = '';
let _settingsSection = 'conversation';
let _currentSettingsSection = 'conversation';
+let _settingsAppearanceAutosaveTimer = null;
+let _settingsAppearanceAutosaveRetryPayload = null;
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
@@ -2541,6 +2548,7 @@ function toggleSettings(){
function _resetSettingsPanelState(){
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
+ _setAppearanceAutosaveStatus('');
}
function _hideSettingsPanel(){
@@ -2562,18 +2570,9 @@ function _closeSettingsPanel(){
// Revert live DOM/localStorage to what they were when the panel opened
function _revertSettingsPreview(){
- if(_settingsThemeOnOpen){
- localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
- if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
- }
- if(_settingsSkinOnOpen){
- localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
- if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen);
- }
- if(_settingsFontSizeOnOpen){
- localStorage.setItem('hermes-font-size', _settingsFontSizeOnOpen);
- if(typeof _applyFontSize==='function') _applyFontSize(_settingsFontSizeOnOpen);
- }
+ // Appearance controls autosave immediately. Closing/discarding the settings
+ // panel must not roll back theme, skin, or font-size after the user sees the
+ // inline saved state.
}
// Show the "Unsaved changes" bar inside the settings panel
@@ -2604,6 +2603,71 @@ function _markSettingsDirty(){
_settingsDirty = true;
}
+function _appearancePayloadFromUi(){
+ return {
+ theme: ($('settingsTheme')||{}).value || localStorage.getItem('hermes-theme') || 'dark',
+ skin: ($('settingsSkin')||{}).value || localStorage.getItem('hermes-skin') || 'default',
+ font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
+ };
+}
+
+function _setAppearanceAutosaveStatus(state){
+ const el=$('settingsAppearanceAutosaveStatus');
+ if(!el) return;
+ el.className='settings-autosave-status';
+ if(!state){
+ el.textContent='';
+ return;
+ }
+ el.classList.add('is-'+state);
+ if(state==='saving'){
+ el.textContent=t('settings_autosave_saving');
+ }else if(state==='saved'){
+ el.textContent=t('settings_autosave_saved');
+ }else if(state==='failed'){
+ el.innerHTML=`
${esc(t('settings_autosave_failed'))} `;
+ }
+}
+
+function _rememberAppearanceSaved(payload){
+ if(!payload) return;
+ _settingsThemeOnOpen=payload.theme||localStorage.getItem('hermes-theme')||'dark';
+ _settingsSkinOnOpen=payload.skin||localStorage.getItem('hermes-skin')||'default';
+ _settingsFontSizeOnOpen=payload.font_size||localStorage.getItem('hermes-font-size')||'default';
+}
+
+function _scheduleAppearanceAutosave(){
+ const payload=_appearancePayloadFromUi();
+ // Keep discard/close behavior aligned with the new mental model: appearance
+ // changes are committed immediately instead of treated as preview-only edits.
+ _rememberAppearanceSaved(payload);
+ _settingsAppearanceAutosaveRetryPayload=payload;
+ _setAppearanceAutosaveStatus('saving');
+ if(_settingsAppearanceAutosaveTimer) clearTimeout(_settingsAppearanceAutosaveTimer);
+ _settingsAppearanceAutosaveTimer=setTimeout(()=>_autosaveAppearanceSettings(payload),350);
+}
+
+async function _autosaveAppearanceSettings(payload){
+ try{
+ const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
+ _settingsAppearanceAutosaveRetryPayload=null;
+ _rememberAppearanceSaved(payload);
+ if(saved&&saved.font_size){
+ localStorage.setItem('hermes-font-size',saved.font_size);
+ }
+ _setAppearanceAutosaveStatus('saved');
+ }catch(e){
+ console.warn('[settings] appearance autosave failed', e);
+ _setAppearanceAutosaveStatus('failed');
+ }
+}
+
+function _retryAppearanceAutosave(){
+ const payload=_settingsAppearanceAutosaveRetryPayload||_appearancePayloadFromUi();
+ _setAppearanceAutosaveStatus('saving');
+ _autosaveAppearanceSettings(payload);
+}
+
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
@@ -2621,7 +2685,9 @@ async function loadSettingsPanel(){
const skinSel=$('settingsSkin');
if(skinSel) skinSel.value=skinVal;
if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal);
- const fontSizeVal=localStorage.getItem('hermes-font-size')||'default';
+ const fontSizeVal=settings.font_size||localStorage.getItem('hermes-font-size')||'default';
+ localStorage.setItem('hermes-font-size',fontSizeVal);
+ if(typeof _applyFontSize==='function') _applyFontSize(fontSizeVal);
const fontSizeSel=$('settingsFontSize');
if(fontSizeSel) fontSizeSel.value=fontSizeVal;
if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal);
@@ -3100,6 +3166,7 @@ async function saveSettings(andClose){
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.skin=skin;
+ body.font_size=fontSize;
body.language=language;
body.show_token_usage=showTokenUsage;
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
diff --git a/static/style.css b/static/style.css
index 1d8a8921..d82a303a 100644
--- a/static/style.css
+++ b/static/style.css
@@ -739,13 +739,18 @@
.msg-media-svg{display:block;max-width:100%;height:auto;border-radius:6px;margin:6px 0;border:1px solid var(--border);background:var(--surface);}
.msg-media-label{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:4px;user-select:none;}
- /* ── Inline audio player ── */
- .msg-media-audio{margin:6px 0;padding:8px 10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;max-width:480px;}
- .msg-media-audio audio{width:100%;height:36px;border-radius:4px;}
-
- /* ── Inline video player ── */
- .msg-media-video{margin:6px 0;background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden;max-width:640px;}
- .msg-media-video video{width:100%;display:block;border-radius:8px 8px 0 0;}
+ /* ── Inline audio/video media editor with speed controls ── */
+ .msg-media-editor{display:flex;flex-direction:column;gap:8px;width:min(520px,100%);max-width:100%;padding:10px;border:1px solid var(--border);border-radius:10px;background:var(--surface);margin:6px 0;}
+ .msg-media-editor--audio{min-width:min(360px,100%);}
+ .msg-media-player{width:100%;display:block;border-radius:8px;}
+ .msg-media-video.msg-media-player{max-height:320px;object-fit:contain;background:#000;border:1px solid var(--border2);}
+ .msg-media-audio.msg-media-player{height:36px;background:transparent;}
+ .msg-media-meta{display:flex;align-items:center;gap:8px;min-width:0;font-size:11px;color:var(--muted);}
+ .msg-media-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .media-speed-controls{display:flex;flex-wrap:wrap;gap:4px;align-items:center;}
+ .media-speed-btn{border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);border-radius:999px;padding:3px 8px;font-size:11px;font-weight:600;line-height:1.2;cursor:pointer;transition:background .15s,border-color .15s,color .15s;}
+ .media-speed-btn:hover{background:var(--hover-bg);color:var(--text);}
+ .media-speed-btn.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text);}
/* ── Composer attachment chips for media ── */
.attach-thumb--svg{background:var(--surface);padding:2px;border-radius:4px;}
@@ -794,6 +799,9 @@
.attach-chip button:hover{color:var(--accent);}
/* Image attachment chips show a thumbnail preview instead of a paperclip chip */
.attach-chip--image{background:transparent;border-color:var(--border);padding:3px;border-radius:6px;}
+ .attach-chip--audio,.attach-chip--video{max-width:260px;}
+ .attach-media-icon{display:inline-flex;align-items:center;color:var(--accent-text);}
+ .attach-chip-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:default;}
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
textarea#msg::placeholder{color:var(--muted);}
@@ -838,10 +846,14 @@
.reasoning-option:hover{background:rgba(255,255,255,.07);}
.reasoning-option.selected{background:var(--accent-bg);}
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
- .composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
+ .composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-model-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
.composer-model-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
+ .composer-model-badge{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;}
+ .composer-model-badge[hidden]{display:none;}
+ .composer-model-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;}
+ .composer-model-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;}
.composer-model-icon,.composer-model-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-model-select{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important;}
.composer-right{display:flex;gap:8px;align-items:center;flex-shrink:0;}
@@ -975,6 +987,11 @@
/* Image preview */
.preview-img-wrap{display:flex;align-items:center;justify-content:center;flex:1;padding:8px 0;min-height:0;}
.preview-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,.4);}
+ .preview-media-wrap{display:flex;align-items:center;justify-content:center;flex:1;min-height:0;padding:12px 0;}
+ .preview-media-wrap .msg-media-editor{width:100%;max-width:100%;background:var(--surface);}
+ .preview-media-wrap .msg-media-video{max-height:60vh;}
+ .preview-pdf-wrap{display:flex;flex:1;min-height:360px;border:1px solid var(--border2);border-radius:8px;overflow:hidden;background:#fff;}
+ .preview-pdf-frame{width:100%;height:100%;min-height:70vh;border:none;background:#fff;}
/* Markdown rendered preview */
.preview-md{font-size:13px;line-height:1.7;color:var(--text);flex:1;overflow-y:auto;min-height:0;}
.preview-md p{margin-bottom:10px;}.preview-md p:last-child{margin-bottom:0;}
@@ -1000,7 +1017,8 @@
.preview-md td code,.preview-md th code{font-size:0.85em;padding:1px 4px;vertical-align:baseline;}
/* File type badge in preview path bar */
.preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;}
- .preview-badge.img{background:var(--accent-bg);color:var(--accent-text);}
+ .preview-badge.img,.preview-badge.image,.preview-badge.audio,.preview-badge.video{background:var(--accent-bg);color:var(--accent-text);}
+ .preview-badge.pdf{background:rgba(239,68,68,.12);color:#f87171;}
.preview-badge.md{background:var(--accent-bg-strong);color:var(--accent-text);}
.preview-badge.code{background:var(--hover-bg);color:var(--muted);}
::-webkit-scrollbar{width:4px;height:4px}
@@ -1144,7 +1162,11 @@
.model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
.model-opt:hover{background:rgba(255,255,255,.07);}
.model-opt.active{background:var(--accent-bg);}
+.model-opt-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;}
.model-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;}
+.model-opt-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;}
+.model-opt-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;}
+.model-opt-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;}
.model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;}
.model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;}
.model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;}
@@ -1901,6 +1923,34 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
#mainSettings .settings-btn:hover,
#mainSettings .sm-btn[onclick^="saveSettings"]:hover{background:var(--accent-hover);border-color:var(--accent-hover);}
+/* Appearance autosave status line (in-place feedback while changing theme/skin/font size). */
+#mainSettings .settings-autosave-status{
+ margin-top:10px;
+ min-height:18px;
+ display:flex;
+ align-items:center;
+ gap:6px;
+ font-size:12px;
+ color:var(--muted);
+}
+#mainSettings .settings-autosave-status.is-saving{color:var(--muted);}
+#mainSettings .settings-autosave-status.is-saved{color:var(--success);}
+#mainSettings .settings-autosave-status.is-failed{color:var(--error);}
+#mainSettings .settings-autosave-status button{
+ border:none;
+ background:var(--accent);
+ color:#fff;
+ font-size:11px;
+ font-weight:600;
+ border-radius:6px;
+ padding:4px 10px;
+ cursor:pointer;
+ margin-left:4px;
+}
+#mainSettings .settings-autosave-status button:hover{
+ background:var(--accent-hover);
+}
+
/* Secondary sm-btn fallback (Sign out / Disable auth) — neutral outline. */
#mainSettings .sm-btn{border-radius:8px;padding:9px 16px;font-weight:600;font-size:13px;background:var(--surface);border:1px solid var(--border);color:var(--text);cursor:pointer;transition:background .15s,border-color .15s;}
#mainSettings .sm-btn:hover{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
diff --git a/static/ui.js b/static/ui.js
index 48872120..6c73fd5f 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -92,13 +92,102 @@ const _PDF_EXTS=/\.pdf$/i;
const _HTML_EXTS=/\.(html?|htm)$/i;
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
const _SVG_EXTS=/\.svg$/i;
-const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
+const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
const _CSV_EXTS=/\.csv$/i;
const _EXCALIDRAW_EXTS=/\.excalidraw$/i;
+// ── Media playback speed controls ─────────────────────────────────────────
+const MEDIA_PLAYBACK_RATES=[0.5,0.75,1,1.25,1.5,2];
+const MEDIA_PLAYBACK_STORAGE_KEY='hermes-media-playback-rate';
+function _getStoredMediaPlaybackRate(){
+ try{
+ const raw=localStorage.getItem(MEDIA_PLAYBACK_STORAGE_KEY);
+ const rate=Number(raw);
+ return MEDIA_PLAYBACK_RATES.includes(rate)?rate:1;
+ }catch(_){return 1;}
+}
+function _setStoredMediaPlaybackRate(rate){
+ if(!MEDIA_PLAYBACK_RATES.includes(rate)) return;
+ try{localStorage.setItem(MEDIA_PLAYBACK_STORAGE_KEY,String(rate));}catch(_){}
+}
+function _syncMediaSpeedButtons(editor, rate){
+ if(!editor) return;
+ editor.querySelectorAll('.media-speed-btn').forEach(b=>{
+ const active=Number(b.dataset.rate)===rate;
+ b.classList.toggle('active',active);
+ b.setAttribute('aria-pressed',active?'true':'false');
+ });
+}
+function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
+ if(!media) return;
+ media.playbackRate=rate;
+ _syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate);
+}
+function _mediaKindForName(name=''){
+ const clean=String(name||'').split('?')[0].toLowerCase();
+ if(_AUDIO_EXTS.test(clean)) return 'audio';
+ if(_VIDEO_EXTS.test(clean)) return 'video';
+ if(_IMAGE_EXTS.test(clean)) return 'image';
+ return '';
+}
+function _mediaSpeedControlsHtml(kind, label){
+ const safeLabel=esc(label||kind||'media');
+ const current=_getStoredMediaPlaybackRate();
+ return `
${MEDIA_PLAYBACK_RATES.map(rate=>``).join('')}
`;
+}
+function _mediaPlayerHtml(kind, src, name, extra=''){
+ const safeName=esc(name||'media');
+ const safeSrc=esc(src);
+ const tag=kind==='video'
+ ? `
`
+ : `
`;
+ return `
`;
+}
+function _renderAttachmentHtml(fname, url){
+ const kind=_mediaKindForName(fname);
+ if(kind==='image') return `
})
`;
+ if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname);
+ return `
${li('paperclip',12)} ${esc(fname)}
`;
+}
+document.addEventListener('click', e => {
+ const btn=e.target&&e.target.closest?e.target.closest('.media-speed-btn'):null;
+ if(!btn) return;
+ const editor=btn.closest('.msg-media-editor,.preview-media-wrap');
+ if(!editor) return;
+ const media=editor.querySelector('audio,video');
+ if(!media) return;
+ const rate=Number(btn.dataset.rate)||1;
+ _setStoredMediaPlaybackRate(rate);
+ _applyMediaPlaybackRate(media,rate);
+});
+document.addEventListener("loadedmetadata", e=>{
+ if(e.target&&e.target.matches&&e.target.matches('.msg-media-player,audio,video')){
+ _applyMediaPlaybackRate(e.target);
+ }
+},true);
+function _initMediaPlaybackObserver(){
+ if(!document.body||window._mediaPlaybackObserver) return;
+ window._mediaPlaybackObserver=new MutationObserver(records=>{
+ for(const rec of records){
+ for(const node of rec.addedNodes||[]){
+ if(!node||node.nodeType!==1) continue;
+ const media=[];
+ if(node.matches&&node.matches('audio,video')) media.push(node);
+ if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video'));
+ media.forEach(m=>_applyMediaPlaybackRate(m));
+ }
+ }
+ });
+ window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true});
+ document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m));
+}
+if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver);
+else _initMediaPlaybackObserver();
+setTimeout(_initMediaPlaybackObserver,0);
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
+window._configuredModelBadges=window._configuredModelBadges||{};
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a