mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
7d1aa2e261
* feat: add manual 'Check for Updates' button in System settings (#785) Add a 'Check now' button next to the version badge in the System settings section, allowing users to manually trigger an update check at any time without waiting for the automatic periodic check. Changes: - index.html: add button with spinner and status text inline with version badge - panels.js: add checkUpdatesNow() calling /api/updates/check?force=1 with immediate feedback (checking... / up to date / X updates available) - style.css: style the button block and spinner - i18n.js: add 5 new keys (settings_check_now, settings_checking, settings_up_to_date, settings_updates_available, settings_updates_disabled) in all 6 locales (en, ru, es, de, zh, zh-Hant) * fix: sanitize error message in checkUpdatesNow to avoid exposing paths Review feedback: strip filesystem paths from error messages and cap length to prevent internal details leaking into the UI. * fix: fully sanitize error in update check — never expose raw e.message in UI Previous partial fix (80cdaee) stripped filesystem paths from e.message but still displayed the JS exception message to users. Per reviewer feedback and project convention (NEVER expose raw e.message in UI), replace with: - A generic user-facing i18n key (settings_update_check_failed) as default - Fallback to API response body error if available (structured, not raw) - Full error logged via console.warn for debugging - Button disable-during-check already confirmed working (try/finally pattern) - settings_update_check_failed key added in all 6 locales * fix(#785): align HTML selectors with CSS and add regression tests - Wrap update button in div#checkUpdatesBlock so CSS selectors apply - Change button class from sm-btn to btn-tiny (matching stylesheet) - Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny) - Move spinner sizing to CSS class .spinner-xs - Add 4 static tests in test_update_banner_fixes.py: checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales * feat: 'Keep workspace panel open' toggle in Appearance settings (#999) * feat: categorize providers in setup wizard (#603) - Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok), Ollama, LM Studio to the onboarding quick-setup catalog - Group providers into 3 categories: Easy start, Open/self-hosted, Specialized — rendered as <optgroup> in the provider dropdown - Generic base_url save logic (requires_base_url + default_base_url) instead of hardcoded provider checks - i18n keys for category labels in en, ru, es, zh, zh-Hant * ci: re-run tests * fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644 The test helper _available_models_with_cfg patches cfg in-memory but get_available_models() calls reload_config() when the config file's mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0 and _cfg_mtime starts at 0.0, triggering a reload that overwrites the test's mock with on-disk content. Fix: freeze _cfg_mtime to the current config file mtime inside the helper, so reload_config() is not triggered during the test. * fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests - gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview - x-ai: grok-4.20 → grok-3 - deepseek: deepseek-chat-v3-0324 → deepseek-chat - Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for gemini, deepseek, mistral, and x-ai through apply_onboarding_setup * test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai * fix(onboarding): correct stale model defaults for specialized providers Three issues in the new specialized provider catalog (#1027 hold reason): 1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog has the 3.1 family. Updated to `gemini-3.1-pro-preview`. 2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`. Updated. 3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")` which returns []. The catalog in api/config.py is keyed under "google" (even though the agent's alias map normalizes google -> gemini). Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces the actual 5-model list. Also forward-compatible lookup for x-ai (xai or x-ai key). Without these fixes, users picking gemini or x-ai in the wizard would see no model dropdown and the default_model written to config.yaml would 404 on first chat. deepseek default_model bumped from `deepseek-chat` to `deepseek-chat-v3-0324` to match the test fixture's expectation and the agent catalog's pinned version. Added two regression tests: - test_gemini_model_list_is_populated: pins the catalog-key correctness - test_specialized_default_models_match_catalog: pins the version prefixes (3.x for gemini, 4.x for grok) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: inline HTML preview in workspace panel (#779) Render .html/.htm files as live previews in a sandboxed iframe instead of showing raw source code. Adds an 'Open in browser' button to open the file in a new tab. Changes: - workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing in openFile(), and openInBrowser() function - index.html: add sandboxed iframe element and 'Open in browser' button in preview toolbar (visible only for HTML files) - i18n.js: add 'open_in_browser' key in all 6 locales The iframe uses sandbox='allow-scripts' for security. Download button remains available alongside the new preview. * docs: document sandbox security tradeoff for HTML preview Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work. Added code comment explaining the deliberate sandbox=allow-scripts choice: scripts are needed for most HTML documents but the iframe is still origin- isolated and cannot access parent cookies/data. * fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading routes.py: add inline_preview param — bypasses Content-Disposition:attachment for text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe. workspace.js: add &inline=1 to the iframe src URL. test: add 5 static regression tests for the inline HTML preview. * fix(security): CSP sandbox header for inline HTML preview The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only applies when HTML is loaded INSIDE that iframe. A user tricked into opening /api/file/raw?path=evil.html&inline=1 directly in a top-level tab (e.g. via a chat link) would render the HTML in the WebUI's origin without any sandbox, giving the page full access to cookies and localStorage. Server-side Content-Security-Policy: sandbox allow-scripts mirrors the iframe sandbox exactly: scripts run, but the document is treated as a unique opaque origin (no allow-same-origin) and cannot read WebUI cookies, localStorage, or postMessage to the parent regardless of how the URL is accessed. Added test_inline_html_response_sets_csp_sandbox to pin the header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43) * docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209 The stage commited2bd18listed v0.50.209 as a 4-PR release but the stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in without a corresponding CHANGELOG entry. Without this fix, the queue feature ships silently and the bundled Cloudflare CSP relaxation in api/helpers.py is also undocumented. Adds two entries: - Added: queue flyout (#1040) under v0.50.209 - Changed: CSP allowlist for Cloudflare Access deployments Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2819 lines
119 KiB
JavaScript
2819 lines
119 KiB
JavaScript
let _currentPanel = 'chat';
|
||
let _skillsData = null; // cached skills list
|
||
let _cronList = null; // cached cron jobs (array)
|
||
let _currentCronDetail = null; // full cron job object
|
||
let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
|
||
let _cronPreFormDetail = null; // snapshot of prior selection when entering a form
|
||
let _currentWorkspaceDetail = null; // { path, name, is_default }
|
||
let _workspaceMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
|
||
let _workspacePreFormDetail = null;
|
||
let _currentProfileDetail = null; // full profile object
|
||
let _profileMode = 'empty'; // 'empty' | 'read' | 'create'
|
||
let _profilePreFormDetail = null;
|
||
let _pendingSettingsTargetPanel = null; // destination selected while settings had unsaved changes
|
||
|
||
// Map of panel names → i18n keys for the app titlebar label.
|
||
const APP_TITLEBAR_KEYS = {
|
||
chat: 'tab_chat', tasks: 'tab_tasks', skills: 'tab_skills',
|
||
memory: 'tab_memory', workspaces: 'tab_workspaces',
|
||
profiles: 'tab_profiles', todos: 'tab_todos', settings: 'tab_settings',
|
||
};
|
||
|
||
/**
|
||
* Update the top app titlebar to reflect the current page or selected conversation.
|
||
* On the chat panel, a selected session's title takes precedence over the page name.
|
||
*/
|
||
function syncAppTitlebar() {
|
||
const titleEl = document.getElementById('appTitlebarTitle');
|
||
const subEl = document.getElementById('appTitlebarSub');
|
||
if (!titleEl) return;
|
||
const panel = (typeof _currentPanel === 'string' && _currentPanel) ? _currentPanel : 'chat';
|
||
let mainText = '';
|
||
let subText = '';
|
||
if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) {
|
||
mainText = S.session.title || (typeof t === 'function' ? t('untitled') : 'Untitled');
|
||
} else {
|
||
const key = APP_TITLEBAR_KEYS[panel];
|
||
mainText = key && typeof t === 'function' ? t(key) : (panel.charAt(0).toUpperCase() + panel.slice(1));
|
||
}
|
||
titleEl.textContent = mainText;
|
||
if (subEl) {
|
||
if (subText) { subEl.textContent = subText; subEl.hidden = false; }
|
||
else { subEl.textContent = ''; subEl.hidden = true; }
|
||
}
|
||
}
|
||
|
||
function _beginSettingsPanelSession() {
|
||
_settingsDirty = false;
|
||
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark';
|
||
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
|
||
_settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default';
|
||
_pendingSettingsTargetPanel = null;
|
||
_resetSettingsPanelState();
|
||
}
|
||
|
||
function _beforePanelSwitch(nextPanel) {
|
||
if (_currentPanel !== 'settings' || nextPanel === 'settings') return true;
|
||
if (_settingsDirty) {
|
||
_pendingSettingsTargetPanel = nextPanel || 'chat';
|
||
_showSettingsUnsavedBar();
|
||
return false;
|
||
}
|
||
_revertSettingsPreview();
|
||
_pendingSettingsTargetPanel = null;
|
||
_resetSettingsPanelState();
|
||
return true;
|
||
}
|
||
|
||
function _consumeSettingsTargetPanel(fallback = 'chat') {
|
||
const target = (_pendingSettingsTargetPanel && _pendingSettingsTargetPanel !== 'settings')
|
||
? _pendingSettingsTargetPanel
|
||
: fallback;
|
||
_pendingSettingsTargetPanel = null;
|
||
return target;
|
||
}
|
||
|
||
async function switchPanel(name, opts = {}) {
|
||
const nextPanel = name || 'chat';
|
||
const prevPanel = _currentPanel;
|
||
if (!opts.bypassSettingsGuard && !_beforePanelSwitch(nextPanel)) return false;
|
||
if (prevPanel !== 'settings' && nextPanel === 'settings') _beginSettingsPanelSession();
|
||
_currentPanel = nextPanel;
|
||
// Update nav tabs (rail + mobile sidebar-nav share data-panel)
|
||
document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel));
|
||
// Update panel views
|
||
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
|
||
const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1));
|
||
if (panelEl) panelEl.classList.add('active');
|
||
// Toggle main content view. Each entry in MAIN_VIEW_PANELS gets a matching
|
||
// showing-<name> class on <main>; no class means chat (the default).
|
||
const mainEl = document.querySelector('main.main');
|
||
if (mainEl) {
|
||
['settings','skills','memory','tasks','workspaces','profiles'].forEach(p => {
|
||
mainEl.classList.toggle('showing-' + p, nextPanel === p);
|
||
});
|
||
}
|
||
// Lazy-load panel data
|
||
if (nextPanel === 'tasks') await loadCrons();
|
||
if (nextPanel === 'skills') await loadSkills();
|
||
if (nextPanel === 'memory') await loadMemory();
|
||
if (nextPanel === 'workspaces') await loadWorkspacesPanel();
|
||
if (nextPanel === 'profiles') await loadProfilesPanel();
|
||
if (nextPanel === 'todos') loadTodos();
|
||
if (nextPanel === 'settings') {
|
||
switchSettingsSection(_currentSettingsSection);
|
||
loadSettingsPanel();
|
||
}
|
||
syncAppTitlebar();
|
||
return true;
|
||
}
|
||
|
||
// ── Cron panel ──
|
||
async function loadCrons(animate) {
|
||
const box = $('cronList');
|
||
const refreshBtn = $('cronRefreshBtn');
|
||
if (animate && refreshBtn) {
|
||
refreshBtn.style.opacity = '0.5';
|
||
refreshBtn.disabled = true;
|
||
}
|
||
try {
|
||
const data = await api('/api/crons');
|
||
_cronList = data.jobs || [];
|
||
if (!_cronList.length) {
|
||
box.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('cron_no_jobs'))}</div>`;
|
||
if (_cronMode !== 'create' && _cronMode !== 'edit') _clearCronDetail();
|
||
return;
|
||
}
|
||
box.innerHTML = '';
|
||
for (const job of _cronList) {
|
||
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 ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
|
||
item.innerHTML = `
|
||
<div class="cron-header">
|
||
<span class="cron-name" title="${esc(job.name)}">${esc(job.name)}</span>
|
||
<span class="cron-status ${statusClass}">${esc(statusLabel)}</span>
|
||
</div>`;
|
||
item.onclick = () => openCronDetail(job.id, item);
|
||
if (_currentCronDetail && _currentCronDetail.id === job.id) item.classList.add('active');
|
||
box.appendChild(item);
|
||
}
|
||
// Re-render current detail with fresh data if we have one and we're not in a form
|
||
if (_currentCronDetail && _cronMode !== 'create' && _cronMode !== 'edit') {
|
||
const refreshed = _cronList.find(j => j.id === _currentCronDetail.id);
|
||
if (refreshed) _renderCronDetail(refreshed);
|
||
else _clearCronDetail();
|
||
}
|
||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`; }
|
||
finally {
|
||
if (animate && refreshBtn) {
|
||
refreshBtn.style.opacity = '';
|
||
refreshBtn.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function _renderCronDetail(job){
|
||
_currentCronDetail = job;
|
||
const title = $('taskDetailTitle');
|
||
const body = $('taskDetailBody');
|
||
const empty = $('taskDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = job.name || job.schedule_display || '(unnamed)';
|
||
const statusClass = job.enabled === false ? 'warn' : job.state === 'paused' ? 'warn' : job.last_status === 'error' ? 'err' : 'ok';
|
||
const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active');
|
||
const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available');
|
||
const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never');
|
||
const schedule = job.schedule_display || (job.schedule && job.schedule.expression) || '';
|
||
const skills = Array.isArray(job.skills) && job.skills.length ? job.skills.join(', ') : '—';
|
||
const deliver = job.deliver || 'local';
|
||
const lastError = job.last_error ? `<div class="detail-row"><div class="detail-row-label">${esc(t('error_prefix').replace(/:\s*$/,''))}</div><div class="detail-row-value" style="color:var(--accent-text)">${esc(job.last_error)}</div></div>` : '';
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<div class="detail-card">
|
||
<div class="detail-card-title">${esc(t('cron_status_active').replace(/./,c=>c.toUpperCase()))}</div>
|
||
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value"><span class="detail-badge ${statusClass}">${esc(statusLabel)}</span></div></div>
|
||
<div class="detail-row"><div class="detail-row-label">Schedule</div><div class="detail-row-value"><code>${esc(schedule)}</code></div></div>
|
||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_next'))}</div><div class="detail-row-value">${esc(nextRun)}</div></div>
|
||
<div class="detail-row"><div class="detail-row-label">${esc(t('cron_last'))}</div><div class="detail-row-value">${esc(lastRun)}</div></div>
|
||
<div class="detail-row"><div class="detail-row-label">Deliver</div><div class="detail-row-value">${esc(deliver)}</div></div>
|
||
<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(skills)}</div></div>
|
||
${lastError}
|
||
</div>
|
||
<div class="detail-card">
|
||
<div class="detail-card-title">Prompt</div>
|
||
<div class="detail-prompt">${esc(job.prompt || '')}</div>
|
||
</div>
|
||
<div class="detail-card" id="cronDetailRuns">
|
||
<div class="detail-card-title">${esc(t('cron_last_output'))}</div>
|
||
<div style="color:var(--muted);font-size:12px">${esc(t('loading'))}</div>
|
||
</div>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_cronMode = 'read';
|
||
_setCronHeaderButtons('read', job);
|
||
// Load runs asynchronously
|
||
_loadCronDetailRuns(job.id);
|
||
}
|
||
|
||
function _setCronHeaderButtons(mode, job) {
|
||
const runBtn = $('btnRunTaskDetail');
|
||
const pauseBtn = $('btnPauseTaskDetail');
|
||
const resumeBtn = $('btnResumeTaskDetail');
|
||
const editBtn = $('btnEditTaskDetail');
|
||
const delBtn = $('btnDeleteTaskDetail');
|
||
const cancelBtn = $('btnCancelTaskDetail');
|
||
const saveBtn = $('btnSaveTaskDetail');
|
||
const hide = b => b && (b.style.display = 'none');
|
||
const show = b => b && (b.style.display = '');
|
||
if (mode === 'read') {
|
||
show(runBtn);
|
||
if (job && job.state === 'paused') { hide(pauseBtn); show(resumeBtn); }
|
||
else { show(pauseBtn); hide(resumeBtn); }
|
||
show(editBtn); show(delBtn); hide(cancelBtn); hide(saveBtn);
|
||
} else if (mode === 'create' || mode === 'edit') {
|
||
hide(runBtn); hide(pauseBtn); hide(resumeBtn); hide(editBtn); hide(delBtn);
|
||
show(cancelBtn); show(saveBtn);
|
||
} else {
|
||
[runBtn,pauseBtn,resumeBtn,editBtn,delBtn,cancelBtn,saveBtn].forEach(hide);
|
||
}
|
||
}
|
||
|
||
async function _loadCronDetailRuns(jobId){
|
||
try {
|
||
const data = await api(`/api/crons/output?job_id=${encodeURIComponent(jobId)}&limit=20`);
|
||
if (!_currentCronDetail || _currentCronDetail.id !== jobId) return;
|
||
const card = $('cronDetailRuns');
|
||
if (!card) return;
|
||
if (!data.outputs || !data.outputs.length) {
|
||
card.innerHTML = `<div class="detail-card-title">${esc(t('cron_last_output'))}</div><div style="color:var(--muted);font-size:12px">${esc(t('cron_no_runs_yet'))}</div>`;
|
||
return;
|
||
}
|
||
const rows = data.outputs.map((out, i) => {
|
||
const ts = out.filename.replace('.md','').replace(/_/g,' ');
|
||
const snippet = _cronOutputSnippet(out.content);
|
||
const rid = `cron-det-run-${jobId}-${i}`;
|
||
return `<div class="detail-run-item" id="${rid}">
|
||
<div class="detail-run-head" onclick="document.getElementById('${rid}').classList.toggle('open')"><span>${esc(ts)}</span><span style="opacity:.6">▸</span></div>
|
||
<div class="detail-run-body">${esc(snippet)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
card.innerHTML = `<div class="detail-card-title">${esc(t('cron_last_output'))}</div>${rows}`;
|
||
} catch(e) { /* ignore */ }
|
||
}
|
||
|
||
function openCronDetail(id, el){
|
||
const job = _cronList ? _cronList.find(j => j.id === id) : null;
|
||
if (!job) return;
|
||
document.querySelectorAll('.cron-item').forEach(e => e.classList.remove('active'));
|
||
const target = el || $('cron-' + id);
|
||
if (target) target.classList.add('active');
|
||
_cronPreFormDetail = null;
|
||
_editingCronId = null;
|
||
_renderCronDetail(job);
|
||
}
|
||
|
||
function _clearCronDetail(){
|
||
if (_cronRunningPoll) { clearInterval(_cronRunningPoll); _cronRunningPoll = null; }
|
||
_currentCronDetail = null;
|
||
_cronMode = 'empty';
|
||
const title = $('taskDetailTitle');
|
||
const body = $('taskDetailBody');
|
||
const empty = $('taskDetailEmpty');
|
||
if (title) title.textContent = '';
|
||
if (body) { body.innerHTML = ''; body.style.display = 'none'; }
|
||
if (empty) empty.style.display = '';
|
||
_setCronHeaderButtons('empty');
|
||
}
|
||
|
||
async function runCurrentCron(){ if (_currentCronDetail) await cronRun(_currentCronDetail.id); }
|
||
async function pauseCurrentCron(){ if (_currentCronDetail) await cronPause(_currentCronDetail.id); }
|
||
async function resumeCurrentCron(){ if (_currentCronDetail) await cronResume(_currentCronDetail.id); }
|
||
function editCurrentCron(){
|
||
if (!_currentCronDetail) return;
|
||
openCronEdit(_currentCronDetail);
|
||
}
|
||
async function deleteCurrentCron(){
|
||
if (!_currentCronDetail) return;
|
||
const id = _currentCronDetail.id;
|
||
const _ok = await showConfirmDialog({title:t('cron_delete_confirm_title'),message:t('cron_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
|
||
if(!_ok) return;
|
||
try {
|
||
await api('/api/crons/delete', {method:'POST', body: JSON.stringify({job_id: id})});
|
||
showToast(t('cron_job_deleted'));
|
||
_clearCronDetail();
|
||
await loadCrons();
|
||
} catch(e) { showToast(t('delete_failed') + e.message, 4000); }
|
||
}
|
||
|
||
let _cronSelectedSkills=[];
|
||
let _cronSkillsCache=null;
|
||
|
||
function openCronCreate(){
|
||
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
||
_cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null;
|
||
_editingCronId = null;
|
||
_cronMode = 'create';
|
||
_cronSelectedSkills = [];
|
||
_renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', isEdit:false });
|
||
_cronSkillsCache = null;
|
||
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
|
||
}
|
||
|
||
function openCronEdit(job){
|
||
if (!job) return;
|
||
_cronPreFormDetail = { ...job };
|
||
_editingCronId = job.id;
|
||
_cronMode = 'edit';
|
||
_cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
|
||
_renderCronForm({
|
||
name: job.name || '',
|
||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||
prompt: job.prompt || '',
|
||
deliver: job.deliver || 'local',
|
||
isEdit: true,
|
||
});
|
||
if (!_cronSkillsCache) {
|
||
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
|
||
} else {
|
||
_bindCronSkillPicker();
|
||
}
|
||
}
|
||
|
||
function _renderCronForm({ name, schedule, prompt, deliver, isEdit }){
|
||
const title = $('taskDetailTitle');
|
||
const body = $('taskDetailBody');
|
||
const empty = $('taskDetailEmpty');
|
||
if (!body || !title) return;
|
||
title.textContent = isEdit ? (t('edit') + ' · ' + (name || schedule || t('scheduled_jobs'))) : t('new_job');
|
||
const deliverOpt = (v,l) => `<option value="${v}"${deliver===v?' selected':''}>${esc(l)}</option>`;
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<form class="detail-form" onsubmit="event.preventDefault(); saveCronForm();">
|
||
<div class="detail-form-row">
|
||
<label for="cronFormName">${esc(t('cron_name_label') || 'Name')}</label>
|
||
<input type="text" id="cronFormName" value="${esc(name || '')}" placeholder="${esc(t('cron_name_placeholder') || 'Optional')}" autocomplete="off">
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="cronFormSchedule">${esc(t('cron_schedule_label') || 'Schedule')}</label>
|
||
<input type="text" id="cronFormSchedule" value="${esc(schedule || '')}" placeholder="0 9 * * * — every 1h — @daily" autocomplete="off" required>
|
||
<div class="detail-form-hint">${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}</div>
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="cronFormPrompt">${esc(t('cron_prompt_label') || 'Prompt')}</label>
|
||
<textarea id="cronFormPrompt" rows="6" placeholder="${esc(t('cron_prompt_placeholder') || 'Must be self-contained')}" required>${esc(prompt || '')}</textarea>
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="cronFormDeliver">${esc(t('cron_deliver_label') || 'Deliver output to')}</label>
|
||
<select id="cronFormDeliver" ${isEdit ? 'disabled' : ''}>
|
||
${deliverOpt('local', t('cron_deliver_local') || 'Local (save output only)')}
|
||
${deliverOpt('discord','Discord')}
|
||
${deliverOpt('telegram','Telegram')}
|
||
</select>
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="cronFormSkillSearch">${esc(t('cron_skills_label') || 'Skills')}</label>
|
||
<div class="skill-picker-wrap">
|
||
<input type="text" id="cronFormSkillSearch" placeholder="${esc(t('cron_skills_placeholder') || 'Add skills (optional)...')}" autocomplete="off" ${isEdit ? 'disabled' : ''}>
|
||
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
|
||
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
||
</div>
|
||
${isEdit ? `<div class="detail-form-hint">${esc(t('cron_skills_edit_hint') || 'Skill list is not editable after creation.')}</div>` : ''}
|
||
</div>
|
||
<div id="cronFormError" class="detail-form-error" style="display:none"></div>
|
||
</form>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_setCronHeaderButtons(isEdit ? 'edit' : 'create');
|
||
_renderCronSkillTags();
|
||
const focusEl = $('cronFormName');
|
||
if (focusEl) focusEl.focus();
|
||
}
|
||
|
||
function _renderCronSkillTags(){
|
||
const wrap=$('cronFormSkillTags');
|
||
if(!wrap)return;
|
||
wrap.innerHTML='';
|
||
for(const name of _cronSelectedSkills){
|
||
const tag=document.createElement('span');
|
||
tag.className='skill-tag';
|
||
tag.dataset.skill=name;
|
||
const rm=document.createElement('span');
|
||
rm.className='remove-tag';rm.textContent='×';
|
||
rm.onclick=()=>{_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==name);tag.remove();};
|
||
tag.appendChild(document.createTextNode(name));
|
||
tag.appendChild(rm);
|
||
wrap.appendChild(tag);
|
||
}
|
||
}
|
||
|
||
function _bindCronSkillPicker(){
|
||
const search=$('cronFormSkillSearch');
|
||
const dropdown=$('cronFormSkillDropdown');
|
||
if(!search||!dropdown)return;
|
||
search.oninput=()=>{
|
||
const q=search.value.trim().toLowerCase();
|
||
if(!q||!_cronSkillsCache){dropdown.style.display='none';return;}
|
||
const matches=_cronSkillsCache.filter(s=>
|
||
!_cronSelectedSkills.includes(s.name)&&
|
||
(s.name.toLowerCase().includes(q)||(s.category||'').toLowerCase().includes(q))
|
||
).slice(0,8);
|
||
if(!matches.length){dropdown.style.display='none';return;}
|
||
dropdown.innerHTML='';
|
||
for(const s of matches){
|
||
const opt=document.createElement('div');
|
||
opt.className='skill-opt';
|
||
opt.textContent=s.name+(s.category?' ('+s.category+')':'');
|
||
opt.onclick=()=>{
|
||
_cronSelectedSkills.push(s.name);
|
||
_renderCronSkillTags();
|
||
search.value='';
|
||
dropdown.style.display='none';
|
||
};
|
||
dropdown.appendChild(opt);
|
||
}
|
||
dropdown.style.display='';
|
||
};
|
||
search.onblur=()=>setTimeout(()=>{dropdown.style.display='none';},150);
|
||
}
|
||
|
||
function cancelCronForm(){
|
||
_editingCronId = null;
|
||
if (_cronPreFormDetail) {
|
||
const snap = _cronPreFormDetail;
|
||
_cronPreFormDetail = null;
|
||
_renderCronDetail(snap);
|
||
return;
|
||
}
|
||
_cronPreFormDetail = null;
|
||
_clearCronDetail();
|
||
}
|
||
|
||
async function saveCronForm(){
|
||
const nameEl=$('cronFormName');
|
||
const schEl=$('cronFormSchedule');
|
||
const promptEl=$('cronFormPrompt');
|
||
const delivEl=$('cronFormDeliver');
|
||
const errEl=$('cronFormError');
|
||
if(!schEl||!promptEl||!errEl) return;
|
||
const name=(nameEl?nameEl.value:'').trim();
|
||
const schedule=schEl.value.trim();
|
||
const prompt=promptEl.value.trim();
|
||
const deliver=delivEl?delivEl.value:'local';
|
||
errEl.style.display='none';
|
||
if(!schedule){errEl.textContent=t('cron_schedule_required_example');errEl.style.display='';return;}
|
||
if(!prompt){errEl.textContent=t('cron_prompt_required');errEl.style.display='';return;}
|
||
try{
|
||
if (_editingCronId) {
|
||
const updates = {job_id: _editingCronId, schedule, prompt};
|
||
if (name) updates.name = name;
|
||
await api('/api/crons/update', {method:'POST', body: JSON.stringify(updates)});
|
||
const editedId = _editingCronId;
|
||
_editingCronId = null;
|
||
_cronPreFormDetail = null;
|
||
showToast(t('cron_job_updated'));
|
||
await loadCrons();
|
||
const job = _cronList && _cronList.find(j => j.id === editedId);
|
||
if (job) openCronDetail(editedId);
|
||
return;
|
||
}
|
||
const body={schedule,prompt,deliver};
|
||
if(name)body.name=name;
|
||
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
|
||
const res = await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
|
||
_cronPreFormDetail = null;
|
||
showToast(t('cron_job_created'));
|
||
await loadCrons();
|
||
const newId = res && (res.id || (res.job && res.job.id));
|
||
if (newId) openCronDetail(newId);
|
||
else if (_cronList && _cronList.length) openCronDetail(_cronList[_cronList.length - 1].id);
|
||
}catch(e){
|
||
errEl.textContent=t('error_prefix')+e.message;errEl.style.display='';
|
||
}
|
||
}
|
||
|
||
// Back-compat aliases for any stale callers
|
||
const submitCronCreate = saveCronForm;
|
||
function toggleCronForm(){ openCronCreate(); }
|
||
|
||
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)';
|
||
}
|
||
|
||
let _cronRunningPoll = null; // timer for polling job status after trigger
|
||
|
||
async function cronRun(id) {
|
||
try {
|
||
await api('/api/crons/run', {method:'POST', body: JSON.stringify({job_id: id})});
|
||
showToast(t('cron_job_triggered'));
|
||
// Immediately show "running" state in detail if this job is selected
|
||
if (_currentCronDetail && _currentCronDetail.id === id) {
|
||
_setCronDetailStatus('running');
|
||
_startCronRunningPoll(id);
|
||
}
|
||
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
|
||
}
|
||
|
||
function _setCronDetailStatus(status) {
|
||
const badge = document.querySelector('#taskDetailBody .detail-badge');
|
||
if (!badge) return;
|
||
if (status === 'running') {
|
||
badge.className = 'detail-badge running';
|
||
badge.textContent = t('cron_status_running');
|
||
}
|
||
}
|
||
|
||
function _startCronRunningPoll(jobId) {
|
||
// Clear any existing poll
|
||
if (_cronRunningPoll) { clearInterval(_cronRunningPoll); _cronRunningPoll = null; }
|
||
let attempts = 0;
|
||
const maxAttempts = 10; // 10 * 3s = 30s max
|
||
_cronRunningPoll = setInterval(async () => {
|
||
attempts++;
|
||
if (!_currentCronDetail || _currentCronDetail.id !== jobId || attempts > maxAttempts) {
|
||
clearInterval(_cronRunningPoll);
|
||
_cronRunningPoll = null;
|
||
// Re-render detail with real status when poll ends (fallback from "running" indicator)
|
||
if (_currentCronDetail && _currentCronDetail.id === jobId) {
|
||
const refreshed = _cronList ? _cronList.find(j => j.id === jobId) : null;
|
||
if (refreshed) _renderCronDetail(refreshed);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
await loadCrons();
|
||
// loadCrons() re-renders the detail which overwrites our "running" badge.
|
||
// Re-apply the running indicator if poll is still active.
|
||
if (_cronRunningPoll) _setCronDetailStatus('running');
|
||
} catch(e) { /* ignore */ }
|
||
}, 3000);
|
||
}
|
||
|
||
async function cronPause(id) {
|
||
try {
|
||
await api('/api/crons/pause', {method:'POST', body: JSON.stringify({job_id: id})});
|
||
showToast(t('cron_job_paused'));
|
||
await loadCrons();
|
||
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
|
||
}
|
||
|
||
async function cronResume(id) {
|
||
try {
|
||
await api('/api/crons/resume', {method:'POST', body: JSON.stringify({job_id: id})});
|
||
showToast(t('cron_job_resumed'));
|
||
await loadCrons();
|
||
} catch(e) { showToast(t('failed_colon') + e.message, 4000); }
|
||
}
|
||
|
||
let _editingCronId = null;
|
||
|
||
function loadTodos() {
|
||
const panel = $('todoPanel');
|
||
if (!panel) return;
|
||
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
|
||
// Parse the most recent todo state from message history
|
||
let todos = [];
|
||
for (let i = sourceMessages.length - 1; i >= 0; i--) {
|
||
const m = sourceMessages[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">${esc(t('todos_no_active'))}</div>`;
|
||
return;
|
||
}
|
||
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
|
||
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;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||li('square',14)}</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;
|
||
const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true});
|
||
if(!_clrMsg) 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(t('conversation_cleared'));
|
||
} catch(e) { setStatus(t('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">${esc(t('skills_no_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">${li('folder',12)} ${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);
|
||
}
|
||
|
||
// Currently selected skill detail — kept across panel switches so re-entering
|
||
// the Skills view shows the last-viewed skill.
|
||
let _currentSkillDetail = null; // { name, category, content }
|
||
let _skillMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
|
||
let _skillPreFormDetail = null; // snapshot of previously-viewed skill when entering a form
|
||
let _editingSkillName = null;
|
||
|
||
function _stripYamlFrontmatter(content) {
|
||
if (!content) return { frontmatter: null, body: '' };
|
||
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
|
||
if (!m) return { frontmatter: null, body: content };
|
||
return { frontmatter: m[1], body: content.slice(m[0].length) };
|
||
}
|
||
|
||
function _renderSkillDetail(name, content, linkedFiles) {
|
||
const title = $('skillDetailTitle');
|
||
const body = $('skillDetailBody');
|
||
const empty = $('skillDetailEmpty');
|
||
const editBtn = $('btnEditSkillDetail');
|
||
const delBtn = $('btnDeleteSkillDetail');
|
||
if (title) title.textContent = name;
|
||
const { frontmatter, body: markdownBody } = _stripYamlFrontmatter(content);
|
||
let html = '';
|
||
if (frontmatter) {
|
||
html += `<details class="skill-frontmatter"><summary>${esc(t('skill_metadata'))}</summary><pre><code>${esc(frontmatter)}</code></pre></details>`;
|
||
}
|
||
html += renderMd(markdownBody || '(no content)');
|
||
const lf = linkedFiles || {};
|
||
const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0);
|
||
if (categories.length) {
|
||
html += `<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">${esc(t('linked_files'))}</div>`;
|
||
for (const [cat, files] of categories) {
|
||
html += `<div class="skill-linked-section"><h4>${esc(cat)}</h4>`;
|
||
for (const f of files) {
|
||
html += `<a class="skill-linked-file" href="#" data-skill-name="${esc(name)}" data-skill-file="${esc(f)}">${esc(f)}</a>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
body.innerHTML = `<div class="main-view-content skill-detail-content">${html}</div>`;
|
||
body.querySelectorAll('.skill-linked-file').forEach(a => {
|
||
a.addEventListener('click', e => { e.preventDefault(); openSkillFile(a.dataset.skillName, a.dataset.skillFile); });
|
||
});
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_skillMode = 'read';
|
||
_setSkillHeaderButtons('read');
|
||
}
|
||
|
||
function _setSkillHeaderButtons(mode) {
|
||
const editBtn = $('btnEditSkillDetail');
|
||
const delBtn = $('btnDeleteSkillDetail');
|
||
const cancelBtn = $('btnCancelSkillDetail');
|
||
const saveBtn = $('btnSaveSkillDetail');
|
||
const show = b => b && (b.style.display = '');
|
||
const hide = b => b && (b.style.display = 'none');
|
||
if (mode === 'read') { show(editBtn); show(delBtn); hide(cancelBtn); hide(saveBtn); }
|
||
else if (mode === 'create' || mode === 'edit') { hide(editBtn); hide(delBtn); show(cancelBtn); show(saveBtn); }
|
||
else { hide(editBtn); hide(delBtn); hide(cancelBtn); hide(saveBtn); }
|
||
}
|
||
|
||
async function openSkill(name, el) {
|
||
// Highlight active skill in the sidebar list
|
||
document.querySelectorAll('.skill-item').forEach(e => e.classList.remove('active'));
|
||
if (el) el.classList.add('active');
|
||
_skillPreFormDetail = null;
|
||
_editingSkillName = null;
|
||
try {
|
||
const data = await api(`/api/skills/content?name=${encodeURIComponent(name)}`);
|
||
_currentSkillDetail = { name, content: data.content || '', linked_files: data.linked_files || {} };
|
||
_renderSkillDetail(name, data.content || '', data.linked_files || {});
|
||
} catch(e) { setStatus(t('skill_load_failed') + e.message); }
|
||
}
|
||
|
||
async function openSkillFile(skillName, filePath) {
|
||
try {
|
||
const data = await api(`/api/skills/content?name=${encodeURIComponent(skillName)}&file=${encodeURIComponent(filePath)}`);
|
||
const body = $('skillDetailBody');
|
||
if (!body) return;
|
||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||
const isMd = ['md','markdown'].includes(ext);
|
||
const backLabel = t('skills_back_to').replace('{0}', skillName);
|
||
const header = `<div class="skill-file-breadcrumb"><a href="#" class="skill-file-back" data-skill-name="${esc(skillName)}">← ${esc(backLabel)}</a><span class="skill-file-path">${esc(filePath)}</span></div>`;
|
||
let content;
|
||
if (isMd) {
|
||
content = `<div class="main-view-content">${renderMd(data.content || '')}</div>`;
|
||
} else {
|
||
const escaped = esc(data.content || '');
|
||
content = `<pre class="skill-file-code"><code>${escaped}</code></pre>`;
|
||
}
|
||
body.innerHTML = header + content;
|
||
body.style.display = '';
|
||
const empty = $('skillDetailEmpty');
|
||
if (empty) empty.style.display = 'none';
|
||
body.querySelectorAll('.skill-file-back').forEach(a => {
|
||
a.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
if (_currentSkillDetail && _currentSkillDetail.name === a.dataset.skillName) {
|
||
_renderSkillDetail(_currentSkillDetail.name, _currentSkillDetail.content, _currentSkillDetail.linked_files);
|
||
} else {
|
||
openSkill(a.dataset.skillName, null);
|
||
}
|
||
});
|
||
});
|
||
if (!isMd) requestAnimationFrame(() => { if (typeof highlightCode === 'function') highlightCode(); });
|
||
} catch(e) { setStatus(t('skill_file_load_failed') + e.message); }
|
||
}
|
||
|
||
function editCurrentSkill() {
|
||
if (!_currentSkillDetail) return;
|
||
const s = _currentSkillDetail;
|
||
let category = '';
|
||
if (_skillsData) {
|
||
const match = _skillsData.find(x => x.name === s.name);
|
||
if (match) category = match.category || '';
|
||
}
|
||
_skillPreFormDetail = { name: s.name, content: s.content, linked_files: s.linked_files };
|
||
_editingSkillName = s.name;
|
||
_skillMode = 'edit';
|
||
_renderSkillForm({ name: s.name, category, content: s.content || '', isEdit: true });
|
||
}
|
||
|
||
function openSkillCreate() {
|
||
if (typeof switchPanel === 'function' && _currentPanel !== 'skills') switchPanel('skills');
|
||
_skillPreFormDetail = _currentSkillDetail ? { ..._currentSkillDetail } : null;
|
||
_editingSkillName = null;
|
||
_skillMode = 'create';
|
||
_renderSkillForm({ name: '', category: '', content: '', isEdit: false });
|
||
}
|
||
|
||
function _renderSkillForm({ name, category, content, isEdit }) {
|
||
const title = $('skillDetailTitle');
|
||
const body = $('skillDetailBody');
|
||
const empty = $('skillDetailEmpty');
|
||
if (!body || !title) return;
|
||
title.textContent = isEdit ? t('skills_edit') + ' · ' + name : t('new_skill');
|
||
const nameDisabled = isEdit ? 'disabled' : '';
|
||
const nameHint = isEdit ? `<div class="detail-form-hint">${esc(t('skill_rename_not_supported') || 'Renaming a skill is not supported. Create a new skill and delete the old one to rename.')}</div>` : '';
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<form class="detail-form" onsubmit="event.preventDefault(); saveSkillForm();">
|
||
<div class="detail-form-row">
|
||
<label for="skillFormName">${esc(t('skill_name') || 'Name')}</label>
|
||
<input type="text" id="skillFormName" value="${esc(name || '')}" placeholder="my-skill" autocomplete="off" ${nameDisabled} required>
|
||
${nameHint}
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="skillFormCategory">${esc(t('skill_category') || 'Category')}</label>
|
||
<input type="text" id="skillFormCategory" value="${esc(category || '')}" placeholder="${esc(t('skill_category_placeholder') || 'Optional, e.g. devops')}" autocomplete="off">
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="skillFormContent">${esc(t('skill_content') || 'SKILL.md content')}</label>
|
||
<textarea id="skillFormContent" rows="18" placeholder="${esc(t('skill_content_placeholder') || 'YAML frontmatter + markdown body')}">${esc(content || '')}</textarea>
|
||
</div>
|
||
<div id="skillFormError" class="detail-form-error" style="display:none"></div>
|
||
</form>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_setSkillHeaderButtons(isEdit ? 'edit' : 'create');
|
||
const focusEl = isEdit ? $('skillFormCategory') : $('skillFormName');
|
||
if (focusEl) focusEl.focus();
|
||
}
|
||
|
||
function cancelSkillForm() {
|
||
_editingSkillName = null;
|
||
if (_skillPreFormDetail) {
|
||
const snap = _skillPreFormDetail;
|
||
_skillPreFormDetail = null;
|
||
_currentSkillDetail = snap;
|
||
_renderSkillDetail(snap.name, snap.content || '', snap.linked_files || {});
|
||
return;
|
||
}
|
||
// Revert to empty state
|
||
_skillPreFormDetail = null;
|
||
_currentSkillDetail = null;
|
||
_skillMode = 'empty';
|
||
const body = $('skillDetailBody');
|
||
const empty = $('skillDetailEmpty');
|
||
const title = $('skillDetailTitle');
|
||
if (body) { body.innerHTML = ''; body.style.display = 'none'; }
|
||
if (empty) empty.style.display = '';
|
||
if (title) title.textContent = '';
|
||
_setSkillHeaderButtons('empty');
|
||
}
|
||
|
||
async function saveSkillForm() {
|
||
const nameInput = $('skillFormName');
|
||
const catInput = $('skillFormCategory');
|
||
const contentInput = $('skillFormContent');
|
||
const errEl = $('skillFormError');
|
||
if (!nameInput || !contentInput || !errEl) return;
|
||
const name = (nameInput.value || '').trim().toLowerCase().replace(/\s+/g, '-');
|
||
const category = (catInput ? (catInput.value || '').trim() : '');
|
||
const content = contentInput.value;
|
||
errEl.style.display = 'none';
|
||
if (!name) { errEl.textContent = t('skill_name_required'); errEl.style.display = ''; return; }
|
||
if (!content.trim()) { errEl.textContent = t('content_required'); errEl.style.display = ''; return; }
|
||
try {
|
||
await api('/api/skills/save', {method:'POST', body: JSON.stringify({name, category: category||undefined, content})});
|
||
showToast(_editingSkillName ? t('skill_updated') : t('skill_created'));
|
||
_skillsData = null;
|
||
_cronSkillsCache = null;
|
||
_editingSkillName = null;
|
||
_skillPreFormDetail = null;
|
||
await loadSkills();
|
||
// Reload the saved skill in read mode with fresh content
|
||
const row = document.querySelector(`.skill-item .skill-name`);
|
||
const match = document.querySelectorAll('.skill-item');
|
||
let targetEl = null;
|
||
match.forEach(el => {
|
||
const nm = el.querySelector('.skill-name');
|
||
if (nm && nm.textContent === name) targetEl = el;
|
||
});
|
||
await openSkill(name, targetEl);
|
||
} catch(e) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
|
||
}
|
||
|
||
// Back-compat aliases (delete flow + any old callers)
|
||
const submitSkillSave = saveSkillForm;
|
||
function toggleSkillForm(){ openSkillCreate(); }
|
||
|
||
async function deleteCurrentSkill() {
|
||
if (!_currentSkillDetail) return;
|
||
const name = _currentSkillDetail.name;
|
||
const message = t('skill_delete_confirm')
|
||
? t('skill_delete_confirm').replace('{0}', name)
|
||
: `Delete skill "${name}"?`;
|
||
const ok = await showConfirmDialog({
|
||
title: t('delete_title') || 'Delete',
|
||
message,
|
||
confirmLabel: t('delete_title') || 'Delete',
|
||
danger: true,
|
||
focusCancel: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api('/api/skills/delete', { method:'POST', body: JSON.stringify({ name }) });
|
||
_currentSkillDetail = null;
|
||
_skillPreFormDetail = null;
|
||
_skillsData = null;
|
||
_cronSkillsCache = null;
|
||
_skillMode = 'empty';
|
||
const body = $('skillDetailBody');
|
||
const empty = $('skillDetailEmpty');
|
||
const title = $('skillDetailTitle');
|
||
if (body) { body.innerHTML = ''; body.style.display = 'none'; }
|
||
if (empty) empty.style.display = '';
|
||
if (title) title.textContent = '';
|
||
_setSkillHeaderButtons('empty');
|
||
await loadSkills();
|
||
showToast(t('skill_deleted') || 'Skill deleted');
|
||
} catch(e) { setStatus(t('error_prefix') + e.message); }
|
||
}
|
||
|
||
// ── Memory (main view) ──
|
||
let _memoryData = null;
|
||
let _currentMemorySection = null; // 'memory' | 'user'
|
||
let _memoryMode = 'empty'; // 'empty' | 'read' | 'edit'
|
||
|
||
const MEMORY_SECTIONS = [
|
||
{ key: 'memory', labelKey: 'my_notes', emptyKey: 'no_notes_yet', iconKey: 'brain' },
|
||
{ key: 'user', labelKey: 'user_profile', emptyKey: 'no_profile_yet', iconKey: 'user' },
|
||
];
|
||
|
||
function _memorySectionMeta(key) {
|
||
return MEMORY_SECTIONS.find(s => s.key === key) || MEMORY_SECTIONS[0];
|
||
}
|
||
|
||
function _memorySectionContent(key) {
|
||
if (!_memoryData) return '';
|
||
return key === 'user' ? (_memoryData.user || '') : (_memoryData.memory || '');
|
||
}
|
||
|
||
function _memorySectionMtime(key) {
|
||
if (!_memoryData) return 0;
|
||
return key === 'user' ? (_memoryData.user_mtime || 0) : (_memoryData.memory_mtime || 0);
|
||
}
|
||
|
||
function _setMemoryHeaderButtons(mode) {
|
||
const show = b => b && (b.style.display = '');
|
||
const hide = b => b && (b.style.display = 'none');
|
||
const editBtn = $('btnEditMemoryDetail');
|
||
const cancelBtn = $('btnCancelMemoryDetail');
|
||
const saveBtn = $('btnSaveMemoryDetail');
|
||
if (mode === 'read') { show(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||
else if (mode === 'edit') { hide(editBtn); show(cancelBtn); show(saveBtn); }
|
||
else { hide(editBtn); hide(cancelBtn); hide(saveBtn); }
|
||
}
|
||
|
||
function _renderMemoryDetail(section) {
|
||
const meta = _memorySectionMeta(section);
|
||
const title = $('memoryDetailTitle');
|
||
const body = $('memoryDetailBody');
|
||
const empty = $('memoryDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = t(meta.labelKey);
|
||
const content = _memorySectionContent(section);
|
||
const mtime = _memorySectionMtime(section);
|
||
const mtimeStr = mtime ? new Date(mtime * 1000).toLocaleString() : '';
|
||
const mtimeHtml = mtimeStr ? `<div class="memory-detail-mtime">${esc(mtimeStr)}</div>` : '';
|
||
const inner = content
|
||
? `<div class="memory-content preview-md">${renderMd(content)}</div>`
|
||
: `<div class="memory-empty">${esc(t(meta.emptyKey))}</div>`;
|
||
body.innerHTML = `<div class="main-view-content">${mtimeHtml}${inner}</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_memoryMode = 'read';
|
||
_setMemoryHeaderButtons('read');
|
||
}
|
||
|
||
function _renderMemoryEdit(section) {
|
||
const meta = _memorySectionMeta(section);
|
||
const title = $('memoryDetailTitle');
|
||
const body = $('memoryDetailBody');
|
||
const empty = $('memoryDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = t(meta.labelKey);
|
||
const content = _memorySectionContent(section);
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<form class="detail-form" onsubmit="event.preventDefault(); submitMemorySave();">
|
||
<div class="detail-form-row">
|
||
<label for="memEditContent">${esc(t('memory_notes_label'))}</label>
|
||
<textarea id="memEditContent" rows="20" spellcheck="false">${esc(content)}</textarea>
|
||
</div>
|
||
<div id="memEditError" class="detail-form-error" style="display:none"></div>
|
||
</form>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_memoryMode = 'edit';
|
||
_setMemoryHeaderButtons('edit');
|
||
const ta = $('memEditContent');
|
||
if (ta) ta.focus();
|
||
}
|
||
|
||
function openMemorySection(section, el) {
|
||
_currentMemorySection = section;
|
||
document.querySelectorAll('#memoryPanel .side-menu-item').forEach(e => e.classList.remove('active'));
|
||
if (el) el.classList.add('active');
|
||
_renderMemoryDetail(section);
|
||
}
|
||
|
||
function editCurrentMemory() {
|
||
if (!_currentMemorySection) return;
|
||
_renderMemoryEdit(_currentMemorySection);
|
||
}
|
||
|
||
function cancelMemoryEdit() {
|
||
if (!_currentMemorySection) return;
|
||
_renderMemoryDetail(_currentMemorySection);
|
||
}
|
||
|
||
// Legacy alias (kept for any stale references)
|
||
function toggleMemoryEdit() { editCurrentMemory(); }
|
||
function closeMemoryEdit() { cancelMemoryEdit(); }
|
||
|
||
async function submitMemorySave() {
|
||
if (!_currentMemorySection) return;
|
||
const ta = $('memEditContent');
|
||
const errEl = $('memEditError');
|
||
if (!ta) return;
|
||
if (errEl) errEl.style.display = 'none';
|
||
try {
|
||
await api('/api/memory/write', {method:'POST', body: JSON.stringify({section: _currentMemorySection, content: ta.value})});
|
||
showToast(t('memory_saved'));
|
||
await loadMemory(true);
|
||
_renderMemoryDetail(_currentMemorySection);
|
||
} catch(e) {
|
||
if (errEl) { errEl.textContent = t('error_prefix') + e.message; errEl.style.display = ''; }
|
||
}
|
||
}
|
||
|
||
// ── Workspace management ──
|
||
let _workspaceList = []; // cached from /api/workspaces
|
||
let _wsSuggestTimer = null;
|
||
let _wsSuggestReq = 0;
|
||
let _wsSuggestIndex = -1;
|
||
|
||
function closeWorkspacePathSuggestions(){
|
||
const box=$('workspaceFormPathSuggestions');
|
||
if(box){
|
||
box.innerHTML='';
|
||
box.style.display='none';
|
||
}
|
||
_wsSuggestIndex=-1;
|
||
}
|
||
|
||
function _applyWorkspaceSuggestion(path){
|
||
const input=$('workspaceFormPath');
|
||
const next=(path||'').endsWith('/')?(path||''):`${path||''}/`;
|
||
if(input){
|
||
input.value=next;
|
||
input.focus();
|
||
input.setSelectionRange(next.length, next.length);
|
||
}
|
||
scheduleWorkspacePathSuggestions();
|
||
}
|
||
|
||
function _highlightWorkspaceSuggestion(idx){
|
||
const box=$('workspaceFormPathSuggestions');
|
||
if(!box)return;
|
||
const items=[...box.querySelectorAll('.ws-suggest-item')];
|
||
items.forEach((el,i)=>{
|
||
const active=i===idx;
|
||
el.classList.toggle('active', active);
|
||
if(active) el.scrollIntoView({block:'nearest'});
|
||
});
|
||
}
|
||
|
||
function _renderWorkspacePathSuggestions(paths){
|
||
const box=$('workspaceFormPathSuggestions');
|
||
if(!box)return;
|
||
box.innerHTML='';
|
||
if(!paths || !paths.length){
|
||
box.style.display='none';
|
||
_wsSuggestIndex=-1;
|
||
return;
|
||
}
|
||
paths.forEach((path, idx)=>{
|
||
const pathParts=(path||'').split('/').filter(Boolean);
|
||
const leaf=pathParts[pathParts.length-1]||path;
|
||
const parent=pathParts.length>1?`/${pathParts.slice(0,-1).join('/')}`:'/';
|
||
const item=document.createElement('button');
|
||
item.type='button';
|
||
item.className='ws-suggest-item';
|
||
item.innerHTML=`<span class="ws-suggest-leaf">${esc(leaf)}</span><span class="ws-suggest-parent">${esc(parent)}</span>`;
|
||
item.dataset.path=path;
|
||
item.onmouseenter=()=>{_wsSuggestIndex=idx;_highlightWorkspaceSuggestion(idx);};
|
||
item.onmousedown=(e)=>{e.preventDefault();_applyWorkspaceSuggestion(path);};
|
||
box.appendChild(item);
|
||
});
|
||
box.style.display='block';
|
||
_wsSuggestIndex=0;
|
||
_highlightWorkspaceSuggestion(_wsSuggestIndex);
|
||
}
|
||
|
||
async function _loadWorkspacePathSuggestions(prefix){
|
||
const reqId=++_wsSuggestReq;
|
||
try{
|
||
const qs=new URLSearchParams({prefix:prefix||''}).toString();
|
||
const data=await api(`/api/workspaces/suggest?${qs}`);
|
||
if(reqId!==_wsSuggestReq)return;
|
||
_renderWorkspacePathSuggestions(data.suggestions||[]);
|
||
}catch(_){
|
||
if(reqId!==_wsSuggestReq)return;
|
||
closeWorkspacePathSuggestions();
|
||
}
|
||
}
|
||
|
||
function scheduleWorkspacePathSuggestions(){
|
||
const input=$('workspaceFormPath');
|
||
if(!input)return;
|
||
const prefix=input.value.trim();
|
||
if(!prefix){
|
||
closeWorkspacePathSuggestions();
|
||
return;
|
||
}
|
||
if(_wsSuggestTimer) clearTimeout(_wsSuggestTimer);
|
||
_wsSuggestTimer=setTimeout(()=>{
|
||
_loadWorkspacePathSuggestions(prefix);
|
||
}, 120);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function syncWorkspaceDisplays(){
|
||
const hasSession=!!(S.session&&S.session.workspace);
|
||
// Fall back to the profile default workspace when no session is active yet.
|
||
// S._profileDefaultWorkspace is set during boot and profile switches from /api/settings.
|
||
const defaultWs=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||
const ws=hasSession?S.session.workspace:(defaultWs||'');
|
||
const hasWorkspace=!!(ws);
|
||
const label=hasWorkspace?getWorkspaceFriendlyName(ws):t('no_workspace');
|
||
|
||
const sidebarName=$('sidebarWsName');
|
||
const sidebarPath=$('sidebarWsPath');
|
||
if(sidebarName) sidebarName.textContent=label;
|
||
if(sidebarPath) sidebarPath.textContent=ws;
|
||
|
||
const composerChip=$('composerWorkspaceChip');
|
||
const composerLabel=$('composerWorkspaceLabel');
|
||
const composerDropdown=$('composerWsDropdown');
|
||
if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open');
|
||
// Only show workspace label once boot has finished to prevent
|
||
// flash of "No workspace" before the saved session finishes loading.
|
||
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
|
||
if(composerChip){
|
||
composerChip.disabled=!hasWorkspace;
|
||
composerChip.title=hasWorkspace?ws:t('no_workspace');
|
||
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
|
||
}
|
||
}
|
||
|
||
async function loadWorkspaceList(){
|
||
try{
|
||
const data = await api('/api/workspaces');
|
||
_workspaceList = data.workspaces || [];
|
||
syncWorkspaceDisplays();
|
||
return data;
|
||
}catch(e){ return {workspaces:[], last:''}; }
|
||
}
|
||
|
||
function _renderWorkspaceAction(label, meta, iconSvg, onClick){
|
||
const opt=document.createElement('div');
|
||
opt.className='ws-opt ws-opt-action';
|
||
opt.innerHTML=`<span class="ws-opt-icon">${iconSvg}</span><span><span class="ws-opt-name">${esc(label)}</span>${meta?`<span class="ws-opt-meta">${esc(meta)}</span>`:''}</span>`;
|
||
opt.onclick=onClick;
|
||
return opt;
|
||
}
|
||
|
||
function _positionComposerWsDropdown(){
|
||
const dd=$('composerWsDropdown');
|
||
const chip=$('composerWorkspaceGroup')||$('composerWorkspaceChip');
|
||
const footer=document.querySelector('.composer-footer');
|
||
if(!dd||!chip||!footer)return;
|
||
const chipRect=chip.getBoundingClientRect();
|
||
const footerRect=footer.getBoundingClientRect();
|
||
let left=chipRect.left-footerRect.left;
|
||
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
|
||
left=Math.max(0, Math.min(left, maxLeft));
|
||
dd.style.left=`${left}px`;
|
||
}
|
||
|
||
function _positionProfileDropdown(){
|
||
const dd=$('profileDropdown');
|
||
const chip=$('profileChip');
|
||
const footer=document.querySelector('.composer-footer');
|
||
if(!dd||!chip||!footer)return;
|
||
const chipRect=chip.getBoundingClientRect();
|
||
const footerRect=footer.getBoundingClientRect();
|
||
let left=chipRect.left-footerRect.left;
|
||
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
|
||
left=Math.max(0, Math.min(left, maxLeft));
|
||
dd.style.left=`${left}px`;
|
||
}
|
||
|
||
function renderWorkspaceDropdownInto(dd, workspaces, currentWs){
|
||
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=()=>switchToWorkspace(w.path,w.name);
|
||
dd.appendChild(opt);
|
||
}
|
||
dd.appendChild(document.createElement('div')).className='ws-divider';
|
||
dd.appendChild(_renderWorkspaceAction(
|
||
t('workspace_choose_path'),
|
||
t('workspace_choose_path_meta'),
|
||
li('folder',12),
|
||
()=>promptWorkspacePath()
|
||
));
|
||
const div=document.createElement('div');div.className='ws-divider';dd.appendChild(div);
|
||
dd.appendChild(_renderWorkspaceAction(
|
||
t('workspace_manage'),
|
||
t('workspace_manage_meta'),
|
||
li('settings',12),
|
||
()=>{closeWsDropdown();mobileSwitchPanel('workspaces');}
|
||
));
|
||
}
|
||
|
||
function toggleWsDropdown(){
|
||
const dd=$('wsDropdown');
|
||
if(!dd)return;
|
||
const open=dd.classList.contains('open');
|
||
if(open){closeWsDropdown();}
|
||
else{
|
||
closeProfileDropdown(); // close profile dropdown if open
|
||
loadWorkspaceList().then(data=>{
|
||
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
|
||
dd.classList.add('open');
|
||
});
|
||
}
|
||
}
|
||
|
||
function toggleComposerWsDropdown(){
|
||
const dd=$('composerWsDropdown');
|
||
const chip=$('composerWorkspaceChip');
|
||
if(!dd||!chip||chip.disabled)return;
|
||
const open=dd.classList.contains('open');
|
||
if(open){closeWsDropdown();}
|
||
else{
|
||
closeProfileDropdown();
|
||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||
loadWorkspaceList().then(data=>{
|
||
renderWorkspaceDropdownInto(dd, data.workspaces, S.session?S.session.workspace:'');
|
||
dd.classList.add('open');
|
||
_positionComposerWsDropdown();
|
||
chip.classList.add('active');
|
||
});
|
||
}
|
||
}
|
||
|
||
function closeWsDropdown(){
|
||
const dd=$('wsDropdown');
|
||
const composerDd=$('composerWsDropdown');
|
||
const composerChip=$('composerWorkspaceChip');
|
||
if(dd)dd.classList.remove('open');
|
||
if(composerDd)composerDd.classList.remove('open');
|
||
if(composerChip)composerChip.classList.remove('active');
|
||
}
|
||
document.addEventListener('click',e=>{
|
||
if(
|
||
!e.target.closest('#composerWorkspaceChip') &&
|
||
!e.target.closest('#composerWsDropdown')
|
||
) closeWsDropdown();
|
||
});
|
||
window.addEventListener('resize',()=>{
|
||
const dd=$('composerWsDropdown');
|
||
if(dd&&dd.classList.contains('open')) _positionComposerWsDropdown();
|
||
});
|
||
|
||
async function loadWorkspacesPanel(){
|
||
const panel=$('workspacesPanel');
|
||
if(!panel)return;
|
||
const data=await loadWorkspaceList();
|
||
renderWorkspacesPanel(data.workspaces);
|
||
}
|
||
|
||
function renderWorkspacesPanel(workspaces){
|
||
const panel=$('workspacesPanel');
|
||
panel.innerHTML='';
|
||
const activePath = S.session ? S.session.workspace : '';
|
||
for(const w of workspaces){
|
||
const row=document.createElement('div');
|
||
row.className='ws-row';
|
||
row.dataset.path = w.path;
|
||
const isActive = w.path === activePath;
|
||
const activeBadge = isActive ? `<span class="detail-badge active" style="margin-left:6px;font-size:9px;padding:1px 6px">${esc(t('profile_active'))}</span>` : '';
|
||
row.innerHTML=`
|
||
<div class="ws-row-info">
|
||
<div class="ws-row-name">${esc(w.name)}${activeBadge}</div>
|
||
<div class="ws-row-path">${esc(w.path)}</div>
|
||
</div>`;
|
||
row.onclick = () => openWorkspaceDetail(w.path, row);
|
||
if (_currentWorkspaceDetail && _currentWorkspaceDetail.path === w.path) row.classList.add('active');
|
||
panel.appendChild(row);
|
||
}
|
||
const hint=document.createElement('div');
|
||
hint.style.cssText='font-size:11px;color:var(--muted);padding:8px 0';
|
||
hint.textContent=t('workspace_paths_validated_hint');
|
||
panel.appendChild(hint);
|
||
// Re-render detail if we have one cached and we're not in a form
|
||
if (_currentWorkspaceDetail && _workspaceMode !== 'create' && _workspaceMode !== 'edit') {
|
||
const refreshed = workspaces.find(w => w.path === _currentWorkspaceDetail.path);
|
||
if (refreshed) _renderWorkspaceDetail(refreshed);
|
||
else _clearWorkspaceDetail();
|
||
}
|
||
}
|
||
|
||
function _renderWorkspaceDetail(ws){
|
||
_currentWorkspaceDetail = ws;
|
||
const title = $('workspaceDetailTitle');
|
||
const body = $('workspaceDetailBody');
|
||
const empty = $('workspaceDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = ws.name || ws.path;
|
||
const activePath = S.session ? S.session.workspace : '';
|
||
const isActive = ws.path === activePath;
|
||
const isDefault = !!ws.is_default;
|
||
const statusBadge = isActive
|
||
? `<span class="detail-badge active">${esc(t('profile_active'))}</span>`
|
||
: `<span class="detail-badge">Inactive</span>`;
|
||
const defaultBadge = isDefault ? ` <span class="detail-badge">${esc(t('profile_default_label'))}</span>` : '';
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<div class="detail-card">
|
||
<div class="detail-card-title">Space</div>
|
||
<div class="detail-row"><div class="detail-row-label">Name</div><div class="detail-row-value">${esc(ws.name || '')}</div></div>
|
||
<div class="detail-row"><div class="detail-row-label">Path</div><div class="detail-row-value"><code>${esc(ws.path)}</code></div></div>
|
||
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value">${statusBadge}${defaultBadge}</div></div>
|
||
</div>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_workspaceMode = 'read';
|
||
_setWorkspaceHeaderButtons('read', ws);
|
||
}
|
||
|
||
function _setWorkspaceHeaderButtons(mode, ws){
|
||
const actBtn = $('btnActivateWorkspaceDetail');
|
||
const editBtn = $('btnEditWorkspaceDetail');
|
||
const delBtn = $('btnDeleteWorkspaceDetail');
|
||
const cancelBtn = $('btnCancelWorkspaceDetail');
|
||
const saveBtn = $('btnSaveWorkspaceDetail');
|
||
const show = b => b && (b.style.display = '');
|
||
const hide = b => b && (b.style.display = 'none');
|
||
if (mode === 'read') {
|
||
const activePath = S.session ? S.session.workspace : '';
|
||
const isActive = ws && ws.path === activePath;
|
||
const isDefault = !!(ws && ws.is_default);
|
||
if (isActive) hide(actBtn); else show(actBtn);
|
||
show(editBtn);
|
||
if (isDefault) hide(delBtn); else show(delBtn);
|
||
hide(cancelBtn); hide(saveBtn);
|
||
} else if (mode === 'create' || mode === 'edit') {
|
||
hide(actBtn); hide(editBtn); hide(delBtn); show(cancelBtn); show(saveBtn);
|
||
} else {
|
||
[actBtn, editBtn, delBtn, cancelBtn, saveBtn].forEach(hide);
|
||
}
|
||
}
|
||
|
||
function openWorkspaceDetail(path, el){
|
||
if (!_workspaceList) return;
|
||
const ws = _workspaceList.find(w => w.path === path);
|
||
if (!ws) return;
|
||
document.querySelectorAll('.ws-row').forEach(e => e.classList.remove('active'));
|
||
const target = el || document.querySelector(`.ws-row[data-path="${CSS.escape(path)}"]`);
|
||
if (target) target.classList.add('active');
|
||
_workspacePreFormDetail = null;
|
||
_renderWorkspaceDetail(ws);
|
||
}
|
||
|
||
function _clearWorkspaceDetail(){
|
||
_currentWorkspaceDetail = null;
|
||
_workspaceMode = 'empty';
|
||
const title = $('workspaceDetailTitle');
|
||
const body = $('workspaceDetailBody');
|
||
const empty = $('workspaceDetailEmpty');
|
||
if (title) title.textContent = '';
|
||
if (body) { body.innerHTML = ''; body.style.display = 'none'; }
|
||
if (empty) empty.style.display = '';
|
||
_setWorkspaceHeaderButtons('empty');
|
||
}
|
||
|
||
async function activateCurrentWorkspace(){
|
||
if (!_currentWorkspaceDetail) return;
|
||
await switchToWorkspace(_currentWorkspaceDetail.path, _currentWorkspaceDetail.name);
|
||
// Re-render detail after activation so the active badge updates
|
||
_renderWorkspaceDetail(_currentWorkspaceDetail);
|
||
}
|
||
|
||
async function deleteCurrentWorkspace(){
|
||
if (!_currentWorkspaceDetail) return;
|
||
const path = _currentWorkspaceDetail.path;
|
||
const _ok = await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true});
|
||
if(!_ok) return;
|
||
try{
|
||
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
|
||
_workspaceList=data.workspaces;
|
||
_clearWorkspaceDetail();
|
||
renderWorkspacesPanel(data.workspaces);
|
||
showToast(t('workspace_removed'));
|
||
}catch(e){setStatus(t('remove_failed')+e.message);}
|
||
}
|
||
|
||
function openWorkspaceCreate(){
|
||
if (typeof switchPanel === 'function' && _currentPanel !== 'workspaces') switchPanel('workspaces');
|
||
_workspacePreFormDetail = _currentWorkspaceDetail ? { ..._currentWorkspaceDetail } : null;
|
||
_workspaceMode = 'create';
|
||
_renderWorkspaceForm({ name:'', path:'', isEdit:false });
|
||
}
|
||
|
||
function editCurrentWorkspace(){
|
||
if (!_currentWorkspaceDetail) return;
|
||
_workspacePreFormDetail = { ..._currentWorkspaceDetail };
|
||
_workspaceMode = 'edit';
|
||
_renderWorkspaceForm({ name: _currentWorkspaceDetail.name || '', path: _currentWorkspaceDetail.path || '', isEdit: true });
|
||
}
|
||
|
||
function _renderWorkspaceForm({ name, path, isEdit }){
|
||
const title = $('workspaceDetailTitle');
|
||
const body = $('workspaceDetailBody');
|
||
const empty = $('workspaceDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = isEdit ? (t('edit') + ' · ' + (name || path)) : (t('workspace_new_title') || 'New space');
|
||
const pathDisabled = isEdit ? 'disabled' : '';
|
||
const pathHint = isEdit
|
||
? `<div class="detail-form-hint">${esc(t('workspace_path_readonly') || 'Path cannot be changed. Rename only.')}</div>`
|
||
: `<div class="detail-form-hint">${esc(t('workspace_paths_validated_hint'))}</div>`;
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<form class="detail-form" onsubmit="event.preventDefault(); saveWorkspaceForm();">
|
||
<div class="detail-form-row">
|
||
<label for="workspaceFormName">${esc(t('workspace_name_label') || 'Name')}</label>
|
||
<input type="text" id="workspaceFormName" value="${esc(name || '')}" placeholder="${esc(t('workspace_name_placeholder') || 'Optional friendly name')}" autocomplete="off">
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="workspaceFormPath">${esc(t('workspace_path_label') || 'Path')}</label>
|
||
<div class="workspace-form-path-wrap" style="position:relative">
|
||
<input type="text" id="workspaceFormPath" value="${esc(path || '')}" placeholder="${esc(t('workspace_add_path_placeholder') || '/absolute/path/to/folder')}" autocomplete="off" ${pathDisabled} required>
|
||
<div id="workspaceFormPathSuggestions" class="ws-suggestions" style="display:none"></div>
|
||
</div>
|
||
${pathHint}
|
||
</div>
|
||
${!isEdit?`<div class="detail-form-row">
|
||
<label class="detail-form-check">
|
||
<input type="checkbox" id="workspaceFormAutoCreate">
|
||
${esc(t('workspace_auto_create_folder')||'Create folder if it doesn\'t exist')}
|
||
</label>
|
||
</div>`:''}
|
||
<div id="workspaceFormError" class="detail-form-error" style="display:none"></div>
|
||
</form>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_setWorkspaceHeaderButtons(isEdit ? 'edit' : 'create');
|
||
if (!isEdit) _wireWorkspaceFormPathSuggestions();
|
||
const focus = isEdit ? $('workspaceFormName') : $('workspaceFormPath');
|
||
if (focus) focus.focus();
|
||
}
|
||
|
||
function cancelWorkspaceForm(){
|
||
closeWorkspacePathSuggestions();
|
||
if (_workspacePreFormDetail) {
|
||
const snap = _workspacePreFormDetail;
|
||
_workspacePreFormDetail = null;
|
||
_renderWorkspaceDetail(snap);
|
||
return;
|
||
}
|
||
_clearWorkspaceDetail();
|
||
}
|
||
|
||
async function saveWorkspaceForm(){
|
||
const nameEl = $('workspaceFormName');
|
||
const pathEl = $('workspaceFormPath');
|
||
const errEl = $('workspaceFormError');
|
||
if (!pathEl || !errEl) return;
|
||
const name = (nameEl ? nameEl.value : '').trim();
|
||
const path = (pathEl.value || '').trim();
|
||
errEl.style.display = 'none';
|
||
if (!path) { errEl.textContent = t('workspace_path_required') || 'Path is required'; errEl.style.display = ''; return; }
|
||
try {
|
||
if (_workspaceMode === 'edit' && _currentWorkspaceDetail) {
|
||
const targetPath = _currentWorkspaceDetail.path;
|
||
const newName = name || _currentWorkspaceDetail.name || '';
|
||
await api('/api/workspaces/rename', { method:'POST', body: JSON.stringify({ path: targetPath, name: newName }) });
|
||
// Refresh list and re-render detail
|
||
const data = await api('/api/workspaces');
|
||
_workspaceList = data.workspaces || [];
|
||
_workspacePreFormDetail = null;
|
||
showToast(t('workspace_renamed') || t('workspace_added'));
|
||
renderWorkspacesPanel(_workspaceList);
|
||
openWorkspaceDetail(targetPath);
|
||
return;
|
||
}
|
||
const data = await api('/api/workspaces/add', { method:'POST', body: JSON.stringify({ path, name, create: ($('workspaceFormAutoCreate')&&$('workspaceFormAutoCreate').checked)||false }) });
|
||
_workspaceList = data.workspaces || [];
|
||
_workspacePreFormDetail = null;
|
||
// Apply rename if a friendly name was supplied
|
||
if (name) {
|
||
try { await api('/api/workspaces/rename', { method:'POST', body: JSON.stringify({ path, name }) }); } catch(_) {}
|
||
const refreshed = await api('/api/workspaces');
|
||
_workspaceList = refreshed.workspaces || _workspaceList;
|
||
}
|
||
renderWorkspacesPanel(_workspaceList);
|
||
showToast(t('workspace_added'));
|
||
const added = _workspaceList.find(w => w.path === path) || _workspaceList[_workspaceList.length - 1];
|
||
if (added) openWorkspaceDetail(added.path);
|
||
} catch (e) {
|
||
errEl.textContent = t('error_prefix') + e.message;
|
||
errEl.style.display = '';
|
||
}
|
||
}
|
||
|
||
// Back-compat: any legacy caller of addWorkspace() opens the new form instead.
|
||
function addWorkspace(){ openWorkspaceCreate(); }
|
||
|
||
function _wireWorkspaceFormPathSuggestions(){
|
||
const input=$('workspaceFormPath');
|
||
if(!input) return;
|
||
input.oninput=()=>scheduleWorkspacePathSuggestions();
|
||
input.onfocus=()=>{
|
||
if(input.value.trim()) scheduleWorkspacePathSuggestions();
|
||
else closeWorkspacePathSuggestions();
|
||
};
|
||
input.onkeydown=(e)=>{
|
||
const box=$('workspaceFormPathSuggestions');
|
||
const items=box?[...box.querySelectorAll('.ws-suggest-item')]:[];
|
||
if(!items.length){
|
||
return;
|
||
}
|
||
if(e.key==='ArrowDown'){
|
||
e.preventDefault();
|
||
_wsSuggestIndex=Math.min(items.length-1,Math.max(-1,_wsSuggestIndex)+1);
|
||
_highlightWorkspaceSuggestion(_wsSuggestIndex);
|
||
return;
|
||
}
|
||
if(e.key==='ArrowUp'){
|
||
e.preventDefault();
|
||
_wsSuggestIndex=_wsSuggestIndex<=0?0:_wsSuggestIndex-1;
|
||
_highlightWorkspaceSuggestion(_wsSuggestIndex);
|
||
return;
|
||
}
|
||
if(e.key==='Escape'){
|
||
e.preventDefault();
|
||
closeWorkspacePathSuggestions();
|
||
return;
|
||
}
|
||
if(e.key==='Enter' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){
|
||
e.preventDefault();
|
||
_applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||'');
|
||
return;
|
||
}
|
||
if(e.key==='Tab' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){
|
||
e.preventDefault();
|
||
_applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||'');
|
||
return;
|
||
}
|
||
};
|
||
}
|
||
|
||
document.addEventListener('click',e=>{
|
||
if(!e.target.closest('.workspace-form-path-wrap')) closeWorkspacePathSuggestions();
|
||
});
|
||
|
||
async function removeWorkspace(path){
|
||
const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true});
|
||
if(!_rmWs) return;
|
||
try{
|
||
const data=await api('/api/workspaces/remove',{method:'POST',body:JSON.stringify({path})});
|
||
_workspaceList=data.workspaces;
|
||
renderWorkspacesPanel(data.workspaces);
|
||
showToast(t('workspace_removed'));
|
||
}catch(e){setStatus(t('remove_failed')+e.message);}
|
||
}
|
||
|
||
async function promptWorkspacePath(){
|
||
// Opus review Q6: if called from blank page (no session), auto-create one first.
|
||
if(!S.session){
|
||
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||
if(!ws)return;
|
||
try{
|
||
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||
if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();}
|
||
}catch(e){showToast(t('workspace_switch_failed')+e.message);return;}
|
||
if(!S.session)return;
|
||
}
|
||
const value=await showPromptDialog({
|
||
title:t('workspace_switch_prompt_title'),
|
||
message:t('workspace_switch_prompt_message'),
|
||
confirmLabel:t('workspace_switch_prompt_confirm'),
|
||
placeholder:t('workspace_switch_prompt_placeholder'),
|
||
value:S.session.workspace||''
|
||
});
|
||
const path=(value||'').trim();
|
||
if(!path)return;
|
||
try{
|
||
const data=await api('/api/workspaces/add',{method:'POST',body:JSON.stringify({path})});
|
||
_workspaceList=data.workspaces||[];
|
||
const target=_workspaceList[_workspaceList.length-1];
|
||
if(!target) throw new Error(t('workspace_not_added'));
|
||
await switchToWorkspace(target.path,target.name);
|
||
}catch(e){
|
||
if(String(e.message||'').includes('Workspace already in list')){
|
||
showToast(t('workspace_already_saved'));
|
||
return;
|
||
}
|
||
showToast(t('workspace_switch_failed')+e.message);
|
||
}
|
||
}
|
||
|
||
async function switchToWorkspace(path,name){
|
||
// Opus review Q6: if called from blank page, auto-create a session bound to
|
||
// the requested workspace so the switch doesn't silently no-op.
|
||
if(!S.session){
|
||
const ws=path||(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
|
||
if(!ws){showToast(t('no_workspace'));return;}
|
||
try{
|
||
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
|
||
if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();}
|
||
}catch(e){if(typeof setStatus==='function')setStatus(t('switch_failed')+e.message);return;}
|
||
if(!S.session)return;
|
||
}
|
||
if(S.busy){
|
||
showToast(t('workspace_busy_switch'));
|
||
return;
|
||
}
|
||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||
const discard=await showConfirmDialog({
|
||
title:t('discard_file_edits_title'),
|
||
message:t('discard_file_edits_message'),
|
||
confirmLabel:t('discard'),
|
||
danger:true
|
||
});
|
||
if(!discard)return;
|
||
if(typeof cancelEditMode==='function')cancelEditMode();
|
||
if(typeof clearPreview==='function')clearPreview();
|
||
}
|
||
try{
|
||
closeWsDropdown();
|
||
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;
|
||
// Explicit workspace switch = user overriding any pending profile-switch default.
|
||
// Clear the one-shot flag so a subsequent newSession() inherits this choice instead.
|
||
S._profileSwitchWorkspace=null;
|
||
syncTopbar();
|
||
await loadDir('.');
|
||
showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path)));
|
||
}catch(e){setStatus(t('switch_failed')+e.message);}
|
||
}
|
||
|
||
// ── Profile panel + dropdown ──
|
||
let _profilesCache = null;
|
||
|
||
async function loadProfilesPanel() {
|
||
const panel = $('profilesPanel');
|
||
if (!panel) return;
|
||
try {
|
||
const data = await api('/api/profiles');
|
||
_profilesCache = data;
|
||
panel.innerHTML = '';
|
||
if (!data.profiles || !data.profiles.length) {
|
||
panel.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:12px">${esc(t('profiles_no_profiles'))}</div>`;
|
||
if (_profileMode !== 'create') _clearProfileDetail();
|
||
return;
|
||
}
|
||
const activeName = (S.activeProfile && data.profiles.some(p => p.name === S.activeProfile))
|
||
? S.activeProfile
|
||
: (data.active || 'default');
|
||
for (const p of data.profiles) {
|
||
const card = document.createElement('div');
|
||
card.className = 'profile-card';
|
||
card.dataset.name = p.name;
|
||
const meta = [];
|
||
if (p.model) meta.push(p.model.split('/').pop());
|
||
if (p.provider) meta.push(p.provider);
|
||
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
|
||
const gwDot = p.gateway_running
|
||
? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>`
|
||
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
|
||
const isActive = p.name === activeName;
|
||
const activeBadge = isActive ? `<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">${esc(t('profile_active'))}</span>` : '';
|
||
const defaultBadge = p.is_default ? ` <span style="opacity:.5">${esc(t('profile_default_label'))}</span>` : '';
|
||
card.innerHTML = `
|
||
<div class="profile-card-header">
|
||
<div style="min-width:0;flex:1">
|
||
<div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${defaultBadge}${activeBadge}</div>
|
||
${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : `<div class="profile-card-meta">${esc(t('profile_no_configuration'))}</div>`}
|
||
</div>
|
||
</div>`;
|
||
card.onclick = () => openProfileDetail(p.name, card);
|
||
if (_currentProfileDetail && _currentProfileDetail.name === p.name) card.classList.add('active');
|
||
panel.appendChild(card);
|
||
}
|
||
// Re-render detail with fresh data if we have one and we're not in a form
|
||
if (_currentProfileDetail && _profileMode !== 'create') {
|
||
const refreshed = data.profiles.find(p => p.name === _currentProfileDetail.name);
|
||
if (refreshed) _renderProfileDetail(refreshed, data.active);
|
||
else _clearProfileDetail();
|
||
}
|
||
} catch (e) {
|
||
panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">${esc(t('error_prefix'))}${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function _renderProfileDetail(p, activeName){
|
||
_currentProfileDetail = p;
|
||
const title = $('profileDetailTitle');
|
||
const body = $('profileDetailBody');
|
||
const empty = $('profileDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = p.name;
|
||
const isActive = p.name === activeName;
|
||
const isDefault = !!p.is_default;
|
||
const statusBadge = isActive
|
||
? `<span class="detail-badge active">${esc(t('profile_active'))}</span>`
|
||
: `<span class="detail-badge">Inactive</span>`;
|
||
const defaultBadge = isDefault ? ` <span class="detail-badge">${esc(t('profile_default_label'))}</span>` : '';
|
||
const gwBadge = p.gateway_running
|
||
? `<span class="detail-badge ok">${esc(t('profile_gateway_running'))}</span>`
|
||
: `<span class="detail-badge">${esc(t('profile_gateway_stopped'))}</span>`;
|
||
const rows = [];
|
||
rows.push(`<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value">${statusBadge}${defaultBadge}</div></div>`);
|
||
rows.push(`<div class="detail-row"><div class="detail-row-label">Gateway</div><div class="detail-row-value">${gwBadge}</div></div>`);
|
||
if (p.model) rows.push(`<div class="detail-row"><div class="detail-row-label">Model</div><div class="detail-row-value"><code>${esc(p.model)}</code></div></div>`);
|
||
if (p.provider) rows.push(`<div class="detail-row"><div class="detail-row-label">Provider</div><div class="detail-row-value">${esc(p.provider)}</div></div>`);
|
||
if (p.base_url) rows.push(`<div class="detail-row"><div class="detail-row-label">Base URL</div><div class="detail-row-value"><code>${esc(p.base_url)}</code></div></div>`);
|
||
rows.push(`<div class="detail-row"><div class="detail-row-label">API key</div><div class="detail-row-value">${p.has_env ? esc(t('profile_api_keys_configured')) : '<span style="color:var(--muted)">Not configured</span>'}</div></div>`);
|
||
if (typeof p.skill_count === 'number') rows.push(`<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(t('profile_skill_count', p.skill_count))}</div></div>`);
|
||
if (p.default_workspace) rows.push(`<div class="detail-row"><div class="detail-row-label">Default space</div><div class="detail-row-value"><code>${esc(p.default_workspace)}</code></div></div>`);
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<div class="detail-card">
|
||
<div class="detail-card-title">Profile</div>
|
||
${rows.join('')}
|
||
</div>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_profileMode = 'read';
|
||
_setProfileHeaderButtons('read', p, activeName);
|
||
}
|
||
|
||
function _setProfileHeaderButtons(mode, p, activeName){
|
||
const actBtn = $('btnActivateProfileDetail');
|
||
const delBtn = $('btnDeleteProfileDetail');
|
||
const cancelBtn = $('btnCancelProfileDetail');
|
||
const saveBtn = $('btnSaveProfileDetail');
|
||
const show = b => b && (b.style.display = '');
|
||
const hide = b => b && (b.style.display = 'none');
|
||
if (mode === 'read') {
|
||
const isActive = p && p.name === activeName;
|
||
const isDefault = !!(p && p.is_default);
|
||
if (isActive) hide(actBtn); else show(actBtn);
|
||
if (isDefault) hide(delBtn); else show(delBtn);
|
||
hide(cancelBtn); hide(saveBtn);
|
||
} else if (mode === 'create') {
|
||
hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn);
|
||
} else {
|
||
[actBtn, delBtn, cancelBtn, saveBtn].forEach(hide);
|
||
}
|
||
}
|
||
|
||
function openProfileDetail(name, el){
|
||
if (!_profilesCache || !_profilesCache.profiles) return;
|
||
const p = _profilesCache.profiles.find(x => x.name === name);
|
||
if (!p) return;
|
||
document.querySelectorAll('.profile-card').forEach(e => e.classList.remove('active'));
|
||
const target = el || document.querySelector(`.profile-card[data-name="${CSS.escape(name)}"]`);
|
||
if (target) target.classList.add('active');
|
||
_profilePreFormDetail = null;
|
||
_renderProfileDetail(p, _profilesCache.active);
|
||
}
|
||
|
||
function _clearProfileDetail(){
|
||
_currentProfileDetail = null;
|
||
_profileMode = 'empty';
|
||
const title = $('profileDetailTitle');
|
||
const body = $('profileDetailBody');
|
||
const empty = $('profileDetailEmpty');
|
||
if (title) title.textContent = '';
|
||
if (body) { body.innerHTML = ''; body.style.display = 'none'; }
|
||
if (empty) empty.style.display = '';
|
||
_setProfileHeaderButtons('empty');
|
||
}
|
||
|
||
async function activateCurrentProfile(){
|
||
if (!_currentProfileDetail) return;
|
||
await switchToProfile(_currentProfileDetail.name);
|
||
}
|
||
|
||
async function deleteCurrentProfile(){
|
||
if (!_currentProfileDetail) return;
|
||
const name = _currentProfileDetail.name;
|
||
const _ok = await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
|
||
if(!_ok) return;
|
||
try {
|
||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||
_clearProfileDetail();
|
||
await loadProfilesPanel();
|
||
showToast(t('profile_deleted', name));
|
||
} catch (e) { showToast(t('delete_failed') + e.message); }
|
||
}
|
||
|
||
function renderProfileDropdown(data) {
|
||
const dd = $('profileDropdown');
|
||
if (!dd) return;
|
||
dd.innerHTML = '';
|
||
const profiles = data.profiles || [];
|
||
const active = (S.activeProfile && profiles.some(p => p.name === S.activeProfile))
|
||
? S.activeProfile
|
||
: (data.active || 'default');
|
||
for (const p of profiles) {
|
||
const opt = document.createElement('div');
|
||
opt.className = 'profile-opt' + (p.name === active ? ' active' : '');
|
||
const meta = [];
|
||
if (p.model) meta.push(p.model.split('/').pop());
|
||
if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count));
|
||
const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
|
||
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
|
||
const defaultBadge = p.is_default ? ` <span style="opacity:.5;font-weight:400">${esc(t('profile_default_label'))}</span>` : '';
|
||
opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${defaultBadge}${checkmark}</div>` +
|
||
(meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');
|
||
opt.onclick = async () => {
|
||
closeProfileDropdown();
|
||
if (p.name === active) return;
|
||
await switchToProfile(p.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 = 'profile-opt ws-manage';
|
||
mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`;
|
||
mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); };
|
||
dd.appendChild(mgmt);
|
||
}
|
||
|
||
function toggleProfileDropdown() {
|
||
const dd = $('profileDropdown');
|
||
if (!dd) return;
|
||
if (dd.classList.contains('open')) { closeProfileDropdown(); return; }
|
||
closeWsDropdown(); // close workspace dropdown if open
|
||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||
api('/api/profiles').then(data => {
|
||
renderProfileDropdown(data);
|
||
dd.classList.add('open');
|
||
_positionProfileDropdown();
|
||
const chip=$('profileChip');
|
||
if(chip) chip.classList.add('active');
|
||
}).catch(e => { showToast(t('profiles_load_failed')); });
|
||
}
|
||
|
||
function closeProfileDropdown() {
|
||
const dd = $('profileDropdown');
|
||
if (dd) dd.classList.remove('open');
|
||
const chip=$('profileChip');
|
||
if(chip) chip.classList.remove('active');
|
||
}
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('#profileChipWrap') && !e.target.closest('#profileDropdown')) closeProfileDropdown();
|
||
});
|
||
window.addEventListener('resize',()=>{
|
||
const dd=$('profileDropdown');
|
||
if(dd&&dd.classList.contains('open')) _positionProfileDropdown();
|
||
});
|
||
|
||
async function switchToProfile(name) {
|
||
if (S.busy) { showToast(t('profiles_busy_switch')); return; }
|
||
|
||
// Determine whether the current session has any messages.
|
||
// A session with messages is "in progress" and belongs to the current profile —
|
||
// we must not retag it. We'll start a fresh session for the new profile instead.
|
||
const sessionInProgress = S.session && S.messages && S.messages.length > 0;
|
||
|
||
try {
|
||
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
||
S.activeProfile = data.active || name;
|
||
|
||
// ── Model ──────────────────────────────────────────────────────────────
|
||
localStorage.removeItem('hermes-webui-model');
|
||
_skillsData = null;
|
||
await populateModelDropdown();
|
||
if (data.default_model) {
|
||
const sel = $('modelSelect');
|
||
const resolved = _applyModelToDropdown(data.default_model, sel);
|
||
const modelToUse = resolved || data.default_model;
|
||
S._pendingProfileModel = modelToUse;
|
||
// Only patch the in-memory session model if we're NOT about to replace the session
|
||
if (S.session && !sessionInProgress) {
|
||
S.session.model = modelToUse;
|
||
}
|
||
}
|
||
|
||
// ── Workspace ──────────────────────────────────────────────────────────
|
||
_workspaceList = null;
|
||
await loadWorkspaceList();
|
||
if (data.default_workspace) {
|
||
// Always store the persistent profile default — used for blank-page display
|
||
// and workspace auto-bind throughout the session lifecycle (#804, #823).
|
||
S._profileDefaultWorkspace = data.default_workspace;
|
||
// Also set the one-shot flag consumed by newSession() so the first new
|
||
// session after a profile switch inherits this workspace (#424).
|
||
S._profileSwitchWorkspace = data.default_workspace;
|
||
|
||
if (S.session && !sessionInProgress) {
|
||
// Empty session (no messages yet) — safe to update it in place
|
||
try {
|
||
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
|
||
session_id: S.session.session_id,
|
||
workspace: data.default_workspace,
|
||
model: S.session.model,
|
||
})});
|
||
S.session.workspace = data.default_workspace;
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
// ── Session ────────────────────────────────────────────────────────────
|
||
_showAllProfiles = false;
|
||
|
||
if (sessionInProgress) {
|
||
// The current session has messages and belongs to the previous profile.
|
||
// Start a new session for the new profile so nothing gets cross-tagged.
|
||
await newSession(false);
|
||
// Apply profile default workspace to the newly created session (fixes #424)
|
||
if (S._profileDefaultWorkspace && S.session) {
|
||
try {
|
||
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
|
||
session_id: S.session.session_id,
|
||
workspace: S._profileDefaultWorkspace,
|
||
model: S.session.model,
|
||
})});
|
||
S.session.workspace = S._profileDefaultWorkspace;
|
||
} catch (_) {}
|
||
}
|
||
// Keep topbar chips (workspace/profile) in sync after creating the
|
||
// new profile-scoped session.
|
||
syncTopbar();
|
||
await renderSessionList();
|
||
showToast(t('profile_switched_new_conversation', name));
|
||
} else {
|
||
// No messages yet — just refresh the list and topbar in place
|
||
await renderSessionList();
|
||
syncTopbar();
|
||
showToast(t('profile_switched', name));
|
||
}
|
||
|
||
// ── Sidebar panels ─────────────────────────────────────────────────────
|
||
if (_currentPanel === 'skills') await loadSkills();
|
||
if (_currentPanel === 'memory') await loadMemory();
|
||
if (_currentPanel === 'tasks') await loadCrons();
|
||
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
||
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
|
||
|
||
} catch (e) { showToast(t('switch_failed') + e.message); }
|
||
}
|
||
|
||
function openProfileCreate(){
|
||
if (typeof switchPanel === 'function' && _currentPanel !== 'profiles') switchPanel('profiles');
|
||
_profilePreFormDetail = _currentProfileDetail ? { ..._currentProfileDetail } : null;
|
||
_profileMode = 'create';
|
||
_renderProfileForm();
|
||
}
|
||
|
||
function _renderProfileForm(){
|
||
const title = $('profileDetailTitle');
|
||
const body = $('profileDetailBody');
|
||
const empty = $('profileDetailEmpty');
|
||
if (!title || !body) return;
|
||
title.textContent = t('new_profile');
|
||
body.innerHTML = `
|
||
<div class="main-view-content">
|
||
<form class="detail-form" onsubmit="event.preventDefault(); saveProfileForm();">
|
||
<div class="detail-form-row">
|
||
<label for="profileFormName">${esc(t('profile_name_label') || 'Name')}</label>
|
||
<input type="text" id="profileFormName" placeholder="${esc(t('profile_name_placeholder') || 'lowercase, a-z 0-9 hyphens')}" autocomplete="off" required>
|
||
<div class="detail-form-hint">${esc(t('profile_name_rule') || 'Lowercase letters, numbers, hyphens, underscores only.')}</div>
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label class="detail-form-check" for="profileFormClone">
|
||
<input type="checkbox" id="profileFormClone"> <span>${esc(t('profile_clone_label') || 'Clone config from active profile')}</span>
|
||
</label>
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="profileFormBaseUrl">${esc(t('profile_base_url_label') || 'Base URL')}</label>
|
||
<input type="text" id="profileFormBaseUrl" placeholder="${esc(t('profile_base_url_placeholder') || 'Optional, e.g. http://localhost:11434')}" autocomplete="off">
|
||
</div>
|
||
<div class="detail-form-row">
|
||
<label for="profileFormApiKey">${esc(t('profile_api_key_label') || 'API key')}</label>
|
||
<input type="password" id="profileFormApiKey" placeholder="${esc(t('profile_api_key_placeholder') || 'Optional')}" autocomplete="off">
|
||
</div>
|
||
<div id="profileFormError" class="detail-form-error" style="display:none"></div>
|
||
</form>
|
||
</div>`;
|
||
body.style.display = '';
|
||
if (empty) empty.style.display = 'none';
|
||
_setProfileHeaderButtons('create');
|
||
const n = $('profileFormName');
|
||
if (n) n.focus();
|
||
}
|
||
|
||
function cancelProfileForm(){
|
||
if (_profilePreFormDetail) {
|
||
const snap = _profilePreFormDetail;
|
||
_profilePreFormDetail = null;
|
||
const activeName = _profilesCache ? _profilesCache.active : null;
|
||
_renderProfileDetail(snap, activeName);
|
||
return;
|
||
}
|
||
_clearProfileDetail();
|
||
}
|
||
|
||
async function saveProfileForm(){
|
||
const nameEl = $('profileFormName');
|
||
const cloneEl = $('profileFormClone');
|
||
const baseEl = $('profileFormBaseUrl');
|
||
const apiKeyEl = $('profileFormApiKey');
|
||
const errEl = $('profileFormError');
|
||
if (!nameEl || !errEl) return;
|
||
const name = (nameEl.value || '').trim().toLowerCase();
|
||
const cloneConfig = !!(cloneEl && cloneEl.checked);
|
||
errEl.style.display = 'none';
|
||
if (!name) { errEl.textContent = t('name_required'); errEl.style.display = ''; return; }
|
||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name)) { errEl.textContent = t('profile_name_rule'); errEl.style.display = ''; return; }
|
||
const baseUrl = (baseEl ? (baseEl.value || '') : '').trim();
|
||
const apiKey = (apiKeyEl ? (apiKeyEl.value || '') : '').trim();
|
||
if (baseUrl && !/^https?:\/\//.test(baseUrl)) { errEl.textContent = t('profile_base_url_rule'); errEl.style.display = ''; return; }
|
||
try {
|
||
const payload = { name, clone_config: cloneConfig };
|
||
if (baseUrl) payload.base_url = baseUrl;
|
||
if (apiKey) payload.api_key = apiKey;
|
||
await api('/api/profile/create', { method: 'POST', body: JSON.stringify(payload) });
|
||
_profilePreFormDetail = null;
|
||
await loadProfilesPanel();
|
||
showToast(t('profile_created', name));
|
||
openProfileDetail(name);
|
||
} catch (e) {
|
||
errEl.textContent = e.message || t('create_failed');
|
||
errEl.style.display = '';
|
||
}
|
||
}
|
||
|
||
// Back-compat
|
||
const submitProfileCreate = saveProfileForm;
|
||
function toggleProfileForm(){ openProfileCreate();
|
||
}
|
||
|
||
async function deleteProfile(name) {
|
||
const _delProf=await showConfirmDialog({title:t('profile_delete_confirm_title',name),message:t('profile_delete_confirm_message'),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
|
||
if(!_delProf) return;
|
||
try {
|
||
await api('/api/profile/delete', { method: 'POST', body: JSON.stringify({ name }) });
|
||
await loadProfilesPanel();
|
||
showToast(t('profile_deleted', name));
|
||
} catch (e) { showToast(t('delete_failed') + e.message); }
|
||
}
|
||
|
||
// ── Memory panel ──
|
||
async function loadMemory(force) {
|
||
const panel = $('memoryPanel');
|
||
try {
|
||
const data = await api('/api/memory');
|
||
_memoryData = data;
|
||
if (panel) {
|
||
panel.innerHTML = '';
|
||
for (const s of MEMORY_SECTIONS) {
|
||
const el = document.createElement('button');
|
||
el.type = 'button';
|
||
el.className = 'side-menu-item';
|
||
if (_currentMemorySection === s.key) el.classList.add('active');
|
||
el.innerHTML = `${li(s.iconKey,16)}<span>${esc(t(s.labelKey))}</span>`;
|
||
el.onclick = () => openMemorySection(s.key, el);
|
||
panel.appendChild(el);
|
||
}
|
||
}
|
||
if (_currentMemorySection && _memoryMode !== 'edit') {
|
||
_renderMemoryDetail(_currentMemorySection);
|
||
}
|
||
} catch(e) {
|
||
if (panel) panel.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">${esc(t('error_prefix'))}${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();}});
|
||
|
||
// ── Settings panel ───────────────────────────────────────────────────────────
|
||
|
||
let _settingsDirty = false;
|
||
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
|
||
let _settingsSkinOnOpen = null; // track skin at open time for discard revert
|
||
let _settingsFontSizeOnOpen = null; // track font size at open time for discard revert
|
||
let _settingsHermesDefaultModelOnOpen = '';
|
||
let _settingsSection = 'conversation';
|
||
let _currentSettingsSection = 'conversation';
|
||
|
||
function switchSettingsSection(name){
|
||
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='system')?name:'conversation';
|
||
_settingsSection=section;
|
||
_currentSettingsSection=section;
|
||
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',providers:'Providers',system:'System'};
|
||
// Sidebar menu items
|
||
document.querySelectorAll('#settingsMenu .side-menu-item').forEach(it=>{
|
||
it.classList.toggle('active', it.dataset.settingsSection===section);
|
||
});
|
||
// Panes in main
|
||
['conversation','appearance','preferences','providers','system'].forEach(key=>{
|
||
const pane=$('settingsPane'+map[key]);
|
||
if(pane) pane.classList.toggle('active', key===section);
|
||
});
|
||
// Sync mobile dropdown
|
||
const dd=$('settingsSectionDropdown');
|
||
if(dd && dd.value!==section) dd.value=section;
|
||
// Lazy-load providers when the tab is opened
|
||
if(section==='providers') loadProvidersPanel();
|
||
}
|
||
|
||
function _syncHermesPanelSessionActions(){
|
||
const hasSession=!!S.session;
|
||
const visibleMessages=hasSession?(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length:0;
|
||
const title=hasSession?(S.session.title||t('untitled')):t('active_conversation_none');
|
||
const meta=$('hermesSessionMeta');
|
||
if(meta){
|
||
meta.textContent=hasSession
|
||
? t('active_conversation_meta', title, visibleMessages)
|
||
: t('active_conversation_none');
|
||
}
|
||
const setDisabled=(id,disabled)=>{
|
||
const el=$(id);
|
||
if(!el)return;
|
||
el.disabled=!!disabled;
|
||
el.classList.toggle('disabled',!!disabled);
|
||
};
|
||
setDisabled('btnDownload',!hasSession||visibleMessages===0);
|
||
setDisabled('btnExportJSON',!hasSession);
|
||
setDisabled('btnClearConvModal',!hasSession||visibleMessages===0);
|
||
}
|
||
|
||
// Thin wrapper: settings now live in the main content area. External callers
|
||
// (keyboard shortcuts, commands) keep working through this name.
|
||
function toggleSettings(){
|
||
if(_currentPanel==='settings'){
|
||
_closeSettingsPanel();
|
||
} else {
|
||
switchPanel('settings');
|
||
}
|
||
}
|
||
|
||
function _resetSettingsPanelState(){
|
||
const bar=$('settingsUnsavedBar');
|
||
if(bar) bar.style.display='none';
|
||
}
|
||
|
||
function _hideSettingsPanel(){
|
||
_resetSettingsPanelState();
|
||
const target = _consumeSettingsTargetPanel('chat');
|
||
if(_currentPanel==='settings') switchPanel(target, {bypassSettingsGuard:true});
|
||
}
|
||
|
||
// Close with unsaved-changes check. If dirty, show a confirm dialog.
|
||
function _closeSettingsPanel(){
|
||
if(!_settingsDirty){
|
||
_revertSettingsPreview();
|
||
_hideSettingsPanel();
|
||
return;
|
||
}
|
||
_pendingSettingsTargetPanel = _pendingSettingsTargetPanel || 'chat';
|
||
_showSettingsUnsavedBar();
|
||
}
|
||
|
||
// Revert live DOM/localStorage to what they were when the panel opened
|
||
function _revertSettingsPreview(){
|
||
if(_settingsThemeOnOpen){
|
||
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
|
||
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
|
||
}
|
||
if(_settingsSkinOnOpen){
|
||
localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
|
||
if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen);
|
||
}
|
||
if(_settingsFontSizeOnOpen){
|
||
localStorage.setItem('hermes-font-size', _settingsFontSizeOnOpen);
|
||
if(typeof _applyFontSize==='function') _applyFontSize(_settingsFontSizeOnOpen);
|
||
}
|
||
}
|
||
|
||
// Show the "Unsaved changes" bar inside the settings panel
|
||
function _showSettingsUnsavedBar(){
|
||
let bar = $('settingsUnsavedBar');
|
||
if(bar){ bar.style.display=''; return; }
|
||
// Create it
|
||
bar = document.createElement('div');
|
||
bar.id = 'settingsUnsavedBar';
|
||
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
|
||
bar.innerHTML = `<span style="color:var(--text)">${esc(t('settings_unsaved_changes'))}</span>`
|
||
+ '<span style="display:flex;gap:8px">'
|
||
+ `<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">${esc(t('discard'))}</button>`
|
||
+ `<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">${esc(t('save'))}</button>`
|
||
+ '</span>';
|
||
const body = document.querySelector('#mainSettings .settings-main') || document.querySelector('.settings-main');
|
||
if(body) body.prepend(bar);
|
||
}
|
||
|
||
function _discardSettings(){
|
||
_revertSettingsPreview();
|
||
_settingsDirty = false;
|
||
_hideSettingsPanel();
|
||
}
|
||
|
||
// Mark settings as dirty whenever anything changes
|
||
function _markSettingsDirty(){
|
||
_settingsDirty = true;
|
||
}
|
||
|
||
async function loadSettingsPanel(){
|
||
try{
|
||
const settings=await api('/api/settings');
|
||
// Populate the version badge from the server — keeps it in sync with git
|
||
// tags automatically without any manual release step.
|
||
const vbadge=document.querySelector('.settings-version-badge');
|
||
if(vbadge && settings.webui_version) vbadge.textContent=settings.webui_version;
|
||
// Hydrate appearance controls first so a slow /api/models request
|
||
// cannot overwrite an in-progress theme/skin selection.
|
||
const themeSel=$('settingsTheme');
|
||
const themeVal=settings.theme||'dark';
|
||
if(themeSel) themeSel.value=themeVal;
|
||
if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal);
|
||
const skinVal=(settings.skin||'default').toLowerCase();
|
||
const skinSel=$('settingsSkin');
|
||
if(skinSel) skinSel.value=skinVal;
|
||
if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal);
|
||
const fontSizeVal=localStorage.getItem('hermes-font-size')||'default';
|
||
const fontSizeSel=$('settingsFontSize');
|
||
if(fontSizeSel) fontSizeSel.value=fontSizeVal;
|
||
if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal);
|
||
// Workspace panel default-open toggle (localStorage-backed)
|
||
// Uses a separate key (hermes-webui-workspace-panel-pref) so that
|
||
// closing the panel via toolbar X does not clear the user's preference.
|
||
const wsPanelCb=$('settingsWorkspacePanelOpen');
|
||
if(wsPanelCb){
|
||
wsPanelCb.checked=localStorage.getItem('hermes-webui-workspace-panel-pref')==='open';
|
||
wsPanelCb.onchange=function(){
|
||
const open=this.checked;
|
||
localStorage.setItem('hermes-webui-workspace-panel-pref',open?'open':'closed');
|
||
// Also sync the runtime key so the current session reflects the change
|
||
localStorage.setItem('hermes-webui-workspace-panel',open?'open':'closed');
|
||
document.documentElement.dataset.workspacePanel=open?'open':'closed';
|
||
if(open&&_workspacePanelMode==='closed') openWorkspacePanel('browse');
|
||
else if(!open&&_workspacePanelMode!=='closed') toggleWorkspacePanel(false);
|
||
};
|
||
}
|
||
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||
// Keep settings modal and current page strings in sync with the resolved locale.
|
||
if(typeof setLocale==='function'){
|
||
setLocale(resolvedLanguage);
|
||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||
}
|
||
// Populate model dropdown from /api/models + live model fetch (#872)
|
||
const modelSel=$('settingsModel');
|
||
if(modelSel){
|
||
modelSel.innerHTML='';
|
||
let models=null;
|
||
try{
|
||
models=await api('/api/models');
|
||
for(const g of ((models||{}).groups||[])){
|
||
const og=document.createElement('optgroup');
|
||
og.label=g.provider;
|
||
if(g.provider_id) og.dataset.provider=g.provider_id;
|
||
for(const m of g.models){
|
||
const opt=document.createElement('option');
|
||
opt.value=m.id;opt.textContent=m.label;
|
||
og.appendChild(opt);
|
||
}
|
||
modelSel.appendChild(og);
|
||
}
|
||
// Append live-fetched models for the active provider, same as the
|
||
// chat-header dropdown does via _fetchLiveModels() (#872).
|
||
if(models.active_provider && typeof _fetchLiveModels==='function'){
|
||
_fetchLiveModels(models.active_provider, modelSel);
|
||
}
|
||
}catch(e){}
|
||
_settingsHermesDefaultModelOnOpen=(models&&models.default_model)||'';
|
||
// Use the smart matcher so a saved bare form like "anthropic/claude-opus-4.6"
|
||
// (what the CLI's `hermes model` command writes) still selects the matching
|
||
// `@nous:anthropic/claude-opus-4.6` option on a Nous setup. Without this, the
|
||
// picker renders blank for any user whose default was persisted without the
|
||
// @-prefix — CLI-first users, legacy installs, etc.
|
||
if(typeof _applyModelToDropdown==='function'){
|
||
_applyModelToDropdown(_settingsHermesDefaultModelOnOpen, modelSel);
|
||
}else{
|
||
modelSel.value=_settingsHermesDefaultModelOnOpen;
|
||
}
|
||
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||
}
|
||
// Send key preference
|
||
const sendKeySel=$('settingsSendKey');
|
||
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
// Language preference — populate from LOCALES bundle
|
||
const langSel=$('settingsLanguage');
|
||
if(langSel){
|
||
langSel.innerHTML='';
|
||
if(typeof LOCALES!=='undefined'){
|
||
for(const [code,bundle] of Object.entries(LOCALES)){
|
||
const opt=document.createElement('option');
|
||
opt.value=code;opt.textContent=bundle._label||code;
|
||
langSel.appendChild(opt);
|
||
}
|
||
}
|
||
langSel.value=resolvedLanguage;
|
||
langSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||
}
|
||
const showUsageCb=$('settingsShowTokenUsage');
|
||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
const showCliCb=$('settingsShowCliSessions');
|
||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
const syncCb=$('settingsSyncInsights');
|
||
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
const updateCb=$('settingsCheckUpdates');
|
||
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
const soundCb=$('settingsSoundEnabled');
|
||
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
const notifCb=$('settingsNotificationsEnabled');
|
||
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||
// show_thinking has no settings panel checkbox — controlled via /reasoning show|hide
|
||
const sidebarDensitySel=$('settingsSidebarDensity');
|
||
if(sidebarDensitySel){
|
||
sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact';
|
||
sidebarDensitySel.addEventListener('change',_markSettingsDirty,{once:false});
|
||
}
|
||
// Bot name
|
||
const botNameField=$('settingsBotName');
|
||
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||
// Password field: always blank (we don't send hash back)
|
||
const pwField=$('settingsPassword');
|
||
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||
// Show auth buttons only when auth is active
|
||
try{
|
||
const authStatus=await api('/api/auth/status');
|
||
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
|
||
}catch(e){}
|
||
_syncHermesPanelSessionActions();
|
||
loadProvidersPanel(); // load provider cards in background
|
||
switchSettingsSection(_settingsSection);
|
||
}catch(e){
|
||
showToast(t('settings_load_failed')+e.message);
|
||
}
|
||
}
|
||
|
||
// ── Providers panel ───────────────────────────────────────────────────────
|
||
|
||
const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn}
|
||
|
||
async function loadProvidersPanel(){
|
||
const list=$('providersList');
|
||
const empty=$('providersEmpty');
|
||
if(!list) return;
|
||
try{
|
||
const data=await api('/api/providers');
|
||
const providers=(data.providers||[]).filter(p=>p.configurable);
|
||
list.innerHTML='';
|
||
_providerCardEls.clear();
|
||
if(providers.length===0){
|
||
list.style.display='none';
|
||
if(empty) empty.style.display='';
|
||
return;
|
||
}
|
||
if(empty) empty.style.display='none';
|
||
list.style.display='';
|
||
for(const p of providers){
|
||
list.appendChild(_buildProviderCard(p));
|
||
}
|
||
}catch(e){
|
||
list.innerHTML='<div style="color:var(--error);padding:12px;font-size:13px">Failed to load providers: '+e.message+'</div>';
|
||
}
|
||
}
|
||
|
||
function _buildProviderCard(p){
|
||
const card=document.createElement('div');
|
||
card.className='provider-card';
|
||
card.dataset.provider=p.id;
|
||
const isOauth=p.key_source==='oauth';
|
||
const modelCount=Array.isArray(p.models)?p.models.length:0;
|
||
const sourceLabel=isOauth
|
||
? t('providers_status_oauth')
|
||
: (p.has_key ? t('providers_status_api_key') : t('providers_status_not_configured_label'));
|
||
const metaParts=[];
|
||
if(modelCount>0) metaParts.push(modelCount+(modelCount===1?' model':' models'));
|
||
metaParts.push(sourceLabel);
|
||
const metaText=metaParts.join(' · ');
|
||
|
||
// Clickable header (toggles body)
|
||
const header=document.createElement('button');
|
||
header.type='button';
|
||
header.className='provider-card-header';
|
||
header.innerHTML=`
|
||
<div class="provider-card-info">
|
||
<div class="provider-card-name">${esc(p.display_name)}</div>
|
||
<div class="provider-card-meta">${esc(metaText)}</div>
|
||
</div>
|
||
${p.has_key?`<span class="provider-card-badge">${esc(t('providers_status_configured'))}</span>`:''}
|
||
<svg class="provider-card-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16"><path d="M6 9l6 6 6-6"/></svg>
|
||
`;
|
||
card.appendChild(header);
|
||
|
||
const body=document.createElement('div');
|
||
body.className='provider-card-body';
|
||
|
||
if(isOauth){
|
||
const hint=document.createElement('div');
|
||
hint.className='provider-card-hint';
|
||
hint.textContent=t('providers_oauth_hint');
|
||
body.appendChild(hint);
|
||
card.appendChild(body);
|
||
header.addEventListener('click',()=>card.classList.toggle('open'));
|
||
return card;
|
||
}
|
||
|
||
const field=document.createElement('div');
|
||
field.className='provider-card-field';
|
||
const label=document.createElement('label');
|
||
label.className='provider-card-label';
|
||
label.textContent=t('providers_status_api_key');
|
||
field.appendChild(label);
|
||
|
||
const row=document.createElement('div');
|
||
row.className='provider-card-row';
|
||
const input=document.createElement('input');
|
||
input.type='password';
|
||
input.className='provider-card-input';
|
||
input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new');
|
||
input.autocomplete='off';
|
||
const toggleBtn=document.createElement('button');
|
||
toggleBtn.type='button';
|
||
toggleBtn.className='provider-card-btn provider-card-btn-ghost';
|
||
toggleBtn.textContent='Show';
|
||
toggleBtn.onclick=()=>{
|
||
const revealed=input.type==='text';
|
||
input.type=revealed?'password':'text';
|
||
toggleBtn.textContent=revealed?'Show':'Hide';
|
||
};
|
||
const saveBtn=document.createElement('button');
|
||
saveBtn.type='button';
|
||
saveBtn.className='provider-card-btn provider-card-btn-primary';
|
||
saveBtn.textContent=t('providers_save');
|
||
saveBtn.onclick=()=>_saveProviderKey(p.id);
|
||
saveBtn.disabled=true;
|
||
row.appendChild(input);
|
||
row.appendChild(toggleBtn);
|
||
row.appendChild(saveBtn);
|
||
if(p.has_key){
|
||
const removeBtn=document.createElement('button');
|
||
removeBtn.type='button';
|
||
removeBtn.className='provider-card-btn provider-card-btn-danger';
|
||
removeBtn.textContent=t('providers_remove');
|
||
removeBtn.onclick=()=>_removeProviderKey(p.id);
|
||
row.appendChild(removeBtn);
|
||
}
|
||
field.appendChild(row);
|
||
body.appendChild(field);
|
||
card.appendChild(body);
|
||
|
||
_providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key});
|
||
input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();});
|
||
header.addEventListener('click',e=>{
|
||
// Don't toggle when clicking inside body (defensive; body isn't inside header)
|
||
if(e.target.closest('.provider-card-body')) return;
|
||
card.classList.toggle('open');
|
||
if(card.classList.contains('open')) setTimeout(()=>input.focus(),0);
|
||
});
|
||
return card;
|
||
}
|
||
|
||
async function _saveProviderKey(providerId){
|
||
const els=_providerCardEls.get(providerId);
|
||
if(!els) return;
|
||
const key=els.input.value.trim();
|
||
if(!key){
|
||
showToast(t('providers_enter_key'));
|
||
return;
|
||
}
|
||
els.saveBtn.disabled=true;
|
||
els.saveBtn.textContent=t('providers_saving');
|
||
try{
|
||
const res=await api('/api/providers',{method:'POST',body:JSON.stringify({provider:providerId,api_key:key})});
|
||
if(res.ok){
|
||
showToast(res.provider+' key '+res.action);
|
||
els.input.value='';
|
||
await loadProvidersPanel(); // refresh list
|
||
}else{
|
||
showToast(res.error||'Failed to save key');
|
||
els.saveBtn.disabled=false;
|
||
els.saveBtn.textContent=t('providers_save');
|
||
}
|
||
}catch(e){
|
||
showToast('Error: '+e.message);
|
||
els.saveBtn.disabled=false;
|
||
els.saveBtn.textContent=t('providers_save');
|
||
}
|
||
}
|
||
|
||
async function _removeProviderKey(providerId){
|
||
const els=_providerCardEls.get(providerId);
|
||
if(!els) return;
|
||
if(els.saveBtn){els.saveBtn.disabled=true;els.saveBtn.textContent=t('providers_removing');}
|
||
try{
|
||
const res=await api('/api/providers/delete',{method:'POST',body:JSON.stringify({provider:providerId})});
|
||
if(res.ok){
|
||
showToast(res.provider+' key '+t('providers_key_removed').toLowerCase());
|
||
await loadProvidersPanel(); // refresh list
|
||
}else{
|
||
showToast(res.error||'Failed to remove key');
|
||
if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');}
|
||
}
|
||
}catch(e){
|
||
showToast('Error: '+e.message);
|
||
if(els.saveBtn){els.saveBtn.disabled=false;els.saveBtn.textContent=t('providers_save');}
|
||
}
|
||
}
|
||
|
||
function _setSettingsAuthButtonsVisible(active){
|
||
const signOutBtn=$('btnSignOut');
|
||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||
const disableBtn=$('btnDisableAuth');
|
||
if(disableBtn) disableBtn.style.display=active?'':'none';
|
||
}
|
||
|
||
function _applySavedSettingsUi(saved, body, opts){
|
||
const {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
|
||
window._sendKey=sendKey||'enter';
|
||
window._showTokenUsage=showTokenUsage;
|
||
window._showCliSessions=showCliSessions;
|
||
window._soundEnabled=body.sound_enabled;
|
||
window._notificationsEnabled=body.notifications_enabled;
|
||
window._showThinking=body.show_thinking!==false;
|
||
window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';
|
||
window._botName=body.bot_name||'Hermes';
|
||
if(typeof applyBotName==='function') applyBotName();
|
||
if(typeof setLocale==='function') setLocale(language);
|
||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||
if(typeof startGatewaySSE==='function'){
|
||
if(showCliSessions) startGatewaySSE();
|
||
else if(typeof stopGatewaySSE==='function') stopGatewaySSE();
|
||
}
|
||
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
|
||
_settingsDirty=false;
|
||
_settingsThemeOnOpen=theme;
|
||
_settingsSkinOnOpen=skin||'default';
|
||
_settingsFontSizeOnOpen=fontSize||localStorage.getItem('hermes-font-size')||'default';
|
||
const bar=$('settingsUnsavedBar');
|
||
if(bar) bar.style.display='none';
|
||
_settingsHermesDefaultModelOnOpen=body.default_model||_settingsHermesDefaultModelOnOpen||'';
|
||
// Sync window._defaultModel so newSession() uses the just-saved default without a reload (#908).
|
||
if(body.default_model) window._defaultModel=body.default_model;
|
||
renderMessages();
|
||
if(typeof syncTopbar==='function') syncTopbar();
|
||
if(typeof renderSessionList==='function') renderSessionList();
|
||
}
|
||
|
||
async function checkUpdatesNow(){
|
||
const btn=$('btnCheckUpdatesNow');
|
||
const label=$('checkUpdatesLabel');
|
||
const spinner=$('checkUpdatesSpinner');
|
||
const status=$('checkUpdatesStatus');
|
||
if(!btn||!label) return;
|
||
// Disable button, show spinner
|
||
btn.disabled=true;
|
||
if(spinner) spinner.style.display='';
|
||
if(label) label.textContent=t('settings_checking');
|
||
if(status) status.textContent='';
|
||
try {
|
||
const data=await api('/api/updates/check?force=1');
|
||
if(data.disabled){
|
||
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
|
||
} else {
|
||
const parts=[];
|
||
if(data.webui&&data.webui.behind>0) parts.push('WebUI: '+data.webui.behind);
|
||
if(data.agent&&data.agent.behind>0) parts.push('Agent: '+data.agent.behind);
|
||
if(parts.length){
|
||
if(status){status.textContent=t('settings_updates_available').replace('{count}',parts.join(', '));status.style.color='var(--accent)';}
|
||
// Also trigger the update banner
|
||
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
|
||
} else {
|
||
if(status){status.textContent=t('settings_up_to_date');status.style.color='var(--success)';}
|
||
}
|
||
}
|
||
} catch(e){
|
||
// Never expose raw e.message in UI — log to console for debugging only
|
||
console.warn('[checkUpdatesNow]', e);
|
||
// Show a generic user-facing error; if the API returned a message body use it
|
||
let userMsg=t('settings_update_check_failed');
|
||
if(e&&e.response){
|
||
try{
|
||
const body=JSON.parse(e.response);
|
||
if(body.error) userMsg=String(body.error).substring(0,120);
|
||
}catch(_){}
|
||
}
|
||
if(status){status.textContent=userMsg;status.style.color='var(--error)';}
|
||
} finally {
|
||
btn.disabled=false;
|
||
if(spinner) spinner.style.display='none';
|
||
if(label) label.textContent=t('settings_check_now');
|
||
}
|
||
}
|
||
|
||
async function saveSettings(andClose){
|
||
const model=($('settingsModel')||{}).value;
|
||
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
|
||
const sendKey=($('settingsSendKey')||{}).value;
|
||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||
const pw=($('settingsPassword')||{}).value;
|
||
const theme=($('settingsTheme')||{}).value||'dark';
|
||
const skin=($('settingsSkin')||{}).value||'default';
|
||
const fontSize=($('settingsFontSize')||{}).value||localStorage.getItem('hermes-font-size')||'default';
|
||
const language=($('settingsLanguage')||{}).value||'en';
|
||
const sidebarDensity=($('settingsSidebarDensity')||{}).value==='detailed'?'detailed':'compact';
|
||
const body={};
|
||
|
||
if(sendKey) body.send_key=sendKey;
|
||
body.theme=theme;
|
||
body.skin=skin;
|
||
body.language=language;
|
||
body.show_token_usage=showTokenUsage;
|
||
body.show_cli_sessions=showCliSessions;
|
||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
||
body.show_thinking=window._showThinking!==false;
|
||
body.sidebar_density=sidebarDensity;
|
||
const botName=(($('settingsBotName')||{}).value||'').trim();
|
||
body.bot_name=botName||'Hermes';
|
||
// Password: only act if the field has content; blank = leave auth unchanged
|
||
if(pw && pw.trim()){
|
||
try{
|
||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||
if(modelChanged && model){
|
||
try{
|
||
await api('/api/default-model',{method:'POST',body:JSON.stringify({model})});
|
||
body.default_model=model;
|
||
}catch(_modelErr){
|
||
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
|
||
}
|
||
}
|
||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
|
||
_settingsDirty=false;
|
||
_resetSettingsPanelState();
|
||
if(!andClose) _pendingSettingsTargetPanel = null;
|
||
if(andClose) _hideSettingsPanel();
|
||
return;
|
||
}catch(e){showToast(t('settings_save_failed')+e.message);return;}
|
||
}
|
||
try{
|
||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||
if(modelChanged && model){
|
||
try{
|
||
await api('/api/default-model',{method:'POST',body:JSON.stringify({model})});
|
||
body.default_model=model;
|
||
}catch(_modelErr){
|
||
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
|
||
}
|
||
}
|
||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||
showToast(t('settings_saved'));
|
||
_settingsDirty=false;
|
||
_resetSettingsPanelState();
|
||
if(!andClose) _pendingSettingsTargetPanel = null;
|
||
if(andClose) _hideSettingsPanel();
|
||
}catch(e){
|
||
showToast(t('settings_save_failed')+e.message);
|
||
}
|
||
}
|
||
|
||
async function signOut(){
|
||
try{
|
||
await api('/api/auth/logout',{method:'POST',body:'{}'});
|
||
window.location.href='login';
|
||
}catch(e){
|
||
showToast(t('sign_out_failed')+e.message);
|
||
}
|
||
}
|
||
|
||
async function disableAuth(){
|
||
const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true});
|
||
if(!_disAuth) return;
|
||
try{
|
||
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
||
showToast(t('auth_disabled'));
|
||
// Hide both auth buttons since auth is now off
|
||
const disableBtn=$('btnDisableAuth');
|
||
if(disableBtn) disableBtn.style.display='none';
|
||
const signOutBtn=$('btnSignOut');
|
||
if(signOutBtn) signOutBtn.style.display='none';
|
||
}catch(e){
|
||
showToast(t('disable_auth_failed')+e.message);
|
||
}
|
||
}
|
||
|
||
|
||
// ── Cron completion alerts ────────────────────────────────────────────────────
|
||
|
||
let _cronPollSince=Date.now()/1000; // track from page load
|
||
let _cronPollTimer=null;
|
||
let _cronUnreadCount=0;
|
||
|
||
// Auto-refresh the cron list when a job is created from chat or any external source.
|
||
// The chat path dispatches this event when the agent response mentions cron creation.
|
||
window.addEventListener('hermes:cron_created', () => {
|
||
if ($('cronList')) loadCrons();
|
||
});
|
||
|
||
function startCronPolling(){
|
||
if(_cronPollTimer) return;
|
||
_cronPollTimer=setInterval(async()=>{
|
||
if(document.hidden) return; // don't poll when tab is in background
|
||
try{
|
||
const data=await api(`/api/crons/recent?since=${_cronPollSince}`);
|
||
if(data.completions&&data.completions.length>0){
|
||
for(const c of data.completions){
|
||
showToast(t('cron_completion_status', c.name, c.status==='error' ? t('status_failed') : t('status_completed')),4000);
|
||
_cronPollSince=Math.max(_cronPollSince,c.completed_at);
|
||
}
|
||
_cronUnreadCount+=data.completions.length;
|
||
updateCronBadge();
|
||
}
|
||
}catch(e){}
|
||
},30000);
|
||
}
|
||
|
||
function updateCronBadge(){
|
||
const tab=document.querySelector('.nav-tab[data-panel="tasks"]');
|
||
if(!tab) return;
|
||
let badge=tab.querySelector('.cron-badge');
|
||
if(_cronUnreadCount>0){
|
||
if(!badge){
|
||
badge=document.createElement('span');
|
||
badge.className='cron-badge';
|
||
tab.style.position='relative';
|
||
tab.appendChild(badge);
|
||
}
|
||
badge.textContent=_cronUnreadCount>9?'9+':_cronUnreadCount;
|
||
badge.style.display='';
|
||
}else if(badge){
|
||
badge.style.display='none';
|
||
}
|
||
}
|
||
|
||
// Clear cron badge when Tasks tab is opened
|
||
const _origSwitchPanel=switchPanel;
|
||
switchPanel=async function(name){
|
||
if(name==='tasks'){_cronUnreadCount=0;updateCronBadge();}
|
||
return _origSwitchPanel(name);
|
||
};
|
||
|
||
// Start polling on page load
|
||
startCronPolling();
|
||
|
||
// ── Background agent error tracking ──────────────────────────────────────────
|
||
|
||
const _backgroundErrors=[]; // {session_id, title, message, ts}
|
||
|
||
function trackBackgroundError(sessionId, title, message){
|
||
// Only track if user is NOT currently viewing this session
|
||
if(S.session&&S.session.session_id===sessionId) return;
|
||
_backgroundErrors.push({session_id:sessionId, title:title||t('untitled'), message, ts:Date.now()});
|
||
showErrorBanner();
|
||
}
|
||
|
||
function showErrorBanner(){
|
||
let banner=$('bgErrorBanner');
|
||
if(!banner){
|
||
banner=document.createElement('div');
|
||
banner.id='bgErrorBanner';
|
||
banner.className='bg-error-banner';
|
||
const msgs=document.querySelector('.messages');
|
||
if(msgs) msgs.parentNode.insertBefore(banner,msgs);
|
||
else document.body.appendChild(banner);
|
||
}
|
||
const latest=_backgroundErrors[0]; // FIFO: show oldest (first) error
|
||
if(!latest){banner.style.display='none';return;}
|
||
const count=_backgroundErrors.length;
|
||
const msg=count>1?t('bg_error_multi',count):t('bg_error_single',latest.title);
|
||
banner.innerHTML=`<span>\u26a0 ${esc(msg)}</span><div style="display:flex;gap:6px;flex-shrink:0"><button class="reconnect-btn" onclick="navigateToErrorSession()">${esc(t('view'))}</button><button class="reconnect-btn" onclick="dismissErrorBanner()">${esc(t('dismiss'))}</button></div>`;
|
||
banner.style.display='';
|
||
}
|
||
|
||
function navigateToErrorSession(){
|
||
const latest=_backgroundErrors.shift(); // FIFO: show oldest error first
|
||
if(latest){
|
||
loadSession(latest.session_id);renderSessionList();
|
||
}
|
||
if(_backgroundErrors.length===0) dismissErrorBanner();
|
||
else showErrorBanner();
|
||
}
|
||
|
||
function dismissErrorBanner(){
|
||
_backgroundErrors.length=0;
|
||
const banner=$('bgErrorBanner');
|
||
if(banner) banner.style.display='none';
|
||
}
|
||
|
||
// Event wiring
|