Hermes WebUI v0.1.0 — initial public release
This commit is contained in:
152
static/boot.js
Normal file
152
static/boot.js
Normal file
@@ -0,0 +1,152 @@
|
||||
async function cancelStream(){
|
||||
const streamId = S.activeStreamId;
|
||||
if(!streamId) return;
|
||||
try{
|
||||
await fetch(`/api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`);
|
||||
const btn=$('btnCancel');if(btn)btn.style.display='none';
|
||||
setStatus('Cancelling…');
|
||||
}catch(e){setStatus('Cancel failed: '+e.message);}
|
||||
}
|
||||
|
||||
$('btnSend').onclick=send;
|
||||
$('btnAttach').onclick=()=>$('fileInput').click();
|
||||
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
|
||||
$('btnNewChat').onclick=async()=>{await newSession();await renderSessionList();$('msg').focus();};
|
||||
$('btnDownload').onclick=()=>{
|
||||
if(!S.session)return;
|
||||
const blob=new Blob([transcript()],{type:'text/markdown'});
|
||||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||||
a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
|
||||
};
|
||||
$('btnExportJSON').onclick=()=>{
|
||||
if(!S.session)return;
|
||||
const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
|
||||
const a=document.createElement('a');a.href=url;
|
||||
a.download=`hermes-${S.session.session_id}.json`;a.click();
|
||||
};
|
||||
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
|
||||
$('btnClearPreview').onclick=()=>{
|
||||
$('previewArea').classList.remove('visible');
|
||||
$('previewImg').src='';
|
||||
$('previewMd').innerHTML='';
|
||||
$('previewCode').textContent='';
|
||||
$('previewPathText').textContent='';
|
||||
$('fileTree').style.display='';
|
||||
};
|
||||
// workspacePath click handler removed -- use topbar workspace chip dropdown instead
|
||||
$('modelSelect').onchange=async()=>{
|
||||
if(!S.session)return;
|
||||
const selectedModel=$('modelSelect').value;
|
||||
localStorage.setItem('hermes-webui-model', selectedModel);
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;syncTopbar();
|
||||
};
|
||||
$('msg').addEventListener('input',autoResize);
|
||||
$('msg').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}});
|
||||
// B14: Cmd/Ctrl+K creates a new chat from anywhere
|
||||
document.addEventListener('keydown',async e=>{
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){
|
||||
e.preventDefault();
|
||||
if(!S.busy){await newSession();await renderSessionList();$('msg').focus();}
|
||||
}
|
||||
if(e.key==='Escape'){
|
||||
// Close workspace dropdown
|
||||
closeWsDropdown();
|
||||
// Clear session search
|
||||
const ss=$('sessionSearch');
|
||||
if(ss&&ss.value){ss.value='';filterSessions();}
|
||||
// Cancel any active message edit
|
||||
const editArea=document.querySelector('.msg-edit-area');
|
||||
if(editArea){
|
||||
const bar=editArea.closest('.msg-row')&&editArea.closest('.msg-row').querySelector('.msg-edit-bar');
|
||||
if(bar){const cancel=bar.querySelector('.msg-edit-cancel');if(cancel)cancel.click();}
|
||||
}
|
||||
}
|
||||
});
|
||||
$('msg').addEventListener('paste',e=>{
|
||||
const items=Array.from(e.clipboardData?.items||[]);
|
||||
const imageItems=items.filter(i=>i.type.startsWith('image/'));
|
||||
if(!imageItems.length)return;
|
||||
e.preventDefault();
|
||||
const files=imageItems.map(i=>{
|
||||
const blob=i.getAsFile();
|
||||
const ext=i.type.split('/')[1]||'png';
|
||||
return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type});
|
||||
});
|
||||
addFiles(files);
|
||||
setStatus(`Image pasted: ${files.map(f=>f.name).join(', ')}`);
|
||||
});
|
||||
document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
|
||||
});
|
||||
|
||||
// Boot: restore last session or start fresh
|
||||
// ── Resizable panels ──────────────────────────────────────────────────────
|
||||
(function(){
|
||||
const SIDEBAR_MIN=180, SIDEBAR_MAX=420;
|
||||
const PANEL_MIN=180, PANEL_MAX=500;
|
||||
|
||||
function initResize(handleId, targetEl, edge, minW, maxW, storageKey){
|
||||
const handle = $(handleId);
|
||||
if(!handle || !targetEl) return;
|
||||
|
||||
// Restore saved width
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if(saved) targetEl.style.width = saved + 'px';
|
||||
|
||||
let startX=0, startW=0;
|
||||
|
||||
handle.addEventListener('mousedown', e=>{
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startW = targetEl.getBoundingClientRect().width;
|
||||
handle.classList.add('dragging');
|
||||
document.body.classList.add('resizing');
|
||||
|
||||
const onMove = ev=>{
|
||||
const delta = edge==='right' ? ev.clientX - startX : startX - ev.clientX;
|
||||
const newW = Math.min(maxW, Math.max(minW, startW + delta));
|
||||
targetEl.style.width = newW + 'px';
|
||||
};
|
||||
const onUp = ()=>{
|
||||
handle.classList.remove('dragging');
|
||||
document.body.classList.remove('resizing');
|
||||
localStorage.setItem(storageKey, parseInt(targetEl.style.width));
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// Run after DOM ready (called from boot)
|
||||
window._initResizePanels = function(){
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const rightpanel = document.querySelector('.rightpanel');
|
||||
initResize('sidebarResize', sidebar, 'right', SIDEBAR_MIN, SIDEBAR_MAX, 'hermes-sidebar-w');
|
||||
initResize('rightpanelResize', rightpanel, 'left', PANEL_MIN, PANEL_MAX, 'hermes-panel-w');
|
||||
};
|
||||
})();
|
||||
|
||||
(async()=>{
|
||||
// Restore last-used model preference
|
||||
const savedModel=localStorage.getItem('hermes-webui-model');
|
||||
if(savedModel && $('modelSelect')){
|
||||
$('modelSelect').value=savedModel;
|
||||
// If the value didn't take (model not in list), clear the bad pref
|
||||
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
|
||||
}
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
_initResizePanels();
|
||||
const saved=localStorage.getItem('hermes-webui-session');
|
||||
if(saved){
|
||||
try{await loadSession(saved);await renderSessionList();await checkInflightOnBoot(saved);return;}
|
||||
catch(e){localStorage.removeItem('hermes-webui-session');}
|
||||
}
|
||||
// no saved session - show empty state, wait for user to hit +
|
||||
$('emptyState').style.display='';
|
||||
await renderSessionList();
|
||||
})();
|
||||
|
||||
264
static/index.html
Normal file
264
static/index.html
Normal file
@@ -0,0 +1,264 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hermes</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.1.0 · WebUI</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills">🧩</button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory">🧠</button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces">📁</button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list">✅</button>
|
||||
</div>
|
||||
<!-- Chat panel -->
|
||||
<div class="panel-view active" id="panelChat">
|
||||
<div class="sidebar-section">
|
||||
<button class="new-chat-btn" id="btnNewChat">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New conversation <span style="font-size:10px;opacity:.5;margin-left:4px">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="session-search"><input id="sessionSearch" placeholder="Filter conversations..." oninput="filterSessions()"></div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
<!-- Tasks (cron) panel -->
|
||||
<div class="panel-view" id="panelTasks">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="font-size:11px;color:var(--muted)">Scheduled jobs</div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px" onclick="toggleCronForm()">+ New job</button>
|
||||
</div>
|
||||
<!-- Create job form (hidden by default) -->
|
||||
<div id="cronCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
|
||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:8px">
|
||||
<option value="local">Local (save output only)</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
|
||||
</div>
|
||||
<div id="cronFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Skills panel -->
|
||||
<div class="panel-view" id="panelSkills">
|
||||
<div class="sidebar-section" style="padding-bottom:4px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div class="skills-search" style="flex:1;padding:0"><input id="skillsSearch" placeholder="Search skills..." oninput="filterSkills()"></div>
|
||||
<button class="cron-btn run" style="padding:3px 8px;font-size:10px;flex-shrink:0;margin-left:6px" onclick="toggleSkillForm()">+ New skill</button>
|
||||
</div>
|
||||
<!-- Skill create/edit form (hidden by default) -->
|
||||
<div id="skillCreateForm" style="display:none;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">
|
||||
<input id="skillFormName" placeholder="Skill name (e.g. my-skill)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<input id="skillFormCategory" placeholder="Category (optional, e.g. devops)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px;box-sizing:border-box">
|
||||
<textarea id="skillFormContent" rows="6" placeholder="SKILL.md content (YAML frontmatter + markdown body)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;margin-bottom:6px;box-sizing:border-box"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitSkillSave()">Save skill</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleSkillForm()">Cancel</button>
|
||||
</div>
|
||||
<div id="skillFormError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Memory panel -->
|
||||
<div class="panel-view" id="panelMemory">
|
||||
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
|
||||
<span style="font-size:11px;color:var(--muted)">Personal memory</span>
|
||||
<button class="cron-btn run" id="memEditBtn" style="padding:3px 8px;font-size:10px" onclick="toggleMemoryEdit()">✎ Edit</button>
|
||||
</div>
|
||||
<div class="memory-panel" id="memoryPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
<!-- Memory edit form (hidden by default) -->
|
||||
<div id="memoryEditForm" style="display:none;padding:8px 12px;border-top:1px solid var(--border);flex-shrink:0">
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Editing: <span id="memEditSection">memory</span></div>
|
||||
<textarea id="memEditContent" rows="10" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:11px;outline:none;resize:vertical;font-family:'SF Mono',ui-monospace,monospace;box-sizing:border-box;margin-bottom:6px;line-height:1.5"></textarea>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitMemorySave()">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="closeMemoryEdit()">Cancel</button>
|
||||
</div>
|
||||
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Todo panel -->
|
||||
<div class="panel-view" id="panelTodos">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0">Current task list</div>
|
||||
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
|
||||
</div>
|
||||
<!-- Workspaces panel -->
|
||||
<div class="panel-view" id="panelWorkspaces">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px">Loading...</div></div>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="field-label" style="font-size:10px;letter-spacing:.07em;margin-bottom:4px">MODEL</div>
|
||||
<select id="modelSelect">
|
||||
<optgroup label="OpenAI">
|
||||
<option value="openai/gpt-5.4-mini">GPT-5.4 Mini</option>
|
||||
<option value="openai/gpt-4o">GPT-4o</option>
|
||||
<option value="openai/o3">o3</option>
|
||||
<option value="openai/o4-mini">o4-mini</option>
|
||||
</optgroup>
|
||||
<optgroup label="Anthropic">
|
||||
<option value="anthropic/claude-sonnet-4.6">Claude Sonnet 4.6</option>
|
||||
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
|
||||
<option value="anthropic/claude-haiku-3-5">Claude Haiku 3.5</option>
|
||||
</optgroup>
|
||||
<optgroup label="Other">
|
||||
<option value="google/gemini-2.5-pro">Gemini 2.5 Pro</option>
|
||||
<option value="deepseek/deepseek-chat-v3-0324">DeepSeek V3</option>
|
||||
<option value="meta-llama/llama-4-scout">Llama 4 Scout</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<div id="sidebarWsDisplay" style="display:flex;align-items:center;gap:7px;padding:0 0 8px;cursor:pointer;border-radius:8px;transition:background .15s" onclick="toggleWsDropdown()" title="Switch workspace">
|
||||
<span style="font-size:14px;opacity:.7">📁</span>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="sidebarWsName">Workspace</div>
|
||||
<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px" id="sidebarWsPath"></div>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">▾</span>
|
||||
</div>
|
||||
<div class="sidebar-actions">
|
||||
<button class="sm-btn" id="btnDownload" title="Download as Markdown">↓ Transcript</button>
|
||||
<button class="sm-btn" id="btnExportJSON" title="Export full session as JSON">❬/❭ JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resize-handle" id="sidebarResize"></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<div class="chip model" id="modelChip">GPT-5.4 Mini</div>
|
||||
<div id="wsChipWrap" style="position:relative">
|
||||
<div class="chip ws-chip" id="wsChip" onclick="toggleWsDropdown()" title="Switch workspace" style="cursor:pointer">📁 test-workspace ▾</div>
|
||||
<div class="ws-dropdown" id="wsDropdown"></div>
|
||||
</div>
|
||||
<button class="chip clear-btn" id="btnClearConv" onclick="clearConversation()" title="Clear all messages in this conversation" style="display:none">🗑 Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo">🦉</div>
|
||||
<h2>What can I help with?</h2>
|
||||
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
|
||||
<div class="suggestion-grid">
|
||||
<button class="suggestion" data-msg="What files are in this workspace?">📁 What files are in this workspace?</button>
|
||||
<button class="suggestion" data-msg="What's on my schedule today?">📋 What's on my schedule today?</button>
|
||||
<button class="suggestion" data-msg="Help me plan a small project.">🗺 Help me plan a small project.</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-inner" id="msgInner"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
<span id="reconnectMsg">⚠ A response may have been in progress when you last left. Reload messages?</span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<button class="reconnect-btn" onclick="dismissReconnect()">Dismiss</button>
|
||||
<button class="reconnect-btn" onclick="refreshSession()">↻ Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="approval-card" id="approvalCard">
|
||||
<div class="approval-inner">
|
||||
<div class="approval-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
Dangerous command — approval required
|
||||
</div>
|
||||
<div class="approval-desc" id="approvalDesc"></div>
|
||||
<div class="approval-cmd" id="approvalCmd"></div>
|
||||
<div class="approval-btns">
|
||||
<button class="approval-btn once" onclick="respondApproval('once')">✓ Allow once</button>
|
||||
<button class="approval-btn session" onclick="respondApproval('session')">🔒 Allow this session</button>
|
||||
<button class="approval-btn always" onclick="respondApproval('always')">☆ Always allow</button>
|
||||
<button class="approval-btn deny" onclick="respondApproval('deny')">✕ Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Activity bar: shows tool progress / status above composer (not inside input) -->
|
||||
<div id="activityBar" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;">
|
||||
<div id="activityBarInner" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:12px;color:var(--muted);animation:fadeIn .15s ease;">
|
||||
<span id="activityIcon" style="font-size:13px;opacity:.6">⚙</span>
|
||||
<span id="activityText" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button id="btnCancel" onclick="cancelStream()" style="display:none;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.35);color:#e94560;font-size:11px;font-weight:600;padding:3px 10px;border-radius:6px;cursor:pointer;flex-shrink:0;transition:background .15s" title="Cancel this task">■ Cancel</button>
|
||||
<button id="btnDismissStatus" onclick="setStatus('')" style="display:none;background:none;border:none;color:var(--muted);font-size:14px;line-height:1;cursor:pointer;padding:0 2px;opacity:.5;flex-shrink:0" title="Dismiss">✕</button>
|
||||
<span id="activityDots" style="display:flex;gap:3px;align-items:center">
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite"></span>
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .22s infinite"></span>
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out .44s infinite"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-wrap" id="composerWrap">
|
||||
<div class="composer-box" id="composerBox">
|
||||
<div class="drop-hint" id="dropHint">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Drop files to upload to workspace
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
<input type="file" id="fileInput" multiple accept="image/*,text/*,application/pdf,application/json,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env" style="display:none">
|
||||
<button class="icon-btn" id="btnAttach" title="Attach files">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<button class="send-btn" id="btnSend">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="rightpanel">
|
||||
<div class="resize-handle" id="rightpanelResize"></div>
|
||||
<div class="panel-header">
|
||||
<span>Workspace</span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">↻</button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-tree" id="fileTree"></div>
|
||||
<div class="preview-area" id="previewArea">
|
||||
<div class="preview-path" id="previewPath">
|
||||
<span id="previewPathText"></span>
|
||||
<span class="preview-badge" id="previewBadge"></span>
|
||||
<button id="btnDownloadFile" class="panel-icon-btn" style="margin-left:auto;font-size:12px;width:auto;padding:2px 8px" onclick="downloadFile(_previewCurrentPath)" title="Download file to your computer">⇩ Download</button>
|
||||
<button id="btnEditFile" class="panel-icon-btn" style="font-size:12px;width:auto;padding:2px 8px;display:none" onclick="toggleEditMode()">✎ Edit</button>
|
||||
</div>
|
||||
<pre class="preview-code" id="previewCode"></pre>
|
||||
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
|
||||
<div class="preview-md" id="previewMd" style="display:none"></div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="/static/ui.js"></script>
|
||||
<script src="/static/workspace.js"></script>
|
||||
<script src="/static/sessions.js"></script>
|
||||
<script src="/static/messages.js"></script>
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
310
static/messages.js
Normal file
310
static/messages.js
Normal file
@@ -0,0 +1,310 @@
|
||||
async function send(){
|
||||
const text=$('msg').value.trim();
|
||||
if(!text&&!S.pendingFiles.length)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(`❌ ${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};
|
||||
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='';
|
||||
}catch(e){
|
||||
delete INFLIGHT[activeSid];
|
||||
stopApprovalPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();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;
|
||||
|
||||
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 icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent='H';
|
||||
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent='Hermes';
|
||||
role.appendChild(icon);role.appendChild(lbl);
|
||||
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
|
||||
$('msgInner').appendChild(assistantRow);
|
||||
}
|
||||
|
||||
const es=new EventSource(`/api/chat/stream?stream_id=${encodeURIComponent(streamId)}`);
|
||||
|
||||
es.addEventListener('token',e=>{
|
||||
// Guard: if the user switched sessions, don't write tokens to the wrong DOM
|
||||
if(!S.session||S.session.session_id!==activeSid) return;
|
||||
const d=JSON.parse(e.data);
|
||||
assistantText+=d.text;
|
||||
ensureAssistantRow();
|
||||
assistantBody.innerHTML=renderMd(assistantText);
|
||||
$('messages').scrollTop=$('messages').scrollHeight;
|
||||
});
|
||||
|
||||
es.addEventListener('tool',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
// Only update activity bar if viewing this session
|
||||
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();
|
||||
// Append card to the stable live container -- no renderMessages() call
|
||||
const tc={name:d.name, preview:d.preview||'', args:d.args||{}, snippet:'', done:false};
|
||||
S.toolCalls.push(tc);
|
||||
appendLiveToolCard(tc);
|
||||
$('messages').scrollTop=$('messages').scrollHeight;
|
||||
});
|
||||
|
||||
es.addEventListener('approval',e=>{
|
||||
const d=JSON.parse(e.data);
|
||||
// Tag the approval with the session that owns it so respondApproval uses correct sid
|
||||
d._session_id=activeSid;
|
||||
showApprovalCard(d);
|
||||
});
|
||||
|
||||
es.addEventListener('done',e=>{
|
||||
es.close();
|
||||
const d=JSON.parse(e.data);
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();
|
||||
stopApprovalPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
|
||||
// Only clear active stream state if this is the currently viewed session
|
||||
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||[];
|
||||
// Populate tool calls from server-extracted metadata (has snippets)
|
||||
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();
|
||||
// Set S.busy=false BEFORE renderMessages so the settled tool card
|
||||
// block (!S.busy guard) can render the final grouped cards.
|
||||
S.busy=false;
|
||||
syncTopbar();renderMessages();loadDir('.');
|
||||
}
|
||||
renderSessionList();setBusy(false);setStatus('');
|
||||
});
|
||||
|
||||
es.addEventListener('error',e=>{
|
||||
es.close();
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();
|
||||
stopApprovalPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;
|
||||
const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
|
||||
}
|
||||
let msg='Connection error';
|
||||
try{const d=JSON.parse(e.data);msg=d.message||msg;}catch(_){}
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
clearLiveToolCards();
|
||||
if(!assistantText){removeThinking();}
|
||||
S.messages.push({role:'assistant',content:`**Error:** ${msg}`});
|
||||
renderMessages();
|
||||
}
|
||||
if(!S.session || !INFLIGHT[S.session.session_id]){
|
||||
setBusy(false);setStatus('Error: '+msg);
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('cancel',e=>{
|
||||
es.close();
|
||||
delete INFLIGHT[activeSid];
|
||||
clearInflight();
|
||||
stopApprovalPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
|
||||
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('');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle SSE connection errors (network drop etc)
|
||||
es.onerror=()=>{
|
||||
if(es.readyState===EventSource.CLOSED){
|
||||
delete INFLIGHT[activeSid];
|
||||
stopApprovalPolling();
|
||||
// Only hide approval card if it belongs to the session that just finished
|
||||
if(!_approvalSessionId || _approvalSessionId===activeSid) hideApprovalCard();
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.activeStreamId=null;
|
||||
const _cbo=$('btnCancel');if(_cbo)_cbo.style.display='none';
|
||||
}
|
||||
if(!assistantText&&S.session&&S.session.session_id===activeSid){
|
||||
removeThinking();
|
||||
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});
|
||||
renderMessages();
|
||||
}
|
||||
if(!S.session || !INFLIGHT[S.session.session_id]){
|
||||
setBusy(false);setStatus('');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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';}
|
||||
|
||||
|
||||
// ── Approval polling ──
|
||||
let _approvalPollTimer = null;
|
||||
|
||||
// showApprovalCard moved above respondApproval
|
||||
|
||||
function hideApprovalCard() {
|
||||
$("approvalCard").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) {
|
||||
$("approvalDesc").textContent = pending.description || "";
|
||||
$("approvalCmd").textContent = pending.command || "";
|
||||
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
||||
$("approvalDesc").textContent = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
||||
_approvalSessionId = pending._session_id || (S.session && S.session.session_id) || null;
|
||||
$("approvalCard").classList.add("visible");
|
||||
}
|
||||
|
||||
async function respondApproval(choice) {
|
||||
const sid = _approvalSessionId || (S.session && S.session.session_id);
|
||||
if (!sid) return;
|
||||
hideApprovalCard();
|
||||
_approvalSessionId = null;
|
||||
try {
|
||||
await api("/api/approval/respond", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id: sid, choice })
|
||||
});
|
||||
} catch(e) { setStatus("Approval error: " + e.message); }
|
||||
}
|
||||
|
||||
function startApprovalPolling(sid) {
|
||||
stopApprovalPolling();
|
||||
_approvalPollTimer = setInterval(async () => {
|
||||
if (!S.busy || !S.session || S.session.session_id !== sid) {
|
||||
stopApprovalPolling(); hideApprovalCard(); 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; }
|
||||
}
|
||||
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──
|
||||
|
||||
600
static/panels.js
Normal file
600
static/panels.js
Normal file
@@ -0,0 +1,600 @@
|
||||
let _currentPanel = 'chat';
|
||||
let _skillsData = null; // cached skills list
|
||||
|
||||
async function switchPanel(name) {
|
||||
_currentPanel = name;
|
||||
// Update nav tabs
|
||||
document.querySelectorAll('.nav-tab').forEach(t => t.classList.toggle('active', t.dataset.panel === name));
|
||||
// Update panel views
|
||||
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
|
||||
const panelEl = $('panel' + name.charAt(0).toUpperCase() + name.slice(1));
|
||||
if (panelEl) panelEl.classList.add('active');
|
||||
// Lazy-load panel data
|
||||
if (name === 'tasks') await loadCrons();
|
||||
if (name === 'skills') await loadSkills();
|
||||
if (name === 'memory') await loadMemory();
|
||||
if (name === 'workspaces') await loadWorkspacesPanel();
|
||||
if (name === 'todos') loadTodos();
|
||||
}
|
||||
|
||||
// ── Cron panel ──
|
||||
async function loadCrons() {
|
||||
const box = $('cronList');
|
||||
try {
|
||||
const data = await api('/api/crons');
|
||||
if (!data.jobs || !data.jobs.length) {
|
||||
box.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No scheduled jobs found.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '';
|
||||
for (const job of data.jobs) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'cron-item';
|
||||
item.id = 'cron-' + job.id;
|
||||
const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
|
||||
const statusLabel = job.enabled === false ? 'off' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active';
|
||||
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
|
||||
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'never';
|
||||
item.innerHTML = `
|
||||
<div class="cron-header" onclick="toggleCron('${job.id}')">
|
||||
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
|
||||
<span class="cron-status ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="cron-body" id="cron-body-${job.id}">
|
||||
<div class="cron-schedule">🕑 ${esc(job.schedule_display || job.schedule?.expression || '')} | Next: ${esc(nextRun)} | Last: ${esc(lastRun)}</div>
|
||||
<div class="cron-prompt">${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}</div>
|
||||
<div class="cron-actions">
|
||||
<button class="cron-btn run" onclick="cronRun('${job.id}')">▶ Run now</button>
|
||||
${statusLabel==='paused'
|
||||
? `<button class="cron-btn" onclick="cronResume('${job.id}')">▶│ Resume</button>`
|
||||
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">▮▮ Pause</button>`}
|
||||
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'"')})">✎ Edit</button>
|
||||
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">🗑 Delete</button>
|
||||
</div>
|
||||
<!-- Inline edit form, hidden by default -->
|
||||
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
|
||||
<input id="cron-edit-name-${job.id}" placeholder="Job name" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
|
||||
<input id="cron-edit-schedule-${job.id}" placeholder="Schedule" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:5px;box-sizing:border-box">
|
||||
<textarea id="cron-edit-prompt-${job.id}" rows="3" placeholder="Prompt" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:5px;box-sizing:border-box"></textarea>
|
||||
<div id="cron-edit-err-${job.id}" style="font-size:11px;color:var(--accent);display:none;margin-bottom:5px"></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="cronEditSave('${job.id}')">Save</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="cronEditClose('${job.id}')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cron-output-${job.id}">
|
||||
<div class="cron-last-header" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>Last output</span>
|
||||
<button class="cron-btn" style="padding:1px 8px;font-size:10px" onclick="loadCronHistory('${job.id}',this)">All runs</button>
|
||||
</div>
|
||||
<div class="cron-last" id="cron-out-text-${job.id}" style="color:var(--muted);font-size:11px">Loading…</div>
|
||||
<div id="cron-history-${job.id}" style="display:none"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
box.appendChild(item);
|
||||
// Eagerly load last output for visible items
|
||||
loadCronOutput(job.id);
|
||||
}
|
||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function toggleCronForm(){
|
||||
const form=$('cronCreateForm');
|
||||
if(!form)return;
|
||||
const open=form.style.display!=='none';
|
||||
form.style.display=open?'none':'';
|
||||
if(!open){
|
||||
$('cronFormName').value='';
|
||||
$('cronFormSchedule').value='';
|
||||
$('cronFormPrompt').value='';
|
||||
$('cronFormDeliver').value='local';
|
||||
$('cronFormError').style.display='none';
|
||||
$('cronFormName').focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCronCreate(){
|
||||
const name=$('cronFormName').value.trim();
|
||||
const schedule=$('cronFormSchedule').value.trim();
|
||||
const prompt=$('cronFormPrompt').value.trim();
|
||||
const deliver=$('cronFormDeliver').value;
|
||||
const errEl=$('cronFormError');
|
||||
errEl.style.display='none';
|
||||
if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;}
|
||||
if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;}
|
||||
try{
|
||||
await api('/api/crons/create',{method:'POST',body:JSON.stringify({name:name||undefined,schedule,prompt,deliver})});
|
||||
toggleCronForm();
|
||||
showToast('Job created ✓');
|
||||
await loadCrons();
|
||||
}catch(e){
|
||||
errEl.textContent='Error: '+e.message;errEl.style.display='';
|
||||
}
|
||||
}
|
||||
|
||||
function _cronOutputSnippet(content) {
|
||||
// Extract the response body from a cron output .md file
|
||||
const lines = content.split('\n');
|
||||
const responseIdx = lines.findIndex(l => l.startsWith('## Response') || l.startsWith('# Response'));
|
||||
const body = (responseIdx >= 0 ? lines.slice(responseIdx + 1) : lines).join('\n').trim();
|
||||
return body.slice(0, 600) || '(empty)';
|
||||
}
|
||||
|
||||
async function loadCronOutput(jobId) {
|
||||
try {
|
||||
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=1`);
|
||||
const el = $('cron-out-text-' + jobId);
|
||||
if (!el) return;
|
||||
if (!data.outputs || !data.outputs.length) { el.textContent = '(no runs yet)'; return; }
|
||||
const out = data.outputs[0];
|
||||
const ts = out.filename.replace('.md','').replace(/_/g,' ');
|
||||
el.textContent = ts + '\n\n' + _cronOutputSnippet(out.content);
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadCronHistory(jobId, btn) {
|
||||
const histEl = $('cron-history-' + jobId);
|
||||
if (!histEl) return;
|
||||
// Toggle: if already open, close it
|
||||
if (histEl.style.display !== 'none') {
|
||||
histEl.style.display = 'none';
|
||||
if (btn) btn.textContent = 'All runs';
|
||||
return;
|
||||
}
|
||||
if (btn) btn.textContent = 'Loading…';
|
||||
try {
|
||||
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`);
|
||||
if (!data.outputs || !data.outputs.length) {
|
||||
histEl.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 0">(no runs yet)</div>';
|
||||
} else {
|
||||
histEl.innerHTML = data.outputs.map((out, i) => {
|
||||
const ts = out.filename.replace('.md','').replace(/_/g,' ');
|
||||
const snippet = _cronOutputSnippet(out.content);
|
||||
const id = `cron-hist-run-${jobId}-${i}`;
|
||||
return `<div style="border-top:1px solid var(--border);padding:6px 0">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;cursor:pointer" onclick="document.getElementById('${id}').style.display=document.getElementById('${id}').style.display==='none'?'':'none'">
|
||||
<span style="font-size:11px;font-weight:600;color:var(--muted)">${esc(ts)}</span>
|
||||
<span style="font-size:10px;color:var(--muted);opacity:.6">▸</span>
|
||||
</div>
|
||||
<div id="${id}" style="display:none;font-size:11px;color:var(--muted);white-space:pre-wrap;line-height:1.5;margin-top:4px;max-height:200px;overflow-y:auto">${esc(snippet)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
histEl.style.display = '';
|
||||
if (btn) btn.textContent = 'Hide runs';
|
||||
} catch(e) {
|
||||
if (btn) btn.textContent = 'All runs';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCron(id) {
|
||||
const body = $('cron-body-' + id);
|
||||
if (body) body.classList.toggle('open');
|
||||
}
|
||||
|
||||
async function cronRun(id) {
|
||||
try {
|
||||
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job triggered ✓');
|
||||
setTimeout(() => loadCronOutput(id), 5000);
|
||||
} catch(e) { showToast('Run failed: ' + e.message, 4000); }
|
||||
}
|
||||
|
||||
async function cronPause(id) {
|
||||
try {
|
||||
await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job paused');
|
||||
await loadCrons();
|
||||
} catch(e) { showToast('Pause failed: ' + e.message, 4000); }
|
||||
}
|
||||
|
||||
async function cronResume(id) {
|
||||
try {
|
||||
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job resumed ✓');
|
||||
await loadCrons();
|
||||
} catch(e) { showToast('Resume failed: ' + e.message, 4000); }
|
||||
}
|
||||
|
||||
function cronEditOpen(id, job) {
|
||||
const form = $('cron-edit-' + id);
|
||||
if (!form) return;
|
||||
$('cron-edit-name-' + id).value = job.name || '';
|
||||
$('cron-edit-schedule-' + id).value = job.schedule_display || (job.schedule && job.schedule.expression) || job.schedule || '';
|
||||
$('cron-edit-prompt-' + id).value = job.prompt || '';
|
||||
const errEl = $('cron-edit-err-' + id);
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
form.style.display = '';
|
||||
}
|
||||
|
||||
function cronEditClose(id) {
|
||||
const form = $('cron-edit-' + id);
|
||||
if (form) form.style.display = 'none';
|
||||
}
|
||||
|
||||
async function cronEditSave(id) {
|
||||
const name = $('cron-edit-name-' + id).value.trim();
|
||||
const schedule = $('cron-edit-schedule-' + id).value.trim();
|
||||
const prompt = $('cron-edit-prompt-' + id).value.trim();
|
||||
const errEl = $('cron-edit-err-' + id);
|
||||
if (!schedule) { errEl.textContent = 'Schedule is required'; errEl.style.display = ''; return; }
|
||||
if (!prompt) { errEl.textContent = 'Prompt is required'; errEl.style.display = ''; return; }
|
||||
try {
|
||||
const updates = {job_id: id, schedule, prompt};
|
||||
if (name) updates.name = name;
|
||||
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
|
||||
showToast('Job updated ✓');
|
||||
await loadCrons();
|
||||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
async function cronDelete(id) {
|
||||
if (!confirm('Delete this cron job? This cannot be undone.')) return;
|
||||
try {
|
||||
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
|
||||
showToast('Job deleted');
|
||||
await loadCrons();
|
||||
} catch(e) { showToast('Delete failed: ' + e.message, 4000); }
|
||||
}
|
||||
|
||||
function loadTodos() {
|
||||
const panel = $('todoPanel');
|
||||
if (!panel) return;
|
||||
// Parse the most recent todo state from message history
|
||||
let todos = [];
|
||||
for (let i = S.messages.length - 1; i >= 0; i--) {
|
||||
const m = S.messages[i];
|
||||
if (m && m.role === 'tool') {
|
||||
try {
|
||||
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
|
||||
if (d && Array.isArray(d.todos) && d.todos.length) {
|
||||
todos = d.todos;
|
||||
break;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
if (!todos.length) {
|
||||
panel.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:4px 0">No active task list in this session.</div>';
|
||||
return;
|
||||
}
|
||||
const statusIcon = {pending:'○', in_progress:'◉', completed:'✓', cancelled:'✗'};
|
||||
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
|
||||
panel.innerHTML = todos.map(t => `
|
||||
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
|
||||
<span style="font-size:14px;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||'○'}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px;color:${t.status==='completed'?'var(--muted)':t.status==='in_progress'?'var(--text)':'var(--text)'};${t.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(t.content)}</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(t.id)} · ${esc(t.status)}</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function clearConversation() {
|
||||
if(!S.session) return;
|
||||
if(!confirm('Clear all messages in this conversation? This cannot be undone.')) return;
|
||||
try {
|
||||
const data = await api('/api/session/clear', {method:'POST',
|
||||
body: JSON.stringify({session_id: S.session.session_id})});
|
||||
S.session = data.session;
|
||||
S.messages = [];
|
||||
S.toolCalls = [];
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
showToast('Conversation cleared');
|
||||
} catch(e) { setStatus('Clear failed: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── Skills panel ──
|
||||
async function loadSkills() {
|
||||
if (_skillsData) { renderSkills(_skillsData); return; }
|
||||
const box = $('skillsList');
|
||||
try {
|
||||
const data = await api('/api/skills');
|
||||
_skillsData = data.skills || [];
|
||||
renderSkills(_skillsData);
|
||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function renderSkills(skills) {
|
||||
const query = ($('skillsSearch').value || '').toLowerCase();
|
||||
const filtered = query ? skills.filter(s =>
|
||||
(s.name||'').toLowerCase().includes(query) ||
|
||||
(s.description||'').toLowerCase().includes(query) ||
|
||||
(s.category||'').toLowerCase().includes(query)
|
||||
) : skills;
|
||||
// Group by category
|
||||
const cats = {};
|
||||
for (const s of filtered) {
|
||||
const cat = s.category || '(general)';
|
||||
if (!cats[cat]) cats[cat] = [];
|
||||
cats[cat].push(s);
|
||||
}
|
||||
const box = $('skillsList');
|
||||
box.innerHTML = '';
|
||||
if (!filtered.length) { box.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:12px">No skills match.</div>'; return; }
|
||||
for (const [cat, items] of Object.entries(cats).sort()) {
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'skills-category';
|
||||
sec.innerHTML = `<div class="skills-cat-header">📁 ${esc(cat)} <span style="opacity:.5">(${items.length})</span></div>`;
|
||||
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'skill-item';
|
||||
el.innerHTML = `<span class="skill-name">${esc(skill.name)}</span><span class="skill-desc">${esc(skill.description||'')}</span>`;
|
||||
el.onclick = () => openSkill(skill.name, el);
|
||||
sec.appendChild(el);
|
||||
}
|
||||
box.appendChild(sec);
|
||||
}
|
||||
}
|
||||
|
||||
function filterSkills() {
|
||||
if (_skillsData) renderSkills(_skillsData);
|
||||
}
|
||||
|
||||
async function openSkill(name, el) {
|
||||
// Highlight active skill
|
||||
document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
try {
|
||||
const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`);
|
||||
// Show skill content in right panel preview
|
||||
$('previewPathText').textContent = name + '.md';
|
||||
$('previewBadge').textContent = 'skill';
|
||||
$('previewBadge').className = 'preview-badge md';
|
||||
showPreview('md');
|
||||
$('previewMd').innerHTML = renderMd(data.content || '(no content)');
|
||||
$('previewArea').classList.add('visible');
|
||||
$('fileTree').style.display = 'none';
|
||||
} catch(e) { setStatus('Could not load skill: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── Skill create/edit form ──
|
||||
let _editingSkillName = null;
|
||||
|
||||
function toggleSkillForm(prefillName, prefillCategory, prefillContent) {
|
||||
const form = $('skillCreateForm');
|
||||
if (!form) return;
|
||||
const open = form.style.display !== 'none';
|
||||
if (open) { form.style.display = 'none'; _editingSkillName = null; return; }
|
||||
$('skillFormName').value = prefillName || '';
|
||||
$('skillFormCategory').value = prefillCategory || '';
|
||||
$('skillFormContent').value = prefillContent || '';
|
||||
$('skillFormError').style.display = 'none';
|
||||
_editingSkillName = prefillName || null;
|
||||
form.style.display = '';
|
||||
$('skillFormName').focus();
|
||||
}
|
||||
|
||||
async function submitSkillSave() {
|
||||
const name = ($('skillFormName').value||'').trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const category = ($('skillFormCategory').value||'').trim();
|
||||
const content = $('skillFormContent').value;
|
||||
const errEl = $('skillFormError');
|
||||
errEl.style.display = 'none';
|
||||
if (!name) { errEl.textContent = 'Skill name is required'; errEl.style.display = ''; return; }
|
||||
if (!content.trim()) { errEl.textContent = 'Content is required'; errEl.style.display = ''; return; }
|
||||
try {
|
||||
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
|
||||
showToast(_editingSkillName ? 'Skill updated ✓' : 'Skill created ✓');
|
||||
_skillsData = null;
|
||||
toggleSkillForm();
|
||||
await loadSkills();
|
||||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
// ── Memory inline edit ──
|
||||
let _memoryData = null;
|
||||
|
||||
function toggleMemoryEdit() {
|
||||
const form = $('memoryEditForm');
|
||||
if (!form) return;
|
||||
const open = form.style.display !== 'none';
|
||||
if (open) { form.style.display = 'none'; return; }
|
||||
$('memEditSection').textContent = 'memory (notes)';
|
||||
$('memEditContent').value = _memoryData ? (_memoryData.memory || '') : '';
|
||||
$('memEditError').style.display = 'none';
|
||||
form.style.display = '';
|
||||
}
|
||||
|
||||
function closeMemoryEdit() {
|
||||
const form = $('memoryEditForm');
|
||||
if (form) form.style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitMemorySave() {
|
||||
const content = $('memEditContent').value;
|
||||
const errEl = $('memEditError');
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: 'memory', content})});
|
||||
showToast('Memory saved ✓');
|
||||
closeMemoryEdit();
|
||||
await loadMemory(true);
|
||||
} catch(e) { errEl.textContent = 'Error: ' + e.message; errEl.style.display = ''; }
|
||||
}
|
||||
|
||||
// ── Workspace management ──
|
||||
let _workspaceList = []; // cached from /api/workspaces
|
||||
|
||||
function getWorkspaceFriendlyName(path){
|
||||
// Look up the friendly name from the workspace list cache, fallback to last path segment
|
||||
if(_workspaceList && _workspaceList.length){
|
||||
const match=_workspaceList.find(w=>w.path===path);
|
||||
if(match && match.name) return match.name;
|
||||
}
|
||||
return path.split('/').filter(Boolean).pop()||path;
|
||||
}
|
||||
|
||||
async function loadWorkspaceList(){
|
||||
try{
|
||||
const data = await api('/api/workspaces');
|
||||
_workspaceList = data.workspaces || [];
|
||||
// Refresh sidebar display if we have a current session
|
||||
if(S.session && S.session.workspace) {
|
||||
const sidebarName=$('sidebarWsName');
|
||||
const sidebarPath=$('sidebarWsPath');
|
||||
if(sidebarName) sidebarName.textContent=getWorkspaceFriendlyName(S.session.workspace);
|
||||
if(sidebarPath) sidebarPath.textContent=S.session.workspace;
|
||||
}
|
||||
return data;
|
||||
}catch(e){ return {workspaces:[], last:''}; }
|
||||
}
|
||||
|
||||
function renderWorkspaceDropdown(workspaces, currentWs){
|
||||
const dd = $('wsDropdown');
|
||||
if(!dd)return;
|
||||
dd.innerHTML='';
|
||||
for(const w of workspaces){
|
||||
const opt=document.createElement('div');
|
||||
opt.className='ws-opt'+(w.path===currentWs?' active':'');
|
||||
opt.innerHTML=`<span class="ws-opt-name">${esc(w.name)}</span><span class="ws-opt-path">${esc(w.path)}</span>`;
|
||||
opt.onclick=async()=>{
|
||||
closeWsDropdown();
|
||||
if(!S.session||w.path===S.session.workspace)return;
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id, workspace:w.path, model:S.session.model
|
||||
})});
|
||||
S.session.workspace=w.path;
|
||||
syncTopbar();
|
||||
await loadDir('.');
|
||||
showToast(`Switched to ${w.name}`);
|
||||
};
|
||||
dd.appendChild(opt);
|
||||
}
|
||||
// Divider + Manage link
|
||||
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
|
||||
const mgmt=document.createElement('div');mgmt.className='ws-opt ws-manage';
|
||||
mgmt.innerHTML='⚙ Manage workspaces';
|
||||
mgmt.onclick=()=>{closeWsDropdown();switchPanel('workspaces');};
|
||||
dd.appendChild(mgmt);
|
||||
}
|
||||
|
||||
function toggleWsDropdown(){
|
||||
const dd=$('wsDropdown');
|
||||
if(!dd)return;
|
||||
const open=dd.classList.contains('open');
|
||||
if(open){closeWsDropdown();}
|
||||
else{
|
||||
loadWorkspaceList().then(data=>{
|
||||
renderWorkspaceDropdown(data.workspaces, S.session?S.session.workspace:'');
|
||||
dd.classList.add('open');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeWsDropdown(){
|
||||
const dd=$('wsDropdown');
|
||||
if(dd)dd.classList.remove('open');
|
||||
}
|
||||
document.addEventListener('click',e=>{
|
||||
if(!e.target.closest('#wsChipWrap'))closeWsDropdown();
|
||||
});
|
||||
|
||||
async function loadWorkspacesPanel(){
|
||||
const panel=$('workspacesPanel');
|
||||
if(!panel)return;
|
||||
const data=await loadWorkspaceList();
|
||||
renderWorkspacesPanel(data.workspaces);
|
||||
}
|
||||
|
||||
function renderWorkspacesPanel(workspaces){
|
||||
const panel=$('workspacesPanel');
|
||||
panel.innerHTML='';
|
||||
for(const w of workspaces){
|
||||
const row=document.createElement('div');row.className='ws-row';
|
||||
row.innerHTML=`
|
||||
<div class="ws-row-info">
|
||||
<div class="ws-row-name">${esc(w.name)}</div>
|
||||
<div class="ws-row-path">${esc(w.path)}</div>
|
||||
</div>
|
||||
<div class="ws-row-actions">
|
||||
<button class="ws-action-btn" title="Use in current session" onclick="switchToWorkspace('${esc(w.path)}','${esc(w.name)}')">→ Use</button>
|
||||
<button class="ws-action-btn danger" title="Remove" onclick="removeWorkspace('${esc(w.path)}')">✕</button>
|
||||
</div>`;
|
||||
panel.appendChild(row);
|
||||
}
|
||||
const addRow=document.createElement('div');addRow.className='ws-add-row';
|
||||
addRow.innerHTML=`
|
||||
<input id="wsAddInput" placeholder="Add workspace path (e.g. /home/user/my-project)" style="flex:1;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:7px 10px;font-size:12px;outline:none;">
|
||||
<button class="ws-action-btn" onclick="addWorkspace()">+ Add</button>`;
|
||||
panel.appendChild(addRow);
|
||||
const hint=document.createElement('div');
|
||||
hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px';
|
||||
hint.textContent='Paths are validated as existing directories before saving.';
|
||||
panel.appendChild(hint);
|
||||
}
|
||||
|
||||
async function addWorkspace(){
|
||||
const input=$('wsAddInput');
|
||||
const path=(input?input.value:'').trim();
|
||||
if(!path)return;
|
||||
try{
|
||||
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
|
||||
_workspaceList=data.workspaces;
|
||||
renderWorkspacesPanel(data.workspaces);
|
||||
if(input)input.value='';
|
||||
showToast('Workspace added');
|
||||
}catch(e){setStatus('Add failed: '+e.message);}
|
||||
}
|
||||
|
||||
async function removeWorkspace(path){
|
||||
if(!confirm(`Remove workspace "${path}"?`))return;
|
||||
try{
|
||||
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
|
||||
_workspaceList=data.workspaces;
|
||||
renderWorkspacesPanel(data.workspaces);
|
||||
showToast('Workspace removed');
|
||||
}catch(e){setStatus('Remove failed: '+e.message);}
|
||||
}
|
||||
|
||||
async function switchToWorkspace(path,name){
|
||||
if(!S.session)return;
|
||||
try{
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id, workspace:path, model:S.session.model
|
||||
})});
|
||||
S.session.workspace=path;
|
||||
syncTopbar();
|
||||
await loadDir('.');
|
||||
showToast(`Switched to ${name}`);
|
||||
}catch(e){setStatus('Switch failed: '+e.message);}
|
||||
}
|
||||
|
||||
// ── Memory panel ──
|
||||
async function loadMemory(force) {
|
||||
const panel = $('memoryPanel');
|
||||
try {
|
||||
const data = await api('/api/memory');
|
||||
_memoryData = data; // cache for edit form
|
||||
const fmtTime = ts => ts ? new Date(ts*1000).toLocaleString() : '';
|
||||
panel.innerHTML = `
|
||||
<div class="memory-section">
|
||||
<div class="memory-section-title">
|
||||
🧠 My Notes
|
||||
<span class="memory-mtime">${fmtTime(data.memory_mtime)}</span>
|
||||
</div>
|
||||
${data.memory
|
||||
? `<div class="memory-content preview-md">${renderMd(data.memory)}</div>`
|
||||
: '<div class="memory-empty">No notes yet.</div>'}
|
||||
</div>
|
||||
<div class="memory-section">
|
||||
<div class="memory-section-title">
|
||||
👤 User Profile
|
||||
<span class="memory-mtime">${fmtTime(data.user_mtime)}</span>
|
||||
</div>
|
||||
${data.user
|
||||
? `<div class="memory-content preview-md">${renderMd(data.user)}</div>`
|
||||
: '<div class="memory-empty">No profile yet.</div>'}
|
||||
</div>`;
|
||||
} catch(e) { panel.innerHTML = `<div style="color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
const wrap=$('composerWrap');let dragCounter=0;
|
||||
document.addEventListener('dragover',e=>e.preventDefault());
|
||||
document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')){dragCounter++;wrap.classList.add('drag-over');}});
|
||||
document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}});
|
||||
document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');const files=Array.from(e.dataTransfer.files);if(files.length){addFiles(files);$('msg').focus();}});
|
||||
|
||||
// Event wiring
|
||||
206
static/sessions.js
Normal file
206
static/sessions.js
Normal file
@@ -0,0 +1,206 @@
|
||||
async function newSession(flash){
|
||||
MSG_QUEUE.length=0;updateQueueBadge();
|
||||
S.toolCalls=[];
|
||||
clearLiveToolCards();
|
||||
const inheritWs=S.session?S.session.workspace:null;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
if(flash)S.session._flash=true;
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
syncTopbar();await loadDir('.');renderMessages();
|
||||
// don't call renderSessionList here - callers do it when needed
|
||||
}
|
||||
|
||||
async function loadSession(sid){
|
||||
stopApprovalPolling();hideApprovalCard();
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
S.session=data.session;
|
||||
localStorage.setItem('hermes-webui-session',S.session.session_id);
|
||||
// B9: sanitize empty assistant messages that can appear when agent only ran tool calls
|
||||
data.session.messages=(data.session.messages||[]).filter(m=>{
|
||||
if(!m||!m.role)return false;
|
||||
if(m.role==='tool')return false;
|
||||
if(m.role==='assistant'){let c=m.content||'';if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('');return String(c).trim().length>0;}
|
||||
return true;
|
||||
});
|
||||
if(INFLIGHT[sid]){
|
||||
S.messages=INFLIGHT[sid].messages;
|
||||
// Restore live tool cards for this in-flight session
|
||||
clearLiveToolCards();
|
||||
for(const tc of (S.toolCalls||[])){
|
||||
if(tc&&tc.name) appendLiveToolCard(tc);
|
||||
}
|
||||
syncTopbar();await loadDir('.');renderMessages();appendThinking();
|
||||
setBusy(true);setStatus('Hermes is thinking\u2026');
|
||||
startApprovalPolling(sid);
|
||||
}else{
|
||||
MSG_QUEUE.length=0;updateQueueBadge(); // clear queue for the viewed session
|
||||
S.messages=data.session.messages||[];
|
||||
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
|
||||
// Reset per-session visual state: the viewed session is idle even if another
|
||||
// session's stream is still running in the background.
|
||||
// We directly update the DOM instead of calling setBusy(false), because
|
||||
// setBusy(false) drains MSG_QUEUE which we don't want here.
|
||||
S.busy=false;
|
||||
S.activeStreamId=null;
|
||||
$('btnSend').disabled=false;
|
||||
$('btnSend').style.opacity='1';
|
||||
const _dots=$('activityDots');if(_dots)_dots.style.display='none';
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
setStatus('');
|
||||
clearLiveToolCards();
|
||||
syncTopbar();await loadDir('.');renderMessages();highlightCode();
|
||||
}
|
||||
}
|
||||
|
||||
let _allSessions = []; // cached for search filter
|
||||
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
|
||||
|
||||
async function renderSessionList(){
|
||||
try{
|
||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||
const data=await api('/api/sessions');
|
||||
_allSessions = data.sessions||[];
|
||||
renderSessionListFromCache(); // no-ops if rename is in progress
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}
|
||||
|
||||
let _searchDebounceTimer = null;
|
||||
let _contentSearchResults = []; // results from /api/sessions/search content scan
|
||||
|
||||
function filterSessions(){
|
||||
// Immediate client-side title filter (no flicker)
|
||||
renderSessionListFromCache();
|
||||
// Debounced content search via API for message text
|
||||
const q = ($('sessionSearch').value || '').trim();
|
||||
clearTimeout(_searchDebounceTimer);
|
||||
if (!q) { _contentSearchResults = []; return; }
|
||||
_searchDebounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const data = await api(`/api/sessions/search?q=${encodeURIComponent(q)}&content=1&depth=5`);
|
||||
const titleIds = new Set(_allSessions.filter(s => (s.title||'Untitled').toLowerCase().includes(q.toLowerCase())).map(s=>s.session_id));
|
||||
_contentSearchResults = (data.sessions||[]).filter(s => s.match_type === 'content' && !titleIds.has(s.session_id));
|
||||
renderSessionListFromCache();
|
||||
} catch(e) { /* ignore */ }
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function renderSessionListFromCache(){
|
||||
// Don't re-render while user is actively renaming a session (would destroy the input)
|
||||
if(_renamingSid) return;
|
||||
const q=($('sessionSearch').value||'').toLowerCase();
|
||||
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;
|
||||
// Merge content matches (deduped): content matches appended after title matches
|
||||
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
||||
const sessions=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
||||
const list=$('sessionList');list.innerHTML='';
|
||||
// Date grouping: Today / Yesterday / Earlier
|
||||
const now=Date.now();
|
||||
const ONE_DAY=86400000;
|
||||
let lastGroup='';
|
||||
for(const s of sessions.slice(0,50)){
|
||||
const ts=(s.updated_at||0)*1000;
|
||||
const group=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier';
|
||||
if(group!==lastGroup){
|
||||
lastGroup=group;
|
||||
const hdr=document.createElement('div');
|
||||
hdr.style.cssText='font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:10px 10px 4px;opacity:.8;';
|
||||
hdr.textContent=group;
|
||||
list.appendChild(hdr);
|
||||
}
|
||||
const el=document.createElement('div');
|
||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'');
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const title=document.createElement('span');
|
||||
title.className='session-title';title.textContent=s.title||'Untitled';
|
||||
title.title='Double-click to rename';
|
||||
|
||||
// Rename: called directly when we confirm it's a double-click
|
||||
const startRename=()=>{
|
||||
_renamingSid = s.session_id;
|
||||
const inp=document.createElement('input');
|
||||
inp.className='session-title-input';
|
||||
inp.value=s.title||'Untitled';
|
||||
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
|
||||
inp.addEventListener(ev, e2=>e2.stopPropagation())
|
||||
);
|
||||
const finish=async(save)=>{
|
||||
_renamingSid = null;
|
||||
if(save){
|
||||
const newTitle=inp.value.trim()||'Untitled';
|
||||
title.textContent=newTitle;
|
||||
s.title=newTitle;
|
||||
if(S.session&&S.session.session_id===s.session_id){S.session.title=newTitle;syncTopbar();}
|
||||
try{await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});}
|
||||
catch(err){setStatus('Rename failed: '+err.message);}
|
||||
}
|
||||
inp.replaceWith(title);
|
||||
// Allow list re-renders again after a short delay
|
||||
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
|
||||
};
|
||||
inp.onkeydown=e2=>{
|
||||
if(e2.key==='Enter'){e2.preventDefault();e2.stopPropagation();finish(true);}
|
||||
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
|
||||
};
|
||||
// onblur: cancel only -- no accidental saves
|
||||
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); };
|
||||
title.replaceWith(inp);
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
};
|
||||
|
||||
const trash=document.createElement('button');
|
||||
trash.className='session-trash';trash.innerHTML='🗑';trash.title='Delete';
|
||||
trash.onclick=async(e)=>{e.stopPropagation();e.preventDefault();await deleteSession(s.session_id);};
|
||||
el.appendChild(title);el.appendChild(trash);
|
||||
|
||||
// Use a click timer to distinguish single-click (navigate) from double-click (rename).
|
||||
// This prevents loadSession from firing on the first click of a double-click,
|
||||
// which would re-render the list and destroy the dblclick target before it fires.
|
||||
let _clickTimer=null;
|
||||
el.onclick=async(e)=>{
|
||||
if(_renamingSid) return; // ignore while any rename is active
|
||||
if(e.target===trash||trash.contains(e.target)) return; // trash handles itself
|
||||
clearTimeout(_clickTimer);
|
||||
_clickTimer=setTimeout(async()=>{
|
||||
_clickTimer=null;
|
||||
if(_renamingSid) return;
|
||||
await loadSession(s.session_id);renderSessionListFromCache();
|
||||
}, 220);
|
||||
};
|
||||
el.ondblclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearTimeout(_clickTimer); // cancel the pending single-click navigation
|
||||
_clickTimer=null;
|
||||
startRename();
|
||||
};
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sid){
|
||||
if(!confirm('Delete this conversation?'))return;
|
||||
try{
|
||||
await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})});
|
||||
}catch(e){setStatus(`Delete failed: ${e.message}`);return;}
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
S.session=null;S.messages=[];S.entries=[];
|
||||
localStorage.removeItem('hermes-webui-session');
|
||||
// load the most recent remaining session, or show blank if none left
|
||||
const remaining=await api('/api/sessions');
|
||||
if(remaining.sessions&&remaining.sessions.length){
|
||||
await loadSession(remaining.sessions[0].session_id);
|
||||
}else{
|
||||
$('topbarTitle').textContent='Hermes';
|
||||
$('topbarMeta').textContent='Start a new conversation';
|
||||
$('msgInner').innerHTML='';
|
||||
$('emptyState').style.display='';
|
||||
$('fileTree').innerHTML='';
|
||||
}
|
||||
}
|
||||
showToast('Conversation deleted');
|
||||
await renderSessionList();
|
||||
}
|
||||
|
||||
|
||||
450
static/style.css
Normal file
450
static/style.css
Normal file
@@ -0,0 +1,450 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg:#1a1a2e;--sidebar:#16213e;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.14);
|
||||
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
|
||||
}
|
||||
body{background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;}
|
||||
.layout{display:flex;width:100%;height:100vh;}
|
||||
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
|
||||
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
|
||||
.sidebar-header h1{font-size:15px;font-weight:600;}
|
||||
.sidebar-section{padding:14px 14px 8px;}
|
||||
.new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:rgba(124,185,255,0.07);border:1px solid rgba(124,185,255,0.18);color:var(--blue);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;}
|
||||
.new-chat-btn:hover{background:rgba(124,185,255,0.13);border-color:rgba(124,185,255,.3);}
|
||||
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;}
|
||||
.session-search{padding:4px 10px 8px;flex-shrink:0;}
|
||||
.session-search input{width:100%;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:8px;color:var(--text);padding:7px 12px;font-size:12px;outline:none;transition:all .15s;}
|
||||
.session-search input:focus{border-color:rgba(124,185,255,.35);background:rgba(255,255,255,.06);box-shadow:0 0 0 2px rgba(124,185,255,.07);}
|
||||
.session-search input::placeholder{color:var(--muted);opacity:.7;}
|
||||
/* Inline session title edit */
|
||||
.session-title-input{flex:1;background:rgba(20,32,60,.9);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
||||
.session-item{padding:8px 10px 8px 8px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;display:flex;align-items:center;gap:6px;min-width:0;border-left:2px solid transparent;}
|
||||
.session-item:hover{background:rgba(255,255,255,0.06);color:var(--text);}
|
||||
.session-item.active{background:rgba(124,185,255,0.1);color:var(--blue);border-left:2px solid var(--blue);padding-left:8px;}
|
||||
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.session-trash{flex-shrink:0;opacity:0;font-size:13px;color:var(--muted);background:none;border:none;cursor:pointer;padding:0 2px;line-height:1;transition:opacity .15s,color .15s;}
|
||||
.session-item:hover .session-trash{opacity:1;}
|
||||
.session-trash:hover{color:var(--accent)!important;}
|
||||
@keyframes newflash{0%{background:rgba(124,185,255,0.22);color:var(--blue);}100%{background:transparent;color:var(--muted);}}
|
||||
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
|
||||
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(20,30,50,.95);backdrop-filter:blur(12px);border:1px solid rgba(124,185,255,0.25);color:var(--text);font-size:13px;padding:10px 20px;border-radius:12px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 4px 20px rgba(0,0,0,.3);letter-spacing:.01em;}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(-2px);}
|
||||
.reconnect-banner{display:none;background:#1a2535;border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||
.reconnect-banner.visible{display:flex;}
|
||||
.reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;}
|
||||
.reconnect-btn:hover{background:rgba(201,168,76,0.25);}
|
||||
/* ── Approval card ── */
|
||||
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
|
||||
.approval-card.visible{display:block;}
|
||||
.approval-inner{background:rgba(20,30,50,.95);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:14px 16px;}
|
||||
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
|
||||
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:#e2e8f0;white-space:pre-wrap;word-break:break-all;margin-bottom:12px;max-height:120px;overflow-y:auto;}
|
||||
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;}
|
||||
.approval-btn{padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,0.06);color:var(--text);cursor:pointer;transition:all .15s;}
|
||||
.approval-btn:hover{background:rgba(255,255,255,0.12);}
|
||||
.approval-btn.once{border-color:rgba(124,185,255,0.5);color:var(--blue);}
|
||||
.approval-btn.once:hover{background:rgba(124,185,255,0.15);}
|
||||
.approval-btn.session{border-color:rgba(124,185,255,0.3);color:var(--blue);}
|
||||
.approval-btn.always{border-color:rgba(201,168,76,0.5);color:var(--gold);}
|
||||
.approval-btn.deny{border-color:rgba(233,69,96,0.5);color:var(--accent);}
|
||||
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);}
|
||||
/* Sidebar navigation tabs */
|
||||
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
|
||||
.nav-tab{flex:1;padding:10px 4px 8px;font-size:20px;text-align:center;cursor:pointer;color:var(--muted);border:none;background:none;transition:color .15s;border-bottom:2px solid transparent;white-space:nowrap;overflow:hidden;position:relative;}
|
||||
.nav-tab:hover{color:var(--text);}
|
||||
.nav-tab:hover::after{content:attr(data-label);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:rgba(15,22,40,.98);border:1px solid rgba(124,185,255,0.3);color:var(--blue);font-size:12px;font-weight:700;letter-spacing:.02em;padding:5px 11px;border-radius:7px;white-space:nowrap;pointer-events:none;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.3);}
|
||||
.nav-tab.active{color:var(--blue);}
|
||||
.nav-tab.active::before{content:'';position:absolute;bottom:0;left:50%;transform:translateX(-50%);width:20px;height:2px;background:var(--blue);border-radius:2px 2px 0 0;}
|
||||
/* Panel content areas (swapped by tab) */
|
||||
.panel-view{display:none;flex:1;overflow:hidden;flex-direction:column;}
|
||||
.panel-view.active{display:flex;}
|
||||
/* Cron panel */
|
||||
.cron-list{flex:1;overflow-y:auto;padding:8px;}
|
||||
.cron-item{border-radius:10px;border:1px solid rgba(255,255,255,.08);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
|
||||
.cron-item:hover{border-color:var(--border2);}
|
||||
.cron-header{display:flex;align-items:center;gap:8px;padding:9px 11px;cursor:pointer;}
|
||||
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.cron-status{font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;flex-shrink:0;}
|
||||
.cron-status.active{background:rgba(34,197,94,.15);color:#4ade80;}
|
||||
.cron-status.paused{background:rgba(201,168,76,.15);color:var(--gold);}
|
||||
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
|
||||
.cron-status.error{background:rgba(233,69,96,.15);color:var(--accent);}
|
||||
.cron-body{display:none;padding:0 11px 10px;border-top:1px solid var(--border);overflow:hidden;}
|
||||
.cron-body.open{display:block;}
|
||||
.cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;}
|
||||
.cron-prompt{font-size:11px;color:var(--muted);line-height:1.55;max-height:80px;overflow-y:auto;background:rgba(0,0,0,.2);padding:6px 8px;border-radius:6px;white-space:pre-wrap;margin-bottom:8px;box-sizing:border-box;}
|
||||
.cron-actions{display:flex;gap:6px;margin-bottom:8px;}
|
||||
.cron-btn{padding:4px 10px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;}
|
||||
.cron-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
.cron-btn.run{border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||
.cron-btn.run:hover{background:rgba(124,185,255,.12);}
|
||||
.cron-btn.pause{border-color:rgba(201,168,76,.4);color:var(--gold);}
|
||||
.cron-last{font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px;max-height:220px;overflow-y:auto;white-space:pre-wrap;line-height:1.5;word-break:break-word;}
|
||||
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;}
|
||||
/* Skills panel */
|
||||
.skills-search{padding:8px;flex-shrink:0;}
|
||||
.skills-search input{width:100%;background:rgba(255,255,255,.06);border:1px solid var(--border2);border-radius:7px;color:var(--text);padding:6px 10px;font-size:12px;outline:none;}
|
||||
.skills-search input::placeholder{color:var(--muted);}
|
||||
.skills-list{flex:1;overflow-y:auto;padding:0 8px 8px;}
|
||||
.skills-category{margin-bottom:4px;}
|
||||
.skills-cat-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 6px 4px;cursor:pointer;display:flex;align-items:center;gap:4px;}
|
||||
.skills-cat-header:hover{color:var(--text);}
|
||||
.skill-item{padding:7px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);display:flex;align-items:flex-start;gap:6px;transition:all .12s;line-height:1.4;}
|
||||
.skill-item:hover{background:rgba(255,255,255,.06);color:var(--text);}
|
||||
.skill-item.active{background:rgba(124,185,255,.1);color:var(--blue);}
|
||||
.skill-name{font-weight:500;flex-shrink:0;}
|
||||
.skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;}
|
||||
/* Memory panel */
|
||||
.memory-panel{flex:1;overflow-y:auto;padding:12px;}
|
||||
.memory-section{margin-bottom:16px;}
|
||||
.memory-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;}
|
||||
.memory-mtime{font-size:10px;font-weight:400;text-transform:none;opacity:.6;}
|
||||
.memory-content{font-size:12px;line-height:1.7;color:var(--text);}
|
||||
.memory-content p{margin-bottom:6px;}
|
||||
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
|
||||
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);}
|
||||
optgroup{color:var(--muted);font-size:11px;font-weight:700;}
|
||||
option{background:#1a1a2e;color:var(--text);padding:6px;}
|
||||
.sidebar-actions{display:flex;gap:6px;}
|
||||
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:rgba(26,26,46,0.5);}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;}
|
||||
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;}
|
||||
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,.1);color:var(--muted);font-weight:500;}
|
||||
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;}
|
||||
.messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
||||
.msg-row{padding:10px 0;}
|
||||
.msg-row+.msg-row{border-top:none;}
|
||||
.msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;}
|
||||
.msg-role.user{color:rgba(124,185,255,0.65);}
|
||||
.msg-role.assistant{color:rgba(201,168,76,0.6);}
|
||||
.role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
|
||||
.role-icon.user{background:rgba(124,185,255,0.15);color:var(--blue);border:1px solid rgba(124,185,255,0.2);}
|
||||
.role-icon.assistant{background:rgba(201,168,76,0.15);color:var(--gold);border:1px solid rgba(201,168,76,0.2);}
|
||||
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;}
|
||||
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
.msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;}
|
||||
.msg-body h1{font-size:18px;}.msg-body h2{font-size:16px;}.msg-body h3{font-size:14px;}
|
||||
.msg-body strong{color:#fff;font-weight:600;}.msg-body em{color:#c9c9e8;font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:#e2e8f0;font-size:13px;line-height:1.6;}
|
||||
.pre-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:8px 16px 8px;background:rgba(255,255,255,.04);border-radius:10px 10px 0 0;border:1px solid rgba(255,255,255,.08);border-bottom:1px solid rgba(255,255,255,.05);display:flex;align-items:center;gap:6px;}
|
||||
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
|
||||
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
|
||||
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
|
||||
.msg-body a{color:var(--blue);text-decoration:underline;}
|
||||
.msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;}
|
||||
.msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;}
|
||||
.msg-file-badge{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.25);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--blue);}
|
||||
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
|
||||
.dot{width:6px;height:6px;border-radius:50%;background:var(--blue);opacity:.3;animation:pulse 1.4s ease-in-out infinite;}
|
||||
.dot:nth-child(2){animation-delay:.22s;}.dot:nth-child(3){animation-delay:.44s;}
|
||||
@keyframes pulse{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:.8;transform:scale(1)}}
|
||||
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px;color:var(--muted);}
|
||||
.empty-logo{width:64px;height:64px;border-radius:20px;background:linear-gradient(145deg,rgba(124,185,255,.15),rgba(201,168,76,.1));border:1px solid rgba(124,185,255,.2);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:var(--blue);margin-bottom:4px;box-shadow:0 4px 20px rgba(124,185,255,.1);}
|
||||
.empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;}
|
||||
.empty-state p{font-size:14px;text-align:center;max-width:320px;}
|
||||
.suggestion-grid{display:flex;flex-direction:column;gap:8px;margin-top:12px;width:100%;max-width:380px;}
|
||||
.suggestion{padding:11px 14px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
|
||||
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
|
||||
/* ── Composer ── */
|
||||
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
|
||||
.composer-box{max-width:780px;margin:0 auto;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.12);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
|
||||
.composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);}
|
||||
.composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);}
|
||||
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
|
||||
.composer-wrap.drag-over .drop-hint{display:flex;}
|
||||
.attach-tray{display:none;flex-wrap:wrap;gap:6px;padding:10px 14px 0;}
|
||||
.attach-tray.has-files{display:flex;}
|
||||
.attach-chip{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.08);border:1px solid rgba(124,185,255,0.22);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--blue);}
|
||||
.attach-chip button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;line-height:1;padding:0 0 0 3px;}
|
||||
.attach-chip button:hover{color:var(--accent);}
|
||||
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:14px;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);}
|
||||
.composer-footer{display:flex;align-items:center;justify-content:space-between;padding:6px 10px 10px;}
|
||||
.composer-left{display:flex;gap:2px;align-items:center;}
|
||||
.composer-right{display:flex;gap:6px;align-items:center;}
|
||||
.icon-btn{width:34px;height:34px;border-radius:8px;background:none;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;transition:all .15s;}
|
||||
.icon-btn{opacity:.75;}
|
||||
.icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);opacity:1;}
|
||||
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
|
||||
.send-btn{padding:7px 18px;border-radius:10px;font-size:13px;font-weight:600;background:linear-gradient(135deg,#5ba8f5,#7cb9ff);border:none;color:#0a1628;cursor:pointer;display:flex;align-items:center;gap:6px;transition:all .15s;flex-shrink:0;letter-spacing:.01em;}
|
||||
.send-btn:hover{background:linear-gradient(135deg,#7cb9ff,#a0d0ff);transform:translateY(-1px);}
|
||||
.send-btn:active{transform:translateY(0);}
|
||||
.send-btn:disabled{opacity:.4;cursor:not-allowed;}
|
||||
.upload-bar-wrap{display:none;height:3px;background:rgba(255,255,255,.06);border-radius:0 0 16px 16px;overflow:hidden;}
|
||||
.upload-bar-wrap.active{display:block;}
|
||||
.upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;}
|
||||
.rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;}
|
||||
.panel-actions{display:flex;gap:4px;}
|
||||
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
/* File row actions (shown on hover) */
|
||||
/* file-item-actions removed: delete button is now a flex child */
|
||||
.file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;}
|
||||
.file-action-btn:hover{color:var(--accent);}
|
||||
.close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;}
|
||||
.file-tree{flex:1;overflow-y:auto;padding:8px;}
|
||||
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
|
||||
.file-item:hover{background:rgba(255,255,255,.07);color:var(--text);}
|
||||
.file-item.active{background:rgba(124,185,255,.12);color:var(--blue);}
|
||||
.preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;}
|
||||
.preview-area.visible{display:flex;opacity:1;}
|
||||
.preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;}
|
||||
.preview-code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:#cdd6e0;}
|
||||
/* 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);}
|
||||
/* 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;}
|
||||
.preview-md h1{font-size:18px;font-weight:700;margin:16px 0 8px;color:#fff;border-bottom:1px solid var(--border);padding-bottom:6px;}
|
||||
.preview-md h2{font-size:15px;font-weight:600;margin:14px 0 6px;color:#fff;}
|
||||
.preview-md h3{font-size:13px;font-weight:600;margin:12px 0 4px;color:#e8e8f0;}
|
||||
.preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:rgba(0,0,0,.35);padding:1px 5px;border-radius:4px;color:#f0c27f;}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
.preview-md pre code{background:none;padding:0;color:#e2e8f0;font-size:11.5px;line-height:1.55;}
|
||||
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
|
||||
.preview-md strong{color:#fff;font-weight:600;}.preview-md em{color:#c9c9e8;}
|
||||
.preview-md a{color:var(--blue);text-decoration:underline;}
|
||||
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
|
||||
.preview-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
|
||||
.preview-md th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
|
||||
.preview-md td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
|
||||
.preview-md tr:nth-child(even){background:rgba(255,255,255,.03);}
|
||||
/* 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:rgba(124,185,255,.15);color:var(--blue);}
|
||||
.preview-badge.md{background:rgba(201,168,76,.15);color:var(--gold);}
|
||||
.preview-badge.code{background:rgba(255,255,255,.07);color:var(--muted);}
|
||||
::-webkit-scrollbar{width:4px;height:4px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s}
|
||||
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22)}
|
||||
@media(max-width:900px){.rightpanel{display:none}}@media(max-width:640px){.sidebar{display:none}}
|
||||
|
||||
/* ── Workspace dropdown (topbar) ── */
|
||||
.ws-chip{user-select:none;}
|
||||
.ws-dropdown{display:none;position:absolute;top:calc(100% + 6px);right:0;min-width:240px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown.open{display:block;}
|
||||
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.ws-opt.active{background:rgba(124,185,255,.1);}
|
||||
.ws-opt-name{font-size:13px;color:var(--text);font-weight:500;}
|
||||
.ws-opt-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.ws-divider{height:1px;background:var(--border);margin:4px 0;}
|
||||
.ws-manage{color:var(--muted);font-size:12px;}
|
||||
/* ── Workspace management panel ── */
|
||||
.ws-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border);}
|
||||
.ws-row:last-of-type{border-bottom:none;}
|
||||
.ws-row-info{flex:1;min-width:0;}
|
||||
.ws-row-name{font-size:13px;font-weight:500;color:var(--text);}
|
||||
.ws-row-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.ws-row-actions{display:flex;gap:4px;flex-shrink:0;}
|
||||
.ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;}
|
||||
.ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
.ws-action-btn.danger:hover{background:rgba(233,69,96,.12);color:var(--accent);border-color:rgba(233,69,96,.3);}
|
||||
.ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;}
|
||||
/* ── Message action buttons (copy, edit, retry) ── */
|
||||
.msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;}
|
||||
.msg-row:hover .msg-actions{opacity:1;}
|
||||
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
|
||||
.msg-action-btn:hover{color:var(--blue);background:rgba(124,185,255,.1);}
|
||||
|
||||
/* ── Edit message inline ── */
|
||||
.msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid rgba(124,185,255,.35);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:inherit;line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px rgba(124,185,255,.07);margin-top:4px;}
|
||||
.msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;}
|
||||
.msg-edit-send{background:var(--blue);color:#fff;border:none;border-radius:7px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;}
|
||||
.msg-edit-send:hover{opacity:.85;}
|
||||
.msg-edit-cancel{background:rgba(255,255,255,.06);color:var(--muted);border:1px solid var(--border2);border-radius:7px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .15s;}
|
||||
.msg-edit-cancel:hover{background:rgba(255,255,255,.1);}
|
||||
|
||||
/* ── Clear conversation chip ── */
|
||||
.clear-btn{background:rgba(201,168,76,.06);border:1px solid rgba(201,168,76,.18);color:var(--gold);font-size:11px;padding:4px 10px;cursor:pointer;transition:background .15s;}
|
||||
.clear-btn:hover{background:rgba(201,168,76,.12);}
|
||||
|
||||
/* ── Copy button on messages ── */
|
||||
/* msg-copy-btn styles moved to msg-action-btn */
|
||||
/* ── Nav tab nowrap ── */
|
||||
/* nav-tab-nowrap-handled-above */
|
||||
|
||||
/* ── Final polish additions ── */
|
||||
|
||||
/* Smooth hover on file items */
|
||||
|
||||
|
||||
/* Sidebar section padding: give the session-section breathing room */
|
||||
.sidebar-section{padding:10px 12px 6px;}
|
||||
|
||||
/* New chat btn icon - align nicely */
|
||||
.new-chat-btn svg{flex-shrink:0;opacity:.8;}
|
||||
|
||||
/* Session list: group header spacing */
|
||||
.session-list > div[style]{padding-left:12px;}
|
||||
|
||||
/* Preview path bar: flex row with nice gap */
|
||||
.preview-path{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;overflow:hidden;min-width:0;}
|
||||
.preview-path #previewPathText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.preview-path #previewBadge{flex-shrink:0;white-space:nowrap;}
|
||||
.preview-path #btnDownloadFile,.preview-path #btnEditFile{flex-shrink:0;white-space:nowrap;}
|
||||
|
||||
/* Preview badge typography */
|
||||
.preview-badge{font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;}
|
||||
|
||||
/* Approval buttons: tab stops */
|
||||
.approval-btn:focus{outline:2px solid var(--blue);outline-offset:2px;}
|
||||
|
||||
/* Message role: breathing room between icon and name */
|
||||
.msg-role > span{line-height:1;}
|
||||
|
||||
/* Composer wrap: slightly less padding on smaller heights */
|
||||
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;}
|
||||
|
||||
/* Cron status badges: pill shape refinement */
|
||||
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
|
||||
|
||||
/* Right panel icons: tighter */
|
||||
.panel-actions{gap:2px;}
|
||||
|
||||
/* Workspace hint text: no intrusion */
|
||||
.sidebar-bottom > div[style*="topbar"]{pointer-events:none;}
|
||||
|
||||
/* Topbar: border should match the subtle sidebar border */
|
||||
.topbar{border-bottom:1px solid rgba(255,255,255,.07);}
|
||||
|
||||
|
||||
|
||||
/* Suggestion grid: consistent width */
|
||||
.suggestion-grid{width:100%;max-width:400px;}
|
||||
|
||||
/* Empty state: add subtle gradient behind logo */
|
||||
.empty-state{background:radial-gradient(ellipse at 50% 20%,rgba(124,185,255,.04) 0%,transparent 60%);}
|
||||
|
||||
/* ── Activity bar (tool status above composer) ── */
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
|
||||
#activityBar{padding-bottom:8px;flex-shrink:0;}
|
||||
#activityBarInner{transition:opacity .2s;}
|
||||
/* Remove old status-text from composer (kept for error messages only) */
|
||||
.status-text{font-size:11px;color:var(--muted);padding-left:2px;display:none;}
|
||||
|
||||
/* Sidebar workspace display */
|
||||
#sidebarWsDisplay:hover{background:rgba(255,255,255,.05);}
|
||||
#sidebarWsDisplay:hover #sidebarWsName{color:var(--blue);}
|
||||
|
||||
/* Date group headers in session list */
|
||||
.session-list > div[style*="uppercase"] {
|
||||
padding: 8px 10px 3px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
/* Sidebar bottom: tighten model field */
|
||||
.sidebar-bottom { padding: 10px 14px 12px; }
|
||||
/* Right panel file tree: more padding for breathing room */
|
||||
|
||||
/* Composer footer: even spacing */
|
||||
.composer-footer { padding: 4px 10px 8px; }
|
||||
|
||||
/* ── File tree: clean delete button via CSS hover ── */
|
||||
.file-del-btn{
|
||||
flex-shrink:0;
|
||||
width:0;height:16px;
|
||||
overflow:hidden;
|
||||
background:none;border:none;
|
||||
color:var(--muted);cursor:pointer;
|
||||
font-size:13px;font-weight:300;
|
||||
opacity:0;
|
||||
transition:width .12s,opacity .12s,color .12s;
|
||||
padding:0;border-radius:3px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
line-height:1;
|
||||
}
|
||||
.file-item:hover .file-del-btn{ width:16px;opacity:1;margin-left:2px; }
|
||||
.file-del-btn:hover{ color:var(--accent); }
|
||||
|
||||
/* file-name must be a flex child that can shrink to zero */
|
||||
.file-name{
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
flex:1 1 0;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
/* file-size: never wraps, shrinks away gracefully */
|
||||
.file-size{
|
||||
flex-shrink:0;
|
||||
font-size:10px;
|
||||
color:var(--muted);
|
||||
white-space:nowrap;
|
||||
margin-left:4px;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
|
||||
/* file-icon: never shrinks */
|
||||
.file-icon{
|
||||
flex-shrink:0;
|
||||
font-size:13px;
|
||||
opacity:.7;
|
||||
line-height:1;
|
||||
}
|
||||
|
||||
/* ── Resizable panels ── */
|
||||
.resize-handle{
|
||||
position:absolute;
|
||||
top:0;bottom:0;
|
||||
width:5px;
|
||||
cursor:col-resize;
|
||||
z-index:10;
|
||||
transition:background .15s;
|
||||
}
|
||||
.resize-handle:hover,.resize-handle.dragging{background:rgba(124,185,255,.35);}
|
||||
.sidebar{position:relative;}
|
||||
.sidebar .resize-handle{right:-2px;}
|
||||
.rightpanel{position:relative;}
|
||||
.rightpanel .resize-handle{left:-2px;}
|
||||
/* Prevent text selection during drag */
|
||||
body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
/* ── Tool call cards ── */
|
||||
/* Running indicator dot (pulsing) */
|
||||
.tool-card-running-dot{width:7px;height:7px;border-radius:50%;background:var(--blue);opacity:.8;flex-shrink:0;animation:pulse 1.2s ease-in-out infinite;}
|
||||
/* Show more button inside tool card result */
|
||||
.tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;}
|
||||
.tool-card-more:hover{opacity:1;}
|
||||
.tool-card-row{margin:0;padding:1px 0;}
|
||||
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
|
||||
.tool-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
.tool-card-running{border-color:rgba(124,185,255,.25);background:rgba(124,185,255,.04);}
|
||||
.tool-card-header{display:flex;align-items:center;gap:7px;padding:4px 10px;cursor:pointer;user-select:none;}
|
||||
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;}
|
||||
.tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
|
||||
.tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;transition:transform .15s;}
|
||||
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
|
||||
.tool-card-detail{display:none;border-top:1px solid rgba(255,255,255,.06);padding:8px 12px;}
|
||||
.tool-card.open .tool-card-detail{display:block;}
|
||||
.tool-card-args{margin-bottom:6px;}
|
||||
.tool-card-args div{font-size:11px;line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;word-break:break-all;}
|
||||
.tool-card-result pre{font-size:11px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow-y:auto;margin:0;line-height:1.55;}
|
||||
|
||||
/* ── Scrollbar polish ── */
|
||||
::-webkit-scrollbar{width:5px;height:5px;}
|
||||
::-webkit-scrollbar-track{background:transparent;}
|
||||
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:3px;}
|
||||
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.22);}
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.12) transparent; }
|
||||
589
static/ui.js
Normal file
589
static/ui.js
Normal file
@@ -0,0 +1,589 @@
|
||||
const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null};
|
||||
const INFLIGHT={}; // keyed by session_id while request in-flight
|
||||
const MSG_QUEUE=[]; // messages queued while a request is in-flight
|
||||
const $=id=>document.getElementById(id);
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
function renderMd(raw){
|
||||
let s=raw||'';
|
||||
s=s.replace(/```([\w+-]*)\n?([\s\S]*?)```/g,(_,lang,code)=>{const h=lang?`<div class="pre-header">${esc(lang)}</div>`:'';return `${h}<pre><code>${esc(code.replace(/\n$/,''))}</code></pre>`;});
|
||||
s=s.replace(/`([^`\n]+)`/g,(_,c)=>`<code>${esc(c)}</code>`);
|
||||
s=s.replace(/\*\*\*(.+?)\*\*\*/g,'<strong><em>$1</em></strong>');
|
||||
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>');
|
||||
s=s.replace(/\*([^*\n]+)\*/g,'<em>$1</em>');
|
||||
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>').replace(/^## (.+)$/gm,'<h2>$1</h2>').replace(/^# (.+)$/gm,'<h1>$1</h1>');
|
||||
s=s.replace(/^---+$/gm,'<hr>');
|
||||
s=s.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>');
|
||||
// B8: improved list handling supporting up to 2 levels of indentation
|
||||
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
|
||||
const lines=block.trimEnd().split('\n');
|
||||
let html='<ul>';
|
||||
for(const l of lines){
|
||||
const indent=/^ {2,}/.test(l);
|
||||
const text=l.replace(/^ {0,4}[-*+] /,'');
|
||||
if(indent) html+=`<li style="margin-left:16px">${text}</li>`;
|
||||
else html+=`<li>${text}</li>`;
|
||||
}
|
||||
return html+'</ul>';
|
||||
});
|
||||
s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{
|
||||
const lines=block.trimEnd().split('\n');
|
||||
let html='<ol>';
|
||||
for(const l of lines){
|
||||
const text=l.replace(/^ {0,4}\d+\. /,'');
|
||||
html+=`<li>${text}</li>`;
|
||||
}
|
||||
return html+'</ol>';
|
||||
});
|
||||
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
|
||||
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
|
||||
const rows=block.trim().split('\n').filter(r=>r.trim());
|
||||
if(rows.length<2)return block;
|
||||
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
|
||||
if(!isSep(rows[1]))return block;
|
||||
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<td>${c.trim()}</td>`).join('');
|
||||
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`<th>${c.trim()}</th>`).join('');
|
||||
const header=`<tr>${parseHeader(rows[0])}</tr>`;
|
||||
const body=rows.slice(2).map(r=>`<tr>${parseRow(r)}</tr>`).join('');
|
||||
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
|
||||
});
|
||||
const parts=s.split(/\n{2,}/);
|
||||
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
|
||||
return s;
|
||||
}
|
||||
|
||||
function setStatus(t){
|
||||
const bar=$('activityBar');
|
||||
const txt=$('activityText');
|
||||
const dismiss=$('btnDismissStatus');
|
||||
if(!bar||!txt)return;
|
||||
if(!t){
|
||||
bar.style.display='none';
|
||||
txt.textContent='';
|
||||
if(dismiss)dismiss.style.display='none';
|
||||
} else {
|
||||
txt.textContent=t;
|
||||
bar.style.display='';
|
||||
// Show dismiss X only for static/error messages, not transient busy ones
|
||||
const transient = t.endsWith('…') || t === 'Hermes is thinking…';
|
||||
if(dismiss)dismiss.style.display=(!transient && !S.busy)?'inline':'none';
|
||||
}
|
||||
}
|
||||
function setBusy(v){
|
||||
S.busy=v;
|
||||
$('btnSend').disabled=v;
|
||||
const dots=$('activityDots');
|
||||
if(dots) dots.style.display=v?'flex':'none';
|
||||
if(!v){
|
||||
setStatus('');
|
||||
// Always hide Cancel button when not busy
|
||||
const _cb=$('btnCancel');if(_cb)_cb.style.display='none';
|
||||
updateQueueBadge();
|
||||
// Drain one queued message after UI settles
|
||||
if(MSG_QUEUE.length>0){
|
||||
const next=MSG_QUEUE.shift();
|
||||
updateQueueBadge();
|
||||
setTimeout(()=>{ $('msg').value=next; send(); }, 120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateQueueBadge(){
|
||||
let badge=$('queueBadge');
|
||||
if(MSG_QUEUE.length>0){
|
||||
if(!badge){
|
||||
badge=document.createElement('div');
|
||||
badge.id='queueBadge';
|
||||
badge.style.cssText='position:fixed;bottom:80px;right:24px;background:rgba(124,185,255,.18);border:1px solid rgba(124,185,255,.4);color:var(--blue);font-size:12px;font-weight:600;padding:6px 14px;border-radius:20px;z-index:50;pointer-events:none;backdrop-filter:blur(8px);';
|
||||
document.body.appendChild(badge);
|
||||
}
|
||||
badge.textContent=MSG_QUEUE.length===1?'1 message queued':`${MSG_QUEUE.length} messages queued`;
|
||||
} else {
|
||||
if(badge) badge.remove();
|
||||
}
|
||||
}
|
||||
function showToast(msg,ms){const el=$('toast');el.textContent=msg;el.classList.add('show');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('show'),ms||2800);}
|
||||
|
||||
function copyMsg(btn){
|
||||
const row=btn.closest('.msg-row');
|
||||
const text=row?row.dataset.rawText:'';
|
||||
if(!text)return;
|
||||
navigator.clipboard.writeText(text).then(()=>{
|
||||
const orig=btn.innerHTML;btn.innerHTML='✓';btn.style.color='var(--blue)';
|
||||
setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500);
|
||||
}).catch(()=>showToast('Copy failed'));
|
||||
}
|
||||
|
||||
// ── Reconnect banner (B4/B5: reload resilience) ──
|
||||
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
|
||||
|
||||
function markInflight(sid, streamId) {
|
||||
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
|
||||
}
|
||||
function clearInflight() {
|
||||
localStorage.removeItem(INFLIGHT_KEY);
|
||||
}
|
||||
function showReconnectBanner(msg) {
|
||||
$('reconnectMsg').textContent = msg || 'A response may have been in progress when you last left.';
|
||||
$('reconnectBanner').classList.add('visible');
|
||||
}
|
||||
function dismissReconnect() {
|
||||
$('reconnectBanner').classList.remove('visible');
|
||||
clearInflight();
|
||||
}
|
||||
async function refreshSession() {
|
||||
dismissReconnect();
|
||||
if (!S.session) return;
|
||||
try {
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||
S.session = data.session;
|
||||
S.messages = (data.session.messages || []).filter(m => {
|
||||
if (!m || !m.role || m.role === 'tool') return false;
|
||||
if (m.role === 'assistant') { let c = m.content || ''; if (Array.isArray(c)) c = c.map(p => p.text||'').join(''); return String(c).trim().length > 0; }
|
||||
return true;
|
||||
});
|
||||
syncTopbar(); renderMessages();
|
||||
showToast('Conversation refreshed');
|
||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||
}
|
||||
async function checkInflightOnBoot(sid) {
|
||||
const raw = localStorage.getItem(INFLIGHT_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const {sid: inflightSid, streamId, ts} = JSON.parse(raw);
|
||||
if (inflightSid !== sid) { clearInflight(); return; }
|
||||
// Only show banner if the in-flight entry is less than 10 minutes old
|
||||
if (Date.now() - ts > 10 * 60 * 1000) { clearInflight(); return; }
|
||||
// Check if stream is still active
|
||||
const status = await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId || '')}`);
|
||||
if (status.active) {
|
||||
// Stream is genuinely still running -- show the banner
|
||||
showReconnectBanner('A response is still being generated. Reload when ready?');
|
||||
} else {
|
||||
// Stream finished. Only show banner if reload happened within 90 seconds
|
||||
// (longer gap = normal completed session, not a mid-stream reload)
|
||||
if (Date.now() - ts < 90 * 1000) {
|
||||
showReconnectBanner('A response was in progress when you last left. Messages may have updated.');
|
||||
} else {
|
||||
clearInflight(); // completed normally, no banner needed
|
||||
}
|
||||
}
|
||||
} catch(e) { clearInflight(); }
|
||||
}
|
||||
|
||||
function syncTopbar(){
|
||||
if(!S.session){
|
||||
// Show default workspace name even without a session
|
||||
const sidebarName=$('sidebarWsName');
|
||||
if(sidebarName && sidebarName.textContent==='Workspace'){
|
||||
sidebarName.textContent='No workspace';
|
||||
}
|
||||
return;
|
||||
}
|
||||
$('topbarTitle').textContent=S.session.title||'Untitled';
|
||||
const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool');
|
||||
$('topbarMeta').textContent=`${vis.length} messages`;
|
||||
const m=S.session.model||'';
|
||||
const MODEL_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-2.5-pro':'Gemini 2.5 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'};
|
||||
$('modelSelect').value=m; // set dropdown first so chip reads consistent value
|
||||
// Show Clear button only when session has messages
|
||||
const clearBtn=$('btnClearConv');
|
||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(m=>m.role!=='tool').length>0)?'':'none';
|
||||
const displayModel=$('modelSelect').value||m;
|
||||
$('modelChip').textContent=MODEL_LABELS[displayModel]||(displayModel.split('/').pop()||'Unknown');
|
||||
const ws=S.session.workspace||'';
|
||||
$('wsChip').textContent=ws.split('/').slice(-2).join('/')||ws;
|
||||
// Update workspace chip in topbar with friendly name from workspace list
|
||||
const wsChipEl=$('wsChip');
|
||||
if(wsChipEl){
|
||||
const wsFriendly=getWorkspaceFriendlyName(ws);
|
||||
wsChipEl.textContent='\u{1F4C1} '+wsFriendly+' \u25BE';
|
||||
}
|
||||
// Update sidebar workspace display
|
||||
const sidebarName=$('sidebarWsName');
|
||||
const sidebarPath=$('sidebarWsPath');
|
||||
if(sidebarName){
|
||||
sidebarName.textContent=getWorkspaceFriendlyName(ws);
|
||||
}
|
||||
if(sidebarPath){
|
||||
sidebarPath.textContent=ws;
|
||||
}
|
||||
// modelSelect already set above
|
||||
}
|
||||
|
||||
function msgContent(m){
|
||||
// Extract plain text content from a message for filtering
|
||||
let c=m.content||'';
|
||||
if(Array.isArray(c))c=c.filter(p=>p&&p.type==='text').map(p=>p.text||'').join('').trim();
|
||||
return String(c).trim();
|
||||
}
|
||||
|
||||
function renderMessages(){
|
||||
const inner=$('msgInner');
|
||||
const vis=S.messages.filter(m=>{
|
||||
if(!m||!m.role||m.role==='tool')return false;
|
||||
return msgContent(m)||m.attachments?.length;
|
||||
});
|
||||
$('emptyState').style.display=vis.length?'none':'';
|
||||
inner.innerHTML='';
|
||||
// Track original indices (in S.messages) so truncate knows the cut point
|
||||
const visWithIdx=[];
|
||||
let rawIdx=0;
|
||||
for(const m of S.messages){
|
||||
if(!m||!m.role||m.role==='tool'){rawIdx++;continue;}
|
||||
if(msgContent(m)||m.attachments?.length) visWithIdx.push({m,rawIdx});
|
||||
rawIdx++;
|
||||
}
|
||||
for(let vi=0;vi<visWithIdx.length;vi++){
|
||||
const {m,rawIdx}=visWithIdx[vi];
|
||||
let content=m.content||'';
|
||||
if(Array.isArray(content))content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
|
||||
const isUser=m.role==='user';
|
||||
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
|
||||
const row=document.createElement('div');row.className='msg-row';
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
let filesHtml='';
|
||||
if(m.attachments&&m.attachments.length)
|
||||
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">📎 ${esc(f)}</div>`).join('')}</div>`;
|
||||
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
|
||||
// Action buttons for this bubble
|
||||
const editBtn = isUser ? `<button class="msg-action-btn" title="Edit message" onclick="editMessage(this)">✎</button>` : '';
|
||||
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="Regenerate response" onclick="regenerateResponse(this)">↻</button>` : '';
|
||||
row.innerHTML=`<div class="msg-role ${m.role}"><div class="role-icon ${m.role}">${isUser?'Y':'H'}</div><span style="font-size:12px">${isUser?'You':'Hermes'}</span><span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="Copy" onclick="copyMsg(this)">📋</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
|
||||
row.dataset.rawText = String(content).trim();
|
||||
inner.appendChild(row);
|
||||
}
|
||||
// Insert settled tool call cards (history view only).
|
||||
// During live streaming, tool cards are rendered in #liveToolCards by the
|
||||
// tool SSE handler and never mixed into the message list until done fires.
|
||||
if(!S.busy && S.toolCalls && S.toolCalls.length){
|
||||
inner.querySelectorAll('.tool-card-row').forEach(el=>el.remove());
|
||||
const byAssistant = {};
|
||||
for(const tc of S.toolCalls){
|
||||
const key = tc.assistant_msg_idx !== undefined ? tc.assistant_msg_idx : -1;
|
||||
if(!byAssistant[key]) byAssistant[key] = [];
|
||||
byAssistant[key].push(tc);
|
||||
}
|
||||
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
|
||||
for(const [key, cards] of Object.entries(byAssistant)){
|
||||
const aIdx = parseInt(key);
|
||||
let insertBefore = null;
|
||||
if(aIdx === -1){
|
||||
for(let i=allRows.length-1;i>=0;i--){
|
||||
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
|
||||
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=allRows[i];break;}
|
||||
}
|
||||
} else {
|
||||
for(const r of allRows){
|
||||
const ri=parseInt(r.dataset.msgIdx||'-1');
|
||||
if(ri>aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){insertBefore=r;break;}
|
||||
}
|
||||
}
|
||||
const frag=document.createDocumentFragment();
|
||||
for(const tc of cards){frag.appendChild(buildToolCard(tc));}
|
||||
if(insertBefore) inner.insertBefore(frag,insertBefore);
|
||||
else inner.appendChild(frag);
|
||||
}
|
||||
}
|
||||
$('messages').scrollTop=$('messages').scrollHeight;
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>highlightCode());
|
||||
// Refresh todo panel if it's currently open
|
||||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||
loadTodos();
|
||||
}
|
||||
}
|
||||
|
||||
function toolIcon(name){
|
||||
const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍',
|
||||
web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧',
|
||||
memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖',
|
||||
send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️'};
|
||||
return icons[name]||'🔧';
|
||||
}
|
||||
|
||||
function buildToolCard(tc){
|
||||
const row=document.createElement('div');
|
||||
row.className='msg-row tool-card-row';
|
||||
const icon=toolIcon(tc.name);
|
||||
const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0);
|
||||
let displaySnippet='';
|
||||
if(tc.snippet){
|
||||
const s=tc.snippet;
|
||||
if(s.length<=220){displaySnippet=s;}
|
||||
else{
|
||||
const cutoff=s.slice(0,220);
|
||||
const lastBreak=Math.max(cutoff.lastIndexOf('. '),cutoff.lastIndexOf('\n'),cutoff.lastIndexOf('; '));
|
||||
displaySnippet=lastBreak>80?s.slice(0,lastBreak+1):cutoff;
|
||||
}
|
||||
}
|
||||
const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length;
|
||||
const runIndicator=tc.done===false?'<span class="tool-card-running-dot"></span>':'';
|
||||
row.innerHTML=`
|
||||
<div class="tool-card${tc.done===false?' tool-card-running':''}">
|
||||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||||
${runIndicator}
|
||||
<span class="tool-card-icon">${icon}</span>
|
||||
<span class="tool-card-name">${esc(tc.name)}</span>
|
||||
<span class="tool-card-preview">${esc(tc.preview||displaySnippet||'')}</span>
|
||||
${hasDetail?'<span class="tool-card-toggle">▸</span>':''}
|
||||
</div>
|
||||
${hasDetail?`<div class="tool-card-detail">
|
||||
${tc.args&&Object.keys(tc.args).length?`<div class="tool-card-args">${
|
||||
Object.entries(tc.args).map(([k,v])=>`<div><span class="tool-arg-key">${esc(k)}</span> <span class="tool-arg-val">${esc(String(v))}</span></div>`).join('')
|
||||
}</div>`:''}
|
||||
${displaySnippet?`<div class="tool-card-result">
|
||||
<pre>${esc(displaySnippet)}</pre>
|
||||
${hasMore?`<button class="tool-card-more" data-full="${esc(tc.snippet||'').replace(/"/g,'"')}" data-short="${esc(displaySnippet||'').replace(/"/g,'"')}" onclick="event.stopPropagation();const p=this.previousElementSibling;const full=this.dataset.full;const short=this.dataset.short;p.textContent=p.textContent===short?full:short;this.textContent=p.textContent===short?'Show more':'Show less'">Show more</button>`:''}
|
||||
</div>`:''}
|
||||
</div>`:''}
|
||||
</div>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── Live tool card helpers (called during SSE streaming) ──
|
||||
function appendLiveToolCard(tc){
|
||||
const container=$('liveToolCards');
|
||||
if(!container)return;
|
||||
container.style.display='';
|
||||
// Update existing card if same tool call id (e.g. snippet arrives after done)
|
||||
const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`);
|
||||
if(existing){existing.replaceWith(buildToolCard(tc));return;}
|
||||
const card=buildToolCard(tc);
|
||||
if(tc.tid)card.dataset.tid=tc.tid;
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function clearLiveToolCards(){
|
||||
const container=$('liveToolCards');
|
||||
if(!container)return;
|
||||
container.innerHTML='';
|
||||
container.style.display='none';
|
||||
}
|
||||
|
||||
// ── Edit + Regenerate ──
|
||||
|
||||
function editMessage(btn) {
|
||||
if(S.busy) return;
|
||||
const row = btn.closest('.msg-row');
|
||||
if(!row) return;
|
||||
const msgIdx = parseInt(row.dataset.msgIdx, 10);
|
||||
const originalText = row.dataset.rawText || '';
|
||||
const body = row.querySelector('.msg-body');
|
||||
if(!body || row.dataset.editing) return;
|
||||
row.dataset.editing = '1';
|
||||
|
||||
// Replace msg-body with an editable textarea
|
||||
const ta = document.createElement('textarea');
|
||||
ta.className = 'msg-edit-area';
|
||||
ta.value = originalText;
|
||||
body.replaceWith(ta);
|
||||
// Resize after DOM insertion so scrollHeight is correct
|
||||
requestAnimationFrame(() => { autoResizeTextarea(ta); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); });
|
||||
ta.addEventListener('input', () => autoResizeTextarea(ta));
|
||||
|
||||
// Action bar below the textarea
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'msg-edit-bar';
|
||||
bar.innerHTML = `<button class="msg-edit-send">Send edit</button><button class="msg-edit-cancel">Cancel</button>`;
|
||||
ta.after(bar);
|
||||
|
||||
bar.querySelector('.msg-edit-send').onclick = async () => {
|
||||
const newText = ta.value.trim();
|
||||
if(!newText) return;
|
||||
await submitEdit(msgIdx, newText);
|
||||
};
|
||||
bar.querySelector('.msg-edit-cancel').onclick = () => cancelEdit(row, originalText, body);
|
||||
|
||||
ta.addEventListener('keydown', e => {
|
||||
if(e.key==='Enter' && !e.shiftKey) { e.preventDefault(); bar.querySelector('.msg-edit-send').click(); }
|
||||
if(e.key==='Escape') { e.preventDefault(); cancelEdit(row, originalText, body); }
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEdit(row, originalText, originalBody) {
|
||||
delete row.dataset.editing;
|
||||
const ta = row.querySelector('.msg-edit-area');
|
||||
const bar = row.querySelector('.msg-edit-bar');
|
||||
if(ta) ta.replaceWith(originalBody);
|
||||
if(bar) bar.remove();
|
||||
}
|
||||
|
||||
function autoResizeTextarea(ta) {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 300) + 'px';
|
||||
}
|
||||
|
||||
async function submitEdit(msgIdx, newText) {
|
||||
if(!S.session || S.busy) return;
|
||||
// Truncate session at msgIdx (keep messages before the edited one)
|
||||
// then re-send the edited text
|
||||
try {
|
||||
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
|
||||
session_id: S.session.session_id,
|
||||
keep_count: msgIdx // keep messages[0..msgIdx-1], discard from msgIdx onward
|
||||
})});
|
||||
S.messages = S.messages.slice(0, msgIdx);
|
||||
renderMessages();
|
||||
// Now send the edited message as a new chat
|
||||
$('msg').value = newText;
|
||||
await send();
|
||||
} catch(e) { setStatus('Edit failed: ' + e.message); }
|
||||
}
|
||||
|
||||
async function regenerateResponse(btn) {
|
||||
if(!S.session || S.busy) return;
|
||||
// Find the last user message and re-run it
|
||||
// Remove the last assistant message first (truncate to before it)
|
||||
const row = btn.closest('.msg-row');
|
||||
if(!row) return;
|
||||
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
|
||||
// Find the last user message text (one before this assistant message)
|
||||
let lastUserText = '';
|
||||
for(let i = assistantIdx - 1; i >= 0; i--) {
|
||||
const m = S.messages[i];
|
||||
if(m && m.role === 'user') { lastUserText = msgContent(m); break; }
|
||||
}
|
||||
if(!lastUserText) return;
|
||||
try {
|
||||
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
|
||||
session_id: S.session.session_id,
|
||||
keep_count: assistantIdx // remove the assistant message
|
||||
})});
|
||||
S.messages = S.messages.slice(0, assistantIdx);
|
||||
renderMessages();
|
||||
$('msg').value = lastUserText;
|
||||
await send();
|
||||
} catch(e) { setStatus('Regenerate failed: ' + e.message); }
|
||||
}
|
||||
|
||||
function highlightCode(container) {
|
||||
// Apply Prism.js syntax highlighting to all code blocks in container (or whole messages area)
|
||||
if(typeof Prism === 'undefined' || !Prism.highlightAllUnder) return;
|
||||
const el = container || $('msgInner');
|
||||
if(!el) return;
|
||||
// Prism autoloader handles language detection via class="language-xxx"
|
||||
Prism.highlightAllUnder(el);
|
||||
}
|
||||
|
||||
function appendThinking(){
|
||||
$('emptyState').style.display='none';
|
||||
const row=document.createElement('div');row.className='msg-row';row.id='thinkingRow';
|
||||
row.innerHTML=`<div class="msg-role assistant"><div class="role-icon assistant">H</div>Hermes</div><div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
|
||||
$('msgInner').appendChild(row);$('messages').scrollTop=$('messages').scrollHeight;
|
||||
}
|
||||
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
|
||||
|
||||
function fileIcon(name, type){
|
||||
if(type==='dir') return '📁';
|
||||
const e=fileExt(name);
|
||||
if(IMAGE_EXTS.has(e)) return '📷';
|
||||
if(MD_EXTS.has(e)) return '📝';
|
||||
if(typeof DOWNLOAD_EXTS!=='undefined'&&DOWNLOAD_EXTS.has(e)) return '⬇️';
|
||||
if(e==='.py') return '🐍';
|
||||
if(e==='.js'||e==='.ts'||e==='.jsx'||e==='.tsx') return '⚡';
|
||||
if(e==='.json'||e==='.yaml'||e==='.yml'||e==='.toml') return '⚙';
|
||||
if(e==='.sh'||e==='.bash') return '💻';
|
||||
if(e==='.pdf') return '⬇️';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
function renderFileTree(){
|
||||
const box=$('fileTree');box.innerHTML='';
|
||||
for(const item of S.entries){
|
||||
const el=document.createElement('div');el.className='file-item';
|
||||
|
||||
// Icon
|
||||
const iconEl=document.createElement('span');
|
||||
iconEl.className='file-icon';iconEl.textContent=fileIcon(item.name,item.type);
|
||||
el.appendChild(iconEl);
|
||||
|
||||
// Name -- takes all remaining space, truncates with ellipsis
|
||||
const nameEl=document.createElement('span');
|
||||
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=item.name;
|
||||
el.appendChild(nameEl);
|
||||
|
||||
// Size -- only for files, right-aligned, shrinks but never wraps
|
||||
if(item.type==='file'&&item.size){
|
||||
const sizeEl=document.createElement('span');
|
||||
sizeEl.className='file-size';
|
||||
sizeEl.textContent=`${(item.size/1024).toFixed(1)}k`;
|
||||
el.appendChild(sizeEl);
|
||||
}
|
||||
|
||||
// Delete button -- only for files, shown as a CSS class toggle on hover
|
||||
if(item.type==='file'){
|
||||
const del=document.createElement('button');
|
||||
del.className='file-del-btn';del.title='Delete';del.textContent='×';
|
||||
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
|
||||
el.appendChild(del);
|
||||
}
|
||||
|
||||
el.onclick=async()=>item.type==='dir'?loadDir(item.path):openFile(item.path);
|
||||
box.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkspaceFile(relPath, name){
|
||||
if(!S.session)return;
|
||||
if(!confirm(`Delete ${name}?`))return;
|
||||
try{
|
||||
await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})});
|
||||
showToast(`Deleted ${name}`);
|
||||
// Close preview if we just deleted the viewed file
|
||||
if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick();
|
||||
await loadDir('.');
|
||||
}catch(e){setStatus('Delete failed: '+e.message);}
|
||||
}
|
||||
|
||||
async function promptNewFile(){
|
||||
if(!S.session)return;
|
||||
const name=prompt('New file name (e.g. notes.md):','');
|
||||
if(!name||!name.trim())return;
|
||||
try{
|
||||
await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim(),content:''})});
|
||||
showToast(`Created ${name.trim()}`);
|
||||
await loadDir('.');
|
||||
// Open the new file immediately
|
||||
openFile(name.trim());
|
||||
}catch(e){setStatus('Create failed: '+e.message);}
|
||||
}
|
||||
|
||||
function renderTray(){
|
||||
const tray=$('attachTray');tray.innerHTML='';
|
||||
if(!S.pendingFiles.length){tray.classList.remove('has-files');return;}
|
||||
tray.classList.add('has-files');
|
||||
S.pendingFiles.forEach((f,i)=>{
|
||||
const chip=document.createElement('div');chip.className='attach-chip';
|
||||
chip.innerHTML=`📎 ${esc(f.name)} <button title="Remove">✕</button>`;
|
||||
chip.querySelector('button').onclick=()=>{S.pendingFiles.splice(i,1);renderTray();};
|
||||
tray.appendChild(chip);
|
||||
});
|
||||
}
|
||||
function addFiles(files){for(const f of files){if(!S.pendingFiles.find(p=>p.name===f.name))S.pendingFiles.push(f);}renderTray();}
|
||||
|
||||
async function uploadPendingFiles(){
|
||||
if(!S.pendingFiles.length||!S.session)return[];
|
||||
const names=[];let failures=0;
|
||||
const bar=$('uploadBar');const barWrap=$('uploadBarWrap');
|
||||
barWrap.classList.add('active');bar.style.width='0%';
|
||||
const total=S.pendingFiles.length;
|
||||
for(let i=0;i<total;i++){
|
||||
const f=S.pendingFiles[i];const fd=new FormData();
|
||||
fd.append('session_id',S.session.session_id);fd.append('file',f,f.name);
|
||||
try{
|
||||
const res=await fetch('/api/upload',{method:'POST',body:fd});
|
||||
if(!res.ok){const err=await res.text();throw new Error(err);}
|
||||
const data=await res.json();
|
||||
if(data.error)throw new Error(data.error);
|
||||
names.push(data.filename);
|
||||
}catch(e){failures++;setStatus(`\u274c Upload failed: ${f.name} \u2014 ${e.message}`);}
|
||||
bar.style.width=`${Math.round((i+1)/total*100)}%`;
|
||||
}
|
||||
barWrap.classList.remove('active');bar.style.width='0%';
|
||||
S.pendingFiles=[];renderTray();
|
||||
if(failures===total&&total>0)throw new Error(`All ${total} upload(s) failed`);
|
||||
return names;
|
||||
}
|
||||
|
||||
168
static/workspace.js
Normal file
168
static/workspace.js
Normal file
@@ -0,0 +1,168 @@
|
||||
async function api(path,opts={}){
|
||||
const res=await fetch(path,{headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok)throw new Error(await res.text());
|
||||
const ct=res.headers.get('content-type')||'';
|
||||
return ct.includes('application/json')?res.json():res.text();
|
||||
}
|
||||
|
||||
async function loadDir(path){
|
||||
if(!S.session)return;
|
||||
try{
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
S.entries=data.entries||[];renderFileTree();
|
||||
}catch(e){console.warn('loadDir',e);}
|
||||
}
|
||||
|
||||
// File extension sets for preview routing (must match server-side sets)
|
||||
const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']);
|
||||
const MD_EXTS = new Set(['.md','.markdown','.mdown']);
|
||||
// Binary formats that should download rather than preview
|
||||
const DOWNLOAD_EXTS = new Set([
|
||||
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
|
||||
'.pdf','.zip','.tar','.gz','.bz2','.7z','.rar',
|
||||
'.mp3','.mp4','.wav','.m4a','.ogg','.flac','.mov','.avi','.mkv','.webm',
|
||||
'.exe','.dmg','.pkg','.deb','.rpm',
|
||||
'.woff','.woff2','.ttf','.otf','.eot',
|
||||
'.bin','.dat','.db','.sqlite','.pyc','.class','.so','.dylib','.dll',
|
||||
]);
|
||||
|
||||
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
|
||||
|
||||
let _previewCurrentPath = ''; // relative path of currently previewed file
|
||||
let _previewCurrentMode = ''; // 'code' | 'md' | 'image'
|
||||
let _previewDirty = false; // true when edits are unsaved
|
||||
|
||||
function showPreview(mode){
|
||||
// mode: 'code' | 'image' | 'md'
|
||||
$('previewCode').style.display = mode==='code' ? '' : 'none';
|
||||
$('previewImgWrap').style.display = mode==='image' ? '' : 'none';
|
||||
$('previewMd').style.display = mode==='md' ? '' : 'none';
|
||||
$('previewEditArea').style.display = 'none'; // start in read-only
|
||||
const badge=$('previewBadge');
|
||||
badge.className='preview-badge '+mode;
|
||||
badge.textContent = mode==='image'?'image':mode==='md'?'md':fileExt($('previewPathText').textContent)||'text';
|
||||
_previewCurrentMode = mode;
|
||||
_previewDirty = false;
|
||||
updateEditBtn();
|
||||
}
|
||||
|
||||
function updateEditBtn(){
|
||||
const btn=$('btnEditFile');
|
||||
if(!btn)return;
|
||||
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md';
|
||||
btn.style.display = editable?'':'none';
|
||||
const editing = $('previewEditArea').style.display!=='none';
|
||||
btn.innerHTML = editing ? '💾 Save' : '✎ Edit';
|
||||
btn.title = editing ? 'Save changes' : 'Edit this file';
|
||||
btn.style.color = editing ? 'var(--blue)' : '';
|
||||
if(_previewDirty) btn.innerHTML = '💾 Save*';
|
||||
}
|
||||
|
||||
async function toggleEditMode(){
|
||||
const editing = $('previewEditArea').style.display!=='none';
|
||||
if(editing){
|
||||
// Save
|
||||
if(!S.session||!_previewCurrentPath)return;
|
||||
const content=$('previewEditArea').value;
|
||||
try{
|
||||
await api('/api/file/save',{method:'POST',body:JSON.stringify({
|
||||
session_id:S.session.session_id, path:_previewCurrentPath, content
|
||||
})});
|
||||
_previewDirty=false;
|
||||
// Update read-only views
|
||||
if(_previewCurrentMode==='code') $('previewCode').textContent=content;
|
||||
else $('previewMd').innerHTML=renderMd(content);
|
||||
$('previewEditArea').style.display='none';
|
||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
||||
else $('previewMd').style.display='';
|
||||
showToast('Saved');
|
||||
}catch(e){setStatus('Save failed: '+e.message);}
|
||||
}else{
|
||||
// Enter edit mode: populate textarea with current content
|
||||
const currentText = _previewCurrentMode==='code'
|
||||
? $('previewCode').textContent
|
||||
: _previewRawContent||'';
|
||||
$('previewEditArea').value=currentText;
|
||||
$('previewEditArea').style.display='';
|
||||
if(_previewCurrentMode==='code') $('previewCode').style.display='none';
|
||||
else $('previewMd').style.display='none';
|
||||
// Escape cancels the edit without saving
|
||||
$('previewEditArea').onkeydown=e=>{
|
||||
if(e.key==='Escape'){e.preventDefault();cancelEditMode();}
|
||||
};
|
||||
}
|
||||
updateEditBtn();
|
||||
}
|
||||
|
||||
let _previewRawContent = ''; // raw text for md files (to populate editor)
|
||||
|
||||
function cancelEditMode(){
|
||||
// Discard changes and return to read-only view
|
||||
$('previewEditArea').style.display='none';
|
||||
$('previewEditArea').onkeydown=null;
|
||||
if(_previewCurrentMode==='code') $('previewCode').style.display='';
|
||||
else $('previewMd').style.display='';
|
||||
_previewDirty=false;
|
||||
updateEditBtn();
|
||||
}
|
||||
|
||||
async function openFile(path){
|
||||
if(!S.session)return;
|
||||
const ext=fileExt(path);
|
||||
|
||||
// Binary/download-only formats: trigger browser download, don't preview
|
||||
if(DOWNLOAD_EXTS.has(ext)){
|
||||
downloadFile(path);
|
||||
return;
|
||||
}
|
||||
|
||||
$('previewPathText').textContent=path;
|
||||
$('previewArea').classList.add('visible');
|
||||
$('fileTree').style.display='none';
|
||||
|
||||
_previewCurrentPath = path;
|
||||
if(IMAGE_EXTS.has(ext)){
|
||||
// Image: load via raw endpoint, show as <img>
|
||||
showPreview('image');
|
||||
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`;
|
||||
$('previewImg').alt=path;
|
||||
$('previewImg').src=url;
|
||||
$('previewImg').onerror=()=>setStatus('Could not load image');
|
||||
} else if(MD_EXTS.has(ext)){
|
||||
// Markdown: fetch text, render with renderMd, display as formatted HTML
|
||||
try{
|
||||
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
showPreview('md');
|
||||
_previewRawContent = data.content;
|
||||
$('previewMd').innerHTML=renderMd(data.content);
|
||||
}catch(e){setStatus('Could not open file');}
|
||||
} else {
|
||||
// Plain code / text -- but fall back to download if server signals binary
|
||||
try{
|
||||
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
if(data.binary){
|
||||
// Server flagged this as binary content
|
||||
downloadFile(path);
|
||||
return;
|
||||
}
|
||||
showPreview('code');
|
||||
$('previewCode').textContent=data.content;
|
||||
}catch(e){
|
||||
// If it's a 400/too-large error, offer download instead
|
||||
downloadFile(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(path){
|
||||
if(!S.session)return;
|
||||
// Trigger browser download via the raw file endpoint with content-disposition attachment
|
||||
const url=`/api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
|
||||
const filename=path.split('/').pop();
|
||||
const a=document.createElement('a');
|
||||
a.href=url;a.download=filename;
|
||||
document.body.appendChild(a);a.click();
|
||||
setTimeout(()=>document.body.removeChild(a),100);
|
||||
showToast(`Downloading ${filename}\u2026`,2000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user