* fix: approval pending check broken by stale has_pending import (#228) api/routes.py imported has_pending/pop_pending from tools.approval, but the agent module renamed has_pending to has_blocking_approval (checks gateway queue, not _pending dict) and removed pop_pending. The import fell through to fallback lambdas that always returned False, making GET /api/approval/pending always return {pending:null} even after a successful inject_test. Fix: check _pending directly under _lock — same dict submit_pending writes to. Stale imports removed. Before: 554 pass, 1 fail | After: 555 pass, 0 fail * fix: move login JS into external file, remove inline handlers (#226) Login page used inline onsubmit/onkeydown handlers and an inline <script> block — all blocked by strict script-src CSP, causing silent login failure. Fix: extract doLogin() and Enter key listener into static/login.js (served from /static/, already a public path). Form uses id='login-form' and data-* attributes for i18n strings instead of injected JS literals. Also guards res.json() parse with try/catch so non-JSON error bodies (e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'. Fixes #222. * fix: improve update error messages when pull fails (#227) _apply_update_inner() ran git pull --ff-only and returned only raw stderr on failure, making all failure modes indistinguishable. Fix: explicit git fetch before pull; if fetch fails, returns human-readable network error. Diverged history and missing upstream tracking branch each get distinct messages with exact recovery commands. Generic fallback truncates to 300 chars and shows sentinel when git produces no output. Also adds tests/test_update_checker.py with 13 tests covering all 4 new diagnostic code paths (0 tests existed before). Fixes #223. * fix: stabilize 30s terminal approval prompt visibility (#225) Adds minimum 30-second visibility guard for the approval card using _approvalVisibleSince, _approvalHideTimer, and a signature fingerprint to deduplicate repeated poll ticks. Fix: respondApproval() and all stream-end paths (done/cancel/apperror/ error/start-error) now call hideApprovalCard(true) so the card hides immediately when the user responds or the session ends. The 30s guard only applies to mid-session poll ticks where the approval is still live but briefly absent. Adds 11 structural tests covering the new timer variables, force parameter, force-on-respond, force-on-stream-end, and poll-loop no-force behavior. * feat: replace emoji icons with self-hosted Lucide SVG icons (#221) Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled in static/icons.js (no CDN dependency). Adds li(name) function returning inline SVG geometry from a hardcoded whitelist — unknown keys return '' so dynamic server-supplied names never inject arbitrary SVG. Changes: - static/icons.js: new file with 21 icon paths + li() renderer - static/index.html: all nav/action buttons now use li() icons - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons - static/messages.js: cancelStream button uses SVG square stop icon - .gitignore: adds node_modules/ entry Verified: all 35 onclick= functions exist in JS, all 21 li() calls reference defined icons, applyBotName() selectors intact, version label present, no removed IDs referenced by JS. * docs: v0.44.0 release notes, bump version, update test counts --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
486 lines
20 KiB
JavaScript
486 lines
20 KiB
JavaScript
async function send(){
|
|
const text=$('msg').value.trim();
|
|
if(!text&&!S.pendingFiles.length)return;
|
|
// Slash command intercept -- local commands handled without agent round-trip
|
|
if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){
|
|
$('msg').value='';autoResize();hideCmdDropdown();return;
|
|
}
|
|
// Don't send while an inline message edit is active
|
|
if(document.querySelector('.msg-edit-area'))return;
|
|
// If busy, queue the message instead of dropping it
|
|
if(S.busy){
|
|
if(text){
|
|
MSG_QUEUE.push(text);
|
|
$('msg').value='';autoResize();
|
|
updateQueueBadge();
|
|
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'\u2026':''}"`,2000);
|
|
}
|
|
return;
|
|
}
|
|
if(!S.session){await newSession();await renderSessionList();}
|
|
|
|
const activeSid=S.session.session_id;
|
|
|
|
setStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':'Sending…');
|
|
let uploaded=[];
|
|
try{uploaded=await uploadPendingFiles();}
|
|
catch(e){if(!text){setStatus(`Upload error: ${e.message}`);return;}}
|
|
|
|
let msgText=text;
|
|
if(uploaded.length&&!msgText)msgText=`I've uploaded ${uploaded.length} file(s): ${uploaded.join(', ')}`;
|
|
else if(uploaded.length)msgText=`${text}\n\n[Attached files: ${uploaded.join(', ')}]`;
|
|
if(!msgText){setStatus('Nothing to send');return;}
|
|
|
|
$('msg').value='';autoResize();
|
|
const displayText=text||(uploaded.length?`Uploaded: ${uploaded.join(', ')}`:'(file upload)');
|
|
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploaded:undefined,_ts:Date.now()/1000};
|
|
S.toolCalls=[]; // clear tool calls from previous turn
|
|
clearLiveToolCards(); // clear any leftover live cards from last turn
|
|
S.messages.push(userMsg);renderMessages();appendThinking();setBusy(true); // activity bar shown via setBusy
|
|
INFLIGHT[activeSid]={messages:[...S.messages],uploaded};
|
|
startApprovalPolling(activeSid);
|
|
S.activeStreamId = null; // will be set after stream starts
|
|
|
|
// Set provisional title from user message immediately so session appears
|
|
// in the sidebar right away with a meaningful name (server may refine later)
|
|
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
|
|
const provisionalTitle=displayText.slice(0,64);
|
|
S.session.title=provisionalTitle;
|
|
syncTopbar();
|
|
// Persist it and refresh the sidebar now -- don't wait for done
|
|
api('/api/session/rename',{method:'POST',body:JSON.stringify({
|
|
session_id:activeSid, title:provisionalTitle
|
|
})}).catch(()=>{}); // fire-and-forget, server refines on done
|
|
renderSessionList(); // session appears in sidebar immediately
|
|
} else {
|
|
renderSessionList(); // ensure it's visible even if already titled
|
|
}
|
|
|
|
// Start the agent via POST, get a stream_id back
|
|
let streamId;
|
|
try{
|
|
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
|
session_id:activeSid,message:msgText,
|
|
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
|
|
attachments:uploaded.length?uploaded:undefined
|
|
})});
|
|
streamId=startData.stream_id;
|
|
S.activeStreamId = streamId;
|
|
markInflight(activeSid, streamId);
|
|
// Show Cancel button
|
|
const cancelBtn=$('btnCancel');
|
|
if(cancelBtn) cancelBtn.style.display='inline-flex';
|
|
}catch(e){
|
|
delete INFLIGHT[activeSid];
|
|
stopApprovalPolling();
|
|
// Only hide approval card if it belongs to the session that just finished
|
|
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);removeThinking();
|
|
S.messages.push({role:'assistant',content:`**Error:** ${e.message}`});
|
|
renderMessages();setBusy(false);setStatus('Error: '+e.message);
|
|
return;
|
|
}
|
|
|
|
// Open SSE stream and render tokens live
|
|
let assistantText='';
|
|
let assistantRow=null;
|
|
let assistantBody=null;
|
|
// Thinking tag patterns for streaming display
|
|
const _thinkPairs=[
|
|
{open:'<think>',close:'</think>'},
|
|
{open:'<|channel>thought\n',close:'<channel|>'}
|
|
];
|
|
|
|
function ensureAssistantRow(){
|
|
if(assistantRow)return;
|
|
removeThinking();
|
|
const tr=$('toolRunningRow');if(tr)tr.remove();
|
|
$('emptyState').style.display='none';
|
|
assistantRow=document.createElement('div');assistantRow.className='msg-row';
|
|
assistantBody=document.createElement('div');assistantBody.className='msg-body';
|
|
const role=document.createElement('div');role.className='msg-role assistant';
|
|
const _bn=window._botName||'Hermes';
|
|
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase();
|
|
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn;
|
|
role.appendChild(icon);role.appendChild(lbl);
|
|
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
|
|
$('msgInner').appendChild(assistantRow);
|
|
}
|
|
|
|
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
|
|
let _reconnectAttempted=false;
|
|
|
|
// rAF-throttled rendering: buffer tokens, render at most once per frame
|
|
let _renderPending=false;
|
|
// Extract display text from assistantText, stripping completed thinking blocks
|
|
// and hiding content still inside an open thinking block.
|
|
function _streamDisplay(){
|
|
const raw=assistantText;
|
|
for(const {open,close} of _thinkPairs){
|
|
if(raw.startsWith(open)){
|
|
const ci=raw.indexOf(close,open.length);
|
|
if(ci!==-1){
|
|
// Thinking block complete — strip it, show the rest
|
|
return raw.slice(ci+close.length).replace(/^\s+/,'');
|
|
}
|
|
// Still inside thinking block — show placeholder
|
|
return '';
|
|
}
|
|
// Hide partial tag prefixes while streaming so users don't see
|
|
// `<thi`, `<think`, etc. before the model finishes the token.
|
|
if(open.startsWith(raw)) return '';
|
|
}
|
|
return raw;
|
|
}
|
|
function _scheduleRender(){
|
|
if(_renderPending) return;
|
|
_renderPending=true;
|
|
requestAnimationFrame(()=>{
|
|
_renderPending=false;
|
|
if(assistantBody){
|
|
const txt=_streamDisplay();
|
|
const isThinking=!txt&&assistantText.length>0;
|
|
assistantBody.innerHTML=txt?renderMd(txt):(isThinking?'<span style="color:var(--muted);font-size:13px">Thinking\u2026</span>':'');
|
|
}
|
|
scrollIfPinned();
|
|
});
|
|
}
|
|
|
|
function _wireSSE(source){
|
|
source.addEventListener('token',e=>{
|
|
if(!S.session||S.session.session_id!==activeSid) return;
|
|
const d=JSON.parse(e.data);
|
|
assistantText+=d.text;
|
|
ensureAssistantRow();
|
|
_scheduleRender();
|
|
});
|
|
|
|
source.addEventListener('tool',e=>{
|
|
const d=JSON.parse(e.data);
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
setStatus(`${d.name}${d.preview?' · '+d.preview.slice(0,55):''}`);
|
|
}
|
|
if(!S.session||S.session.session_id!==activeSid) return;
|
|
removeThinking();
|
|
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
|
|
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
|
|
S.toolCalls.push(tc);
|
|
appendLiveToolCard(tc);
|
|
scrollIfPinned();
|
|
});
|
|
|
|
source.addEventListener('approval',e=>{
|
|
const d=JSON.parse(e.data);
|
|
d._session_id=activeSid;
|
|
showApprovalCard(d);
|
|
playNotificationSound();
|
|
sendBrowserNotification('Approval required',d.description||'Tool approval needed');
|
|
});
|
|
|
|
source.addEventListener('done',e=>{
|
|
source.close();
|
|
const d=JSON.parse(e.data);
|
|
delete INFLIGHT[activeSid];
|
|
clearInflight();
|
|
stopApprovalPolling();
|
|
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard(true);
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;
|
|
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
|
}
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.session=d.session;S.messages=d.session.messages||[];
|
|
// Stamp _ts on the last assistant message if it has no timestamp
|
|
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
|
|
if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
|
|
if(d.usage){S.lastUsage=d.usage;_syncCtxIndicator(d.usage);}
|
|
if(d.session.tool_calls&&d.session.tool_calls.length){
|
|
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
|
|
} else {
|
|
S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
|
|
}
|
|
if(uploaded.length){
|
|
const lastUser=[...S.messages].reverse().find(m=>m.role==='user');
|
|
if(lastUser)lastUser.attachments=uploaded;
|
|
}
|
|
clearLiveToolCards();
|
|
S.busy=false;
|
|
syncTopbar();renderMessages();loadDir('.');
|
|
}
|
|
renderSessionList();setBusy(false);setStatus('');
|
|
playNotificationSound();
|
|
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
|
|
});
|
|
|
|
source.addEventListener('compressed',e=>{
|
|
// Context was auto-compressed during this turn -- show a system message
|
|
if(!S.session||S.session.session_id!==activeSid) return;
|
|
try{
|
|
const d=JSON.parse(e.data);
|
|
const sysMsg={role:'assistant',content:'*[Context was auto-compressed to continue the conversation]*'};
|
|
S.messages.push(sysMsg);
|
|
showToast(d.message||'Context compressed');
|
|
}catch(err){}
|
|
});
|
|
|
|
source.addEventListener('apperror',e=>{
|
|
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
|
|
// This is distinct from the SSE network 'error' event below.
|
|
source.close();
|
|
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
|
clearLiveToolCards();if(!assistantText)removeThinking();
|
|
try{
|
|
const d=JSON.parse(e.data);
|
|
const isRateLimit=d.type==='rate_limit';
|
|
const label=isRateLimit?'Rate limit reached':'Error';
|
|
const hint=d.hint?`\n\n*${d.hint}*`:'';
|
|
S.messages.push({role:'assistant',content:`**${label}:** ${d.message}${hint}`});
|
|
}catch(_){
|
|
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
|
|
}
|
|
renderMessages();
|
|
}else if(typeof trackBackgroundError==='function'){
|
|
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
|
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
|
|
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
|
|
}
|
|
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
|
|
});
|
|
|
|
source.addEventListener('warning',e=>{
|
|
// Non-fatal warning from server (e.g. fallback activated, retrying)
|
|
if(!S.session||S.session.session_id!==activeSid) return;
|
|
try{
|
|
const d=JSON.parse(e.data);
|
|
// Show as a small inline notice, not a full error
|
|
setStatus(`${d.message||'Warning'}`);
|
|
// If it's a fallback notice, show it briefly then clear
|
|
if(d.type==='fallback') setTimeout(()=>setStatus(''),4000);
|
|
}catch(_){}
|
|
});
|
|
|
|
source.addEventListener('error',e=>{
|
|
source.close();
|
|
// Attempt one reconnect if the stream is still active server-side
|
|
if(!_reconnectAttempted && streamId){
|
|
_reconnectAttempted=true;
|
|
setStatus('Connection lost \u2014 reconnecting\u2026');
|
|
setTimeout(async()=>{
|
|
try{
|
|
const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`);
|
|
if(st.active){
|
|
setStatus('Reconnected');
|
|
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
|
|
return;
|
|
}
|
|
}catch(_){}
|
|
_handleStreamError();
|
|
},1500);
|
|
return;
|
|
}
|
|
_handleStreamError();
|
|
});
|
|
|
|
source.addEventListener('cancel',e=>{
|
|
source.close();
|
|
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;const _cbc=$('btnCancel');if(_cbc)_cbc.style.display='none';
|
|
}
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
clearLiveToolCards();if(!assistantText)removeThinking();
|
|
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
|
|
}
|
|
renderSessionList();
|
|
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('');}
|
|
});
|
|
}
|
|
|
|
function _handleStreamError(){
|
|
delete INFLIGHT[activeSid];clearInflight();stopApprovalPolling();
|
|
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
|
|
if(S.session&&S.session.session_id===activeSid){
|
|
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
|
clearLiveToolCards();if(!assistantText)removeThinking();
|
|
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
|
|
}else{
|
|
// User switched away — show background error banner
|
|
if(typeof trackBackgroundError==='function'){
|
|
// Look up session title from the session list cache so the banner names it correctly
|
|
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
|
|
trackBackgroundError(activeSid,_errTitle,'Connection lost');
|
|
}
|
|
}
|
|
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setStatus('Error: Connection lost');}
|
|
}
|
|
|
|
_wireSSE(new EventSource(new URL(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.origin).href,{withCredentials:true}));
|
|
|
|
}
|
|
|
|
function transcript(){
|
|
const lines=[`# Hermes session ${S.session?.session_id||''}`,``,
|
|
`Workspace: ${S.session?.workspace||''}`,`Model: ${S.session?.model||''}`,``];
|
|
for(const m of S.messages){
|
|
if(!m||m.role==='tool')continue;
|
|
let c=m.content||'';
|
|
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('\n');
|
|
const ct=String(c).trim();
|
|
if(!ct&&!m.attachments?.length)continue;
|
|
const attach=m.attachments?.length?`\n\n_Files: ${m.attachments.join(', ')}_`:'';
|
|
lines.push(`## ${m.role}`,'',ct+attach,'');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function autoResize(){const el=$('msg');el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px';updateSendBtn();}
|
|
|
|
|
|
// ── Approval polling ──
|
|
let _approvalPollTimer = null;
|
|
let _approvalHideTimer = null;
|
|
let _approvalVisibleSince = 0;
|
|
let _approvalSignature = '';
|
|
const APPROVAL_MIN_VISIBLE_MS = 30000;
|
|
|
|
// showApprovalCard moved above respondApproval
|
|
|
|
function _clearApprovalHideTimer() {
|
|
if (_approvalHideTimer) {
|
|
clearTimeout(_approvalHideTimer);
|
|
_approvalHideTimer = null;
|
|
}
|
|
}
|
|
|
|
function _resetApprovalCardState() {
|
|
_clearApprovalHideTimer();
|
|
_approvalVisibleSince = 0;
|
|
_approvalSignature = '';
|
|
}
|
|
|
|
function hideApprovalCard(force=false) {
|
|
const card = $("approvalCard");
|
|
if (!card) return;
|
|
if (!force && _approvalVisibleSince) {
|
|
const remaining = APPROVAL_MIN_VISIBLE_MS - (Date.now() - _approvalVisibleSince);
|
|
if (remaining > 0) {
|
|
const scheduledSignature = _approvalSignature;
|
|
_clearApprovalHideTimer();
|
|
_approvalHideTimer = setTimeout(() => {
|
|
_approvalHideTimer = null;
|
|
if (_approvalSignature !== scheduledSignature) return;
|
|
hideApprovalCard(true);
|
|
}, remaining);
|
|
return;
|
|
}
|
|
}
|
|
_approvalSessionId = null;
|
|
_resetApprovalCardState();
|
|
card.classList.remove("visible");
|
|
$("approvalCmd").textContent = "";
|
|
$("approvalDesc").textContent = "";
|
|
}
|
|
|
|
// Track session_id of the active approval so respond goes to the right session
|
|
let _approvalSessionId = null;
|
|
|
|
function showApprovalCard(pending) {
|
|
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
|
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
|
const cmd = pending.command || "";
|
|
const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null});
|
|
const card = $("approvalCard");
|
|
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
|
|
$("approvalDesc").textContent = desc;
|
|
$("approvalCmd").textContent = cmd;
|
|
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
|
|
_approvalSignature = sig;
|
|
if (!sameApproval) {
|
|
_approvalVisibleSince = Date.now();
|
|
_clearApprovalHideTimer();
|
|
}
|
|
// Re-enable buttons in case a previous approval disabled them
|
|
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
|
|
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
|
|
});
|
|
card.classList.add("visible");
|
|
if (!sameApproval) card.scrollIntoView({block:"nearest", behavior:"smooth"});
|
|
// Apply current locale to data-i18n elements inside the card
|
|
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
|
|
// Focus Allow once button so Enter works immediately
|
|
const onceBtn = $("approvalBtnOnce");
|
|
if (onceBtn) setTimeout(() => onceBtn.focus(), 50);
|
|
}
|
|
|
|
async function respondApproval(choice) {
|
|
const sid = _approvalSessionId || (S.session && S.session.session_id);
|
|
if (!sid) return;
|
|
// Disable all buttons immediately to prevent double-submit
|
|
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
|
|
const b = $(id);
|
|
if (b) { b.disabled = true; if (b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) b.classList.add("loading"); }
|
|
});
|
|
_approvalSessionId = null;
|
|
hideApprovalCard(true);
|
|
try {
|
|
await api("/api/approval/respond", {
|
|
method: "POST",
|
|
body: JSON.stringify({ session_id: sid, choice })
|
|
});
|
|
} catch(e) { setStatus(t("approval_responding") + " " + e.message); }
|
|
}
|
|
|
|
function startApprovalPolling(sid) {
|
|
stopApprovalPolling();
|
|
_approvalPollTimer = setInterval(async () => {
|
|
if (!S.busy || !S.session || S.session.session_id !== sid) {
|
|
stopApprovalPolling(); hideApprovalCard(true); return;
|
|
}
|
|
try {
|
|
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid));
|
|
if (data.pending) { data.pending._session_id=sid; showApprovalCard(data.pending); }
|
|
else { hideApprovalCard(); }
|
|
} catch(e) { /* ignore poll errors */ }
|
|
}, 1500);
|
|
}
|
|
|
|
function stopApprovalPolling() {
|
|
if (_approvalPollTimer) { clearInterval(_approvalPollTimer); _approvalPollTimer = null; }
|
|
}
|
|
|
|
// ── Notifications and Sound ──────────────────────────────────────────────────
|
|
|
|
function playNotificationSound(){
|
|
if(!window._soundEnabled) return;
|
|
try{
|
|
const ctx=new (window.AudioContext||window.webkitAudioContext)();
|
|
const osc=ctx.createOscillator();
|
|
const gain=ctx.createGain();
|
|
osc.connect(gain);gain.connect(ctx.destination);
|
|
osc.type='sine';osc.frequency.setValueAtTime(660,ctx.currentTime);
|
|
osc.frequency.setValueAtTime(880,ctx.currentTime+0.1);
|
|
gain.gain.setValueAtTime(0.3,ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01,ctx.currentTime+0.3);
|
|
osc.start(ctx.currentTime);osc.stop(ctx.currentTime+0.3);
|
|
osc.onended=()=>ctx.close();
|
|
}catch(e){console.warn('Notification sound failed:',e);}
|
|
}
|
|
|
|
function sendBrowserNotification(title,body){
|
|
if(!window._notificationsEnabled||!document.hidden) return;
|
|
if(!('Notification' in window)) return;
|
|
const botName=window._botName||'Hermes';
|
|
if(Notification.permission==='granted'){
|
|
new Notification(title||botName,{body:body});
|
|
}else if(Notification.permission!=='denied'){
|
|
Notification.requestPermission().then(p=>{
|
|
if(p==='granted') new Notification(title||botName,{body:body});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|