Merge remote-tracking branch 'origin/dev' into test-main-dev-merge-20260615

# Conflicts:
#	src/tool_implementations.py
#	static/js/research/panel.js
This commit is contained in:
pewdiepie-archdaemon
2026-06-15 21:20:15 +09:00
312 changed files with 20047 additions and 2952 deletions
+235 -2
View File
@@ -55,6 +55,7 @@ async function loadUsers() {
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="admin-btn-sm" data-adm-toggle-admin="${esc(u.username)}" data-make-admin="${u.is_admin ? '0' : '1'}" style="font-size:11px;">${u.is_admin ? 'Revoke admin' : 'Make admin'}</button>
<button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button>
${u.is_admin ? '' : `<button class="admin-btn-delete" data-adm-del-user="${esc(u.username)}" style="font-size:11px;">Remove</button>`}
${u.is_admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>'}
@@ -113,7 +114,7 @@ async function loadUsers() {
// Toggle panel visibility + rotate chevron + load models
let _modelsLoaded = false;
header.addEventListener('click', (e) => {
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user]')) return;
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin]')) return;
privPanel.classList.toggle('hidden');
const chevron = header.querySelector('.admin-user-chevron');
if (chevron) {
@@ -199,6 +200,42 @@ async function loadUsers() {
});
}
// Promote / demote (admin toggle) — present on every row
const adminToggleBtn = row.querySelector('[data-adm-toggle-admin]');
if (adminToggleBtn) {
adminToggleBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const username = adminToggleBtn.dataset.admToggleAdmin;
const makeAdmin = adminToggleBtn.dataset.makeAdmin === '1';
const confirmMsg = makeAdmin
? `Grant admin rights to "${username}"? They'll get full access to all settings and users — including the power to demote or remove other admins (you included).`
: `Revoke admin rights from "${username}"? They'll lose access to the admin panel.`;
if (!await uiModule.styledConfirm(confirmMsg, { confirmText: makeAdmin ? 'Make admin' : 'Revoke admin', danger: !makeAdmin })) return;
adminToggleBtn.disabled = true;
try {
const res = await fetch(`/api/auth/users/${encodeURIComponent(username)}/admin`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: makeAdmin }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
uiModule.showError(data.detail || 'Failed to change admin status');
adminToggleBtn.disabled = false;
return;
}
// Demoting yourself drops your own admin access — reload into the
// normal-user view (mirrors the rename-self reload above).
if (data.self) { window.location.reload(); return; }
loadUsers();
} catch (err) {
uiModule.showError('Failed to change admin status');
adminToggleBtn.disabled = false;
}
});
}
list.appendChild(row);
});
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
@@ -2743,12 +2780,206 @@ function initDangerZone() {
});
}
/* ═══════════════════════════════════════════
TERMINAL LOGS VIEWER
═══════════════════════════════════════════ */
let logsPollInterval = null;
let isLogsPolling = false;
let cachedLogs = [];
let logsAbortController = null;
function renderLogs(isAutoPoll = false) {
const consoleContainer = el('log-console-container');
const levelSelect = el('log-level-select');
const searchInput = el('log-search-input');
if (!consoleContainer) return;
const levelFilter = levelSelect ? levelSelect.value : 'ALL';
const searchQuery = searchInput ? searchInput.value.trim().toLowerCase() : '';
let logs = cachedLogs;
// Filter by level locally
if (levelFilter !== 'ALL') {
logs = logs.filter(line => line.includes(` - ${levelFilter} - `));
}
// Filter by search query locally
if (searchQuery) {
logs = logs.filter(line => line.toLowerCase().includes(searchQuery));
}
if (logs.length === 0) {
consoleContainer.innerHTML = '<div class="settings-system-logs-placeholder">No logs found matching current filters.</div>';
return;
}
// Preserve scroll position if user is reading previous logs
const atBottom = consoleContainer.scrollHeight - consoleContainer.scrollTop - consoleContainer.clientHeight < 40;
consoleContainer.innerHTML = logs.map(line => {
let levelClass = 'log-line-default';
if (line.includes(' - INFO - ')) {
levelClass = 'log-line-info';
} else if (line.includes(' - WARNING - ')) {
levelClass = 'log-line-warning';
} else if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
levelClass = 'log-line-error';
} else if (line.includes(' - DEBUG - ')) {
levelClass = 'log-line-debug';
}
// XSS safe escape
const escaped = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return `<div class="log-line ${levelClass}">${escaped}</div>`;
}).join('');
if (!isAutoPoll || atBottom) {
consoleContainer.scrollTop = consoleContainer.scrollHeight;
}
}
async function loadLogs(isAutoPoll = false) {
const consoleContainer = el('log-console-container');
const limitSelect = el('log-limit-select');
if (!consoleContainer) return;
const limit = limitSelect ? limitSelect.value : 200;
if (logsAbortController) {
logsAbortController.abort();
}
logsAbortController = new AbortController();
const { signal } = logsAbortController;
try {
const res = await fetch(`/api/diagnostics/logs?limit=${limit}`, {
credentials: 'same-origin',
signal
});
if (!res.ok) {
if (!isAutoPoll) {
consoleContainer.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.style.color = 'var(--red)';
errDiv.style.fontWeight = '600';
errDiv.textContent = `Failed to load logs: HTTP ${res.status}`;
consoleContainer.appendChild(errDiv);
}
return;
}
const data = await res.json();
if (data.status !== 'success' || !data.logs) {
if (!isAutoPoll) {
consoleContainer.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.style.color = 'var(--red)';
errDiv.style.fontWeight = '600';
errDiv.textContent = 'Failed to parse logs data';
consoleContainer.appendChild(errDiv);
}
return;
}
cachedLogs = data.logs;
renderLogs(isAutoPoll);
} catch (err) {
if (err.name === 'AbortError') {
return; // Silently ignore deliberate abort
}
if (!isAutoPoll) {
consoleContainer.innerHTML = '';
const errDiv = document.createElement('div');
errDiv.style.color = 'var(--red)';
errDiv.style.fontWeight = '600';
errDiv.textContent = `Error retrieving logs: ${err.message}`;
consoleContainer.appendChild(errDiv);
}
} finally {
if (logsAbortController?.signal === signal) {
logsAbortController = null;
}
}
}
function startLogsPolling() {
if (isLogsPolling) return;
isLogsPolling = true;
const toggle = el('log-auto-refresh-toggle');
if (toggle) toggle.checked = true;
logsPollInterval = setInterval(() => {
const modal = el('settings-modal');
const systemPanel = el('settings-modal')?.querySelector('[data-settings-panel="system"]');
// Safe self-cleanup if modal or panel is hidden/closed
if (!modal || modal.classList.contains('hidden') || !systemPanel || systemPanel.classList.contains('hidden')) {
stopLogsPolling();
return;
}
loadLogs(true);
}, 3000);
}
function stopLogsPolling() {
if (!isLogsPolling) return;
isLogsPolling = false;
if (logsPollInterval) {
clearInterval(logsPollInterval);
logsPollInterval = null;
}
const toggle = el('log-auto-refresh-toggle');
if (toggle) toggle.checked = false;
}
function initLogsView() {
const refreshBtn = el('log-refresh-btn');
const levelSelect = el('log-level-select');
const limitSelect = el('log-limit-select');
const searchInput = el('log-search-input');
const autoRefreshToggle = el('log-auto-refresh-toggle');
if (refreshBtn) refreshBtn.addEventListener('click', () => loadLogs(false));
if (levelSelect) levelSelect.addEventListener('change', () => renderLogs(false));
if (limitSelect) limitSelect.addEventListener('change', () => loadLogs(false));
if (searchInput) searchInput.addEventListener('input', () => renderLogs(false));
if (autoRefreshToggle) {
autoRefreshToggle.addEventListener('change', (e) => {
if (e.target.checked) {
startLogsPolling();
} else {
stopLogsPolling();
}
});
}
// Initial fetch on view loading
loadLogs(false);
}
/* ═══════════════════════════════════════════
INIT & REFRESH
═══════════════════════════════════════════ */
function initAll() {
modalEl = el('settings-modal');
const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, () => settingsModule.initIntegrations()];
const inits = [
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
() => settingsModule.initIntegrations()
];
for (const fn of inits) {
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
}
@@ -2762,6 +2993,7 @@ function refreshAll() {
loadBuiltinTools();
loadMcpServers();
loadTokens();
loadLogs(false);
}
/* ═══════════════════════════════════════════
@@ -2778,6 +3010,7 @@ export function open(tab) {
}
export function close() {
stopLogsPolling();
settingsModule.close();
}
+153 -44
View File
@@ -9,7 +9,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js';
import {
WEEKDAYS, MONTHS, MON_SHORT,
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
_trashIcon, _moreIcon, _bellIcon,
_isCalBgImage, _calBgImageUrl, _calBgCss,
@@ -64,6 +64,8 @@ let _hiddenTypes = new Set(); // event_type values to hide
let _onlyImportant = false;
let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1';
// Week-start preference: 'mon' (default, Mon=first col) or 'sun' (Sun=first col).
let _weekStartSun = localStorage.getItem('cal-week-start') === 'sun';
let _selectedDay = null;
let _view = 'month';
let _searchQuery = '';
@@ -360,14 +362,14 @@ function _today() { return _ds(new Date()); }
function _monthRange(d) {
const y = d.getFullYear(), m = d.getMonth();
const first = new Date(y, m, 1);
const dow = (first.getDay() + 6) % 7;
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const gs = new Date(y, m, 1 - dow);
const ge = new Date(gs); ge.setDate(gs.getDate() + 42);
return [_ds(gs), _ds(ge)];
}
function _weekRange(d) {
const dow = (d.getDay() + 6) % 7;
const dow = _weekStartSun ? d.getDay() : (d.getDay() + 6) % 7;
const s = new Date(d); s.setDate(d.getDate() - dow);
const e = new Date(s); e.setDate(s.getDate() + 7);
return [_ds(s), _ds(e)];
@@ -950,11 +952,11 @@ async function _renderMonth() {
_slideDir = 0;
let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`;
h += '<div class="cal-week-headers">';
for (const wd of WEEKDAYS) h += `<div class="cal-weekday">${wd}</div>`;
for (const wd of (_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)) h += `<div class="cal-weekday">${wd}</div>`;
h += '</div>';
const first = new Date(y, m, 1);
const dow = (first.getDay() + 6) % 7;
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const gs = new Date(y, m, 1 - dow);
const multiDay = _events.filter(e => {
@@ -1163,13 +1165,13 @@ function _wkEventTopHeight(ev, dayStr) {
// Date math if the string isn't shaped as expected.
const _toMin = (iso, fallbackDate) => {
if (!iso) return null;
const m = iso.match(/T(\d{2}):(\d{2})/);
if (m) {
const mins = _timeToMin(iso);
if (mins !== null && iso.includes('T')) {
// If the event spans into a previous/next day, clamp to today's bounds.
const evDate = iso.slice(0, 10);
const evDate = _localDateOf(iso);
if (evDate < fallbackDate) return 0; // event started before today
if (evDate > fallbackDate) return 24 * 60; // event ends after today
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
return mins;
}
// All-day or date-only — treat as start of day.
return 0;
@@ -1226,8 +1228,8 @@ async function _renderWeek() {
const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day);
const isSun = d.getDay() === 0;
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${WEEKDAYS[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun && !_weekStartSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${(_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
// All-day strip
colsHtml += `<div class="cal-wk-allday">`;
for (const ev of allDayEvents) {
@@ -1308,12 +1310,17 @@ async function _renderWeek() {
if (!ev) return;
const cols = Array.from(body.querySelectorAll('.cal-wk-grid'));
if (!cols.length) return;
// Original timing
const m1 = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
const m2 = (ev.dtend || '').match(/T(\d{2}):(\d{2})/);
const startMin0 = m1 ? parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10) : 0;
const endMin0 = m2 ? parseInt(m2[1], 10) * 60 + parseInt(m2[2], 10) : startMin0 + 60;
const durationMin = Math.max(15, endMin0 - startMin0);
// Local/display timing
const startMin0 = _timeToMin(ev.dtstart) ?? 0;
const endMin0 = _timeToMin(ev.dtend) ?? startMin0 + 60;
let durationMin = endMin0 - startMin0;
const startDs = _localDateOf(ev.dtstart);
const endDs = ev.dtend ? _localDateOf(ev.dtend) : startDs;
if (endDs > startDs && endMin0 <= startMin0) {
durationMin += 24 * 60;
}
durationMin = Math.max(15, durationMin);
// Where did the cursor grab the block? (offset from block-top in px)
const blockRect = block.getBoundingClientRect();
@@ -1387,7 +1394,7 @@ async function _renderWeek() {
// a plain click (no movement) must still open the event.
if (moved) block.dataset.justResized = '1';
// Decide whether anything actually moved.
const oldDs = (ev.dtstart || '').slice(0, 10);
const oldDs = _localDateOf(ev.dtstart);
if (!nextDs) return;
if (nextDs === oldDs && nextStartMin === startMin0) return;
// Snapshot the original times so we can offer an Undo.
@@ -1396,11 +1403,10 @@ async function _renderWeek() {
const newEndMin = nextStartMin + durationMin;
const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0');
const mm = String(nextStartMin % 60).padStart(2, '0');
const hh2 = String(Math.floor(newEndMin / 60)).padStart(2, '0');
const mm2 = String((newEndMin) % 60).padStart(2, '0');
const _tz = _tzOffset();
const newDtstartDate = new Date(`${nextDs}T${hh}:${mm}:00`);
const _tz = _tzOffsetForDate(newDtstartDate);
const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`;
const newDtend = `${nextDs}T${hh2}:${mm2}:00${_tz}`;
const newDtend = _addMinutesToLocalIso(newDtstart, durationMin);
try {
await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend });
_render();
@@ -1432,10 +1438,7 @@ async function _renderWeek() {
const uid = block.dataset.uid;
const ev = _events.find(x => x.uid === uid);
if (!ev || !grid || !ds) return;
const startMin = (() => {
const m = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0;
})();
const startMin = _timeToMin(ev.dtstart) ?? 0;
const initialTop = parseFloat(block.style.top || '0');
const gridRect = grid.getBoundingClientRect();
let newEndMin = startMin;
@@ -1460,9 +1463,8 @@ async function _renderWeek() {
if (resized) block.dataset.justResized = '1';
if (newEndMin === startMin) return;
const prevDtend = ev.dtend;
const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0');
const mm = String(newEndMin % 60).padStart(2, '0');
const newDtend = `${ds}T${hh}:${mm}:00${_tzOffset()}`;
const durationMin = newEndMin - startMin;
const newDtend = _addMinutesToLocalIso(ev.dtstart, durationMin);
try {
await _updateEvent(uid, { dtend: newDtend });
_render();
@@ -1746,9 +1748,9 @@ async function _renderYear() {
for (let m = 0; m < 12; m++) {
h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`;
h += '<div class="cal-year-grid">';
for (const wd of ['M', 'T', 'W', 'T', 'F', 'S', 'S']) h += `<div class="cal-year-wd">${wd}</div>`;
for (const wd of (_weekStartSun ? ['S','M','T','W','T','F','S'] : ['M','T','W','T','F','S','S'])) h += `<div class="cal-year-wd">${wd}</div>`;
const first = new Date(y, m, 1);
const dow = (first.getDay() + 6) % 7;
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
const daysInMonth = new Date(y, m + 1, 0).getDate();
for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>';
for (let d = 1; d <= daysInMonth; d++) {
@@ -1989,10 +1991,10 @@ function _wireAll(body) {
const ad = document.getElementById('cal-f-allday');
if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); }
} else {
const t1 = (ev.dtstart || '').match(/T(\d{2}:\d{2})/);
const t2 = (ev.dtend || '').match(/T(\d{2}:\d{2})/);
if (t1) set('cal-f-start', t1[1]);
if (t2) set('cal-f-end', t2[1]);
const t1 = _fmtTime(ev.dtstart);
const t2 = _fmtTime(ev.dtend);
if (t1) set('cal-f-start', t1);
if (t2) set('cal-f-end', t2);
document.getElementById('cal-f-start')?.dispatchEvent(new Event('input'));
}
// Make sure the details panel is open so the user can verify time.
@@ -2497,6 +2499,13 @@ async function _showCalSettings() {
</div>
<div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div>
</div>
<div style="border-top:1px solid var(--border);padding-top:12px;">
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Week starts on</div>
<div style="display:flex;gap:6px;">
<button id="cal-wstart-mon" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${!_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Monday</button>
<button id="cal-wstart-sun" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Sunday</button>
</div>
</div>
<div style="border-top:1px solid var(--border);padding-top:12px;">
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
@@ -2517,6 +2526,28 @@ async function _showCalSettings() {
overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup);
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
// Week-start toggle: save to localStorage, update module state, re-render.
const _monBtn = overlay.querySelector('#cal-wstart-mon');
const _sunBtn = overlay.querySelector('#cal-wstart-sun');
const _activeStyle = 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))';
const _inactiveStyle = 'var(--panel)';
const _applyWeekStartActive = () => {
if (_monBtn) _monBtn.style.background = _weekStartSun ? _inactiveStyle : _activeStyle;
if (_sunBtn) _sunBtn.style.background = _weekStartSun ? _activeStyle : _inactiveStyle;
};
_monBtn?.addEventListener('click', () => {
_weekStartSun = false;
localStorage.setItem('cal-week-start', 'mon');
_applyWeekStartActive();
if (_open) _render();
});
_sunBtn?.addEventListener('click', () => {
_weekStartSun = true;
localStorage.setItem('cal-week-start', 'sun');
_applyWeekStartActive();
if (_open) _render();
});
// Create a new (local) calendar. Defaults the name + next palette color, then
// reopens the panel so the user can rename it inline and pick a color.
overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => {
@@ -2941,35 +2972,68 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
const startEl = document.getElementById('cal-f-start');
const endEl = document.getElementById('cal-f-end');
if (!startEl || !endEl) return;
const _toMin = (v) => {
if (!v || !/^\d{2}:\d{2}$/.test(v)) return null;
const [h, m] = v.split(':').map(n => parseInt(n, 10));
return h * 60 + m;
};
const _toHHMM = (mins) => {
let m = ((mins % 1440) + 1440) % 1440;
const hh = String(Math.floor(m / 60)).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0');
return `${hh}:${mm}`;
};
const _autoAdvanceEndDate = () => {
const isAD = document.getElementById('cal-f-allday')?.checked;
if (isAD) return;
const dv = document.getElementById('cal-f-date')?.value;
const dvEndEl = document.getElementById('cal-f-date-end');
if (!dv || !dvEndEl || dvEndEl.value !== dv) return;
const sVal = startEl.value;
const eVal = endEl.value;
if (sVal && eVal && eVal <= sVal) {
const d = new Date(`${dv}T00:00:00`);
d.setDate(d.getDate() + 1);
dvEndEl.value = _ds(d);
}
};
let prevStartMin = _toMin(startEl.value);
endEl.addEventListener('input', () => { endEl.dataset.userEdited = '1'; });
endEl.addEventListener('input', () => {
endEl.dataset.userEdited = '1';
});
endEl.addEventListener('change', _autoAdvanceEndDate);
startEl.addEventListener('change', () => {
const newStartMin = _toMin(startEl.value);
const endMin = _toMin(endEl.value);
if (newStartMin == null) { prevStartMin = newStartMin; return; }
// Compute the duration before the change. Use the user's existing
// start→end gap, fallback to 1 hour.
let durationMin = 60;
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
durationMin = endMin - prevStartMin;
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
// User already set a custom end before changing start — leave it.
if (newStartMin == null) {
prevStartMin = newStartMin;
return;
}
let durationMin = 60;
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
durationMin = endMin - prevStartMin;
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
prevStartMin = newStartMin;
return;
}
endEl.value = _toHHMM(newStartMin + durationMin);
prevStartMin = newStartMin;
_autoAdvanceEndDate();
});
})();
// Custom reminder picker
@@ -3030,6 +3094,20 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
// proper UTC instants (is_utc=True). Without this, naive "10:00" gets
// re-interpreted as local elsewhere — the timezone-misfire bug.
const _tz = _tzOffset();
if (!isAD) {
const startVal = document.getElementById('cal-f-start').value;
const endVal = document.getElementById('cal-f-end').value;
const startDt = new Date(`${dv}T${startVal}:00`);
const endDt = new Date(`${dvEnd}T${endVal}:00`);
if (endDt <= startDt) {
uiModule.showToast('End time must be after start time');
return;
}
}
const payload = {
summary,
dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`,
@@ -3261,6 +3339,37 @@ function _fmtTime(s) {
}
return s.slice(11, 16);
}
function _timeToMin(iso) {
const hm = _fmtTime(iso);
if (!hm) return null;
const m = hm.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const h = parseInt(m[1], 10);
const min = parseInt(m[2], 10);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return h * 60 + min;
}
function _tzOffsetForDate(d) {
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const abs = Math.abs(off);
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
const mm = String(abs % 60).padStart(2, '0');
return `${sign}${hh}:${mm}`;
}
function _addMinutesToLocalIso(baseIso, addMinutes) {
const d = new Date(new Date(baseIso).getTime() + addMinutes * 60000);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const da = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${y}-${mo}-${da}T${h}:${m}:00${_tzOffsetForDate(d)}`;
}
function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// Linkify a location string: URLs become clickable, plain addresses get a Maps link.
+3 -1
View File
@@ -3,7 +3,9 @@
// Pure constants + zero-state helpers for the calendar UI.
// No DOM, no fetch, no global mutable state — safe to import anywhere.
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export const WEEKDAYS_SUN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
+71 -27
View File
@@ -740,9 +740,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const dismissBtn = document.createElement('button');
dismissBtn.textContent = '\u00d7';
dismissBtn.className = 'import-prompt-dismiss';
dismissBtn.setAttribute('aria-label', 'Dismiss');
dismissBtn.title = 'Dismiss';
dismissBtn.addEventListener('click', () => banner.remove());
banner.appendChild(dismissBtn);
const chatBar = document.getElementById('chat-bar');
const chatBar = document.querySelector('.chat-input-bar');
if (chatBar) chatBar.parentNode.insertBefore(banner, chatBar);
// Auto-dismiss after 15 seconds
setTimeout(() => { if (banner.parentNode) banner.remove(); }, 15000);
@@ -813,15 +815,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else {
fd.append('use_web', 'true');
}
} else if (isAgentMode) {
fd.append('allow_web_search', 'false');
}
if (el('research-toggle').checked) {
fd.append('use_research', 'true');
// Research always runs in chat mode — override agent if set
fd.set('mode', 'chat');
}
if (el('bash-toggle').checked) {
fd.append('allow_bash', 'true');
}
fd.append('allow_bash', el('bash-toggle').checked ? 'true' : 'false');
const ragChk = el('rag-toggle');
if (ragChk && !ragChk.checked) {
fd.append('use_rag', 'false');
@@ -830,6 +832,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (incognitoChk && incognitoChk.checked) {
fd.append('incognito', 'true');
}
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
if (_ws) {
fd.append('workspace', _ws);
}
if (presetsModule.getSelectedPreset()) {
fd.append('preset_id', presetsModule.getSelectedPreset());
}
@@ -1093,7 +1099,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let _lastToolName = '';
const _searchIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
const _toolLabels = {
'web_search': _searchIcon + 'Searching',
'web_search': 'Searching',
'bash': 'Running',
'python': 'Running',
'create_document': 'Writing',
@@ -1113,6 +1119,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
'list_models': 'Browsing',
'ui_control': 'Adjusting',
};
const _toolIcons = {
'web_search': _searchIcon,
};
function _thinkingLabel() {
if (!_lastToolName) {
return 'Thinking';
@@ -1568,9 +1577,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
.replace(/<channel\|>/gi, '');
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
// Keep thinking box scrolled to bottom
// Keep thinking box scrolled to bottom, but let user scroll up
var thinkBox = _liveThinkInner.closest('.thinking-content');
if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight;
if (thinkBox) {
var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
}
}
uiModule.scrollHistory();
continue;
@@ -1789,6 +1801,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_sourcesData = json.data; _sourcesType = 'web';
_sourcesHtml = _buildSourcesBox(json.data, 'web');
}
} else if (json.type === 'workspace_rejected') {
// Server refused to bind the posted workspace (deleted folder,
// file path, sensitive dir, filesystem root). Clear the stored
// value so the pill stops claiming a confinement that is not in
// effect, and tell the user.
const _wsPath = (json.data && json.data.path) || '';
import('./workspace.js').then((m) => {
const ws = m.default || m;
if (ws && ws.setWorkspace) ws.setWorkspace('');
});
uiModule.showToast(
`Workspace ${_wsPath || '(unknown)'} is no longer usable; running without confinement`,
6000
);
continue;
} else if (json.type === 'model_fallback') {
// Model went offline — switched to fallback
var _fbData = json.data || {};
@@ -2060,10 +2087,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
threadWrap.classList.add('streaming');
const toolLabel = _toolLabels[json.tool.toLowerCase()] || json.tool;
const toolIcon = _toolIcons[json.tool.toLowerCase()] || '\u25B6';
const node = document.createElement('div')
node.className = 'agent-thread-node running';
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${toolIcon}</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
// Expand/collapse via delegated click handler (init at module bottom).
threadWrap.appendChild(node);
currentToolBubble = node;
@@ -3853,7 +3881,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Also submit on Enter (without shift)
editor.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
const isMobile = window.innerWidth <= 768
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
e.preventDefault();
saveBtn.click();
}
@@ -3861,9 +3891,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
/**
* Resend a user message — truncates history to that point and resubmits.
* Resend a user message. Normal resend appends a fresh copy at the end of
* the current thread; regenerate flows can opt into replacing from here.
*/
export async function resendUserMessage(userMsgElement) {
export async function resendUserMessage(userMsgElement, opts = {}) {
const replaceFromHere = Boolean(opts && opts.replaceFromHere);
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const msgIndex = allMsgs.indexOf(userMsgElement);
@@ -3909,25 +3941,28 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
// Truncate backend to keep everything before this user message
const keepCount = msgIndex;
try {
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount })
});
if (replaceFromHere) {
// Regenerate flows intentionally trim history to this point before
// resubmitting. The plain "Resend message" action must not do this.
const keepCount = msgIndex;
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep_count: keepCount })
});
// Drop the AI replies after the user message but KEEP the user bubble
// itself (so its photo stays visible). Then suppress the new user
// bubble that send would otherwise add — same pattern as regenerate.
let sibling = userMsgElement.nextSibling;
while (sibling) {
const next = sibling.nextSibling;
sibling.remove();
sibling = next;
// Drop the AI replies after the user message but KEEP the user bubble
// itself (so its photo stays visible). Then suppress the new user
// bubble that send would otherwise add — same pattern as regenerate.
let sibling = userMsgElement.nextSibling;
while (sibling) {
const next = sibling.nextSibling;
sibling.remove();
sibling = next;
}
_hideUserBubble = true;
}
_hideUserBubble = true;
_pendingRegenAttachments = _ids;
// Resubmit
@@ -4461,6 +4496,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
* Delete an AI message and its preceding user message from the conversation.
*/
export async function deleteMessage(msgElement) {
if (uiModule && uiModule.styledConfirm) {
const ok = await uiModule.styledConfirm('Delete this message?', {
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
});
if (!ok) return;
}
const box = document.getElementById('chat-history');
const allMsgs = Array.from(box.querySelectorAll('.msg'));
const clickedIndex = allMsgs.indexOf(msgElement);
+17 -2
View File
@@ -362,7 +362,7 @@ function _openVisionEditor(att, userMsgEl) {
await _saveVisionText();
_closeVisionEditor();
if (userMsgEl && window.chatModule?.resendUserMessage) {
window.chatModule.resendUserMessage(userMsgEl);
window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true });
} else if (uiModule?.showToast) {
uiModule.showToast('Saved');
}
@@ -862,6 +862,20 @@ export function stripToolBlocks(text) {
return cleaned.trim();
}
/**
* Plain-text payload for the message copy buttons: the reply as the renderer
* displays it tool blocks and <think> reasoning stripped. dataset.raw keeps
* the full model output (chat.js even embeds the elapsed time into the
* <think> tag for reload persistence), so copying it verbatim leaks the
* thinking block (#3722). Falls back to the raw text when stripping leaves
* nothing (e.g. turns interrupted mid-thinking).
*/
export function copyMessageText(msgElement) {
const raw = msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '';
const { content } = markdownModule.extractThinkingBlocks(stripToolBlocks(raw));
return content || raw;
}
/**
* Build a collapsible sources box (used by both research and web search).
*/
@@ -1372,7 +1386,7 @@ export function createMsgFooter(msgElement) {
{ id: 'copy', icon: COPY_ICON, title: 'Copy message', cls: 'footer-copy-btn', html: true, handler(e) {
e.stopPropagation();
const btn = e.currentTarget;
uiModule.copyToClipboard(msgElement.dataset.raw || msgElement.querySelector('.body')?.textContent || '');
uiModule.copyToClipboard(copyMessageText(msgElement));
btn.innerHTML = CHECK_ICON;
setTimeout(() => { btn.innerHTML = COPY_ICON; }, 1500);
}},
@@ -2444,6 +2458,7 @@ const chatRenderer = {
updateSessionCostUI,
roleTimestamp,
stripToolBlocks,
copyMessageText,
safeToolScreenshotSrc,
safeDisplayImageSrc,
buildSourcesBox,
+1 -1
View File
@@ -40,7 +40,7 @@ export const EVAL_PROMPTS = {
chat: [
// ── ★ Featured — prompts that have actually broken frontier models ──
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
{ sub: '★ Featured', label: 'Three jugs', answer: '4 pours: 7→5, 5→3, 3→7, 5→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
{ sub: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
+10 -1
View File
@@ -354,6 +354,15 @@ export const ERROR_PATTERNS = [
}},
],
},
{
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native dependencies are missing on this server.',
fixes: [
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
{
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
message: 'SGLang is not installed or not in PATH.',
@@ -440,7 +449,7 @@ export const ERROR_PATTERNS = [
{ label: 'Repair kernel package', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages kernels<0.15`);
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages "kernels<0.15"`);
}},
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
+79 -6
View File
@@ -814,6 +814,80 @@ export async function _hwfitFetch(fresh = false) {
}
}
// Renders a non-blocking hardware visibility warning when Cookbook is using
// container-visible hardware that may not match the user's actual host machine.
function _renderHwVisibilityWarning(sys) {
const row = document.getElementById('hwfit-hw-row');
if (!row) return;
let box = document.getElementById('hwfit-hw-visibility-warning');
// Manual hardware is an explicit user override, so avoid showing stale
// container-detection warnings once the user has chosen a simulated profile.
const warning = sys?.manual_hardware ? null : sys?.hardware_visibility_warning;
if (!warning) {
if (box) box.remove();
return;
}
if (!box) {
box = document.createElement('div');
box.id = 'hwfit-hw-visibility-warning';
box.className = 'hwfit-loading hwfit-hw-visibility-warning';
row.insertAdjacentElement('afterend', box);
}
box.innerHTML = `
<div class="hwfit-hw-visibility-warning-title">${esc(warning.title || 'Hardware visibility note')}</div>
<div class="hwfit-hw-visibility-warning-body">${esc(warning.message || '')}</div>
<div class="hwfit-hw-visibility-warning-actions">
<button type="button" class="hwfit-gpu-btn" data-hw-action="manual">Edit manual hardware</button>
<button type="button" class="hwfit-gpu-btn" data-hw-action="rescan">Rescan</button>
<button type="button" class="hwfit-gpu-btn" data-hw-action="copy">Copy diagnostics</button>
</div>
`;
box.querySelector('[data-hw-action="manual"]')?.addEventListener('click', () => {
const panel = document.getElementById('hwfit-manual-panel');
if (panel) panel.classList.remove('hidden');
document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({
behavior: 'smooth',
block: 'center',
});
});
box.querySelector('[data-hw-action="rescan"]')?.addEventListener('click', () => {
_resetGpuToggleState();
_hwfitCache = null;
_hwfitFetch(true);
});
box.querySelector('[data-hw-action="copy"]')?.addEventListener('click', () => {
// Keep diagnostics copy/paste friendly for GitHub issues and Docker support.
const text = [
'Odysseus Cookbook hardware diagnostics',
`probe_scope=${sys?.probe_scope || ''}`,
`containerized=${sys?.containerized === true}`,
`backend=${sys?.backend || ''}`,
`has_gpu=${sys?.has_gpu === true}`,
`gpu_name=${sys?.gpu_name || ''}`,
`gpu_count=${sys?.gpu_count || 0}`,
`gpu_vram_gb=${sys?.gpu_vram_gb || ''}`,
`ram=${sys?.available_ram_gb || '?'} / ${sys?.total_ram_gb || '?'} GB`,
`cpu_cores=${sys?.cpu_cores || ''}`,
`cpu_name=${sys?.cpu_name || ''}`,
'',
'Useful checks:',
'docker compose exec odysseus nvidia-smi -L',
'docker compose exec odysseus cat /proc/meminfo | head',
'docker compose exec odysseus python -c "from services.hwfit.hardware import detect_system; import json; print(json.dumps(detect_system(fresh=True), indent=2))"',
].join('\n');
_copyText(text);
});
}
export function _hwfitRenderHw(el, sys) {
if (!el || !sys) return;
// Cache system info globally so other modules can read VRAM without refetching
@@ -902,6 +976,7 @@ export function _hwfitRenderHw(el, sys) {
+ chip('cores', cores)
+ chip('backend', esc(sys.backend || ''))
+ manualChip;
_renderHwVisibilityWarning(sys);
// Body click → toggle "off" (dimmed, still visible). Membership of
// _dismissedHwChips is what the ranker reads, so both add+remove
// here also flips the model list. The manual chip is excluded —
@@ -1799,12 +1874,10 @@ export function _hwfitInit() {
clearTimeout(_hwfitDebounce);
_hwfitDebounce = setTimeout(() => _hwfitFetch(), 400);
});
// HF Token
const hfToken = document.getElementById('hwfit-hftoken');
if (hfToken) {
hfToken.addEventListener('change', () => { _envState.hfToken = hfToken.value.trim(); _persistEnvState(); });
hfToken.addEventListener('input', () => { _envState.hfToken = hfToken.value.trim(); });
}
// HF token save is owned by cookbook.js (_wireTabEvents) — do not wire a
// second change/input handler here. The old duplicate ran after cookbook.js
// cleared the input on save and overwrote _envState.hfToken with "", so the
// debounced state sync never persisted the token to cookbook_state.json.
// Rebuild all server select dropdowns with current servers
function _rebuildServerSelect() {
+3 -2
View File
@@ -653,7 +653,8 @@ export function _buildServeCmd(f, modelName, backend) {
} else if (backend === 'diffusers') {
const gpuStr = f.gpus?.trim();
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
cmd += `python3 scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
const diffusersPy = _isWindows() ? 'python' : _py3Bin;
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
@@ -774,7 +775,7 @@ async function _fetchDependencies() {
const data = await resp.json();
const pkgs = data.packages || [];
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
const _winUnsupported = new Set(['diffusers', 'hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
+15 -3
View File
@@ -793,9 +793,10 @@ function _winSessionCmd(task, tmuxArgs) {
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
}
if (tmuxArgs.includes('kill-session')) {
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
const ps = host
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
}
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
@@ -3588,12 +3589,22 @@ async function _pollBackgroundStatus() {
// dead-session check inspects). Recover "done" from the retained output's
// exit-0 sentinel so a clean install isn't downgraded to crashed.
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
// A finished model download whose tmux pane is gone is also reported
// "stopped" (the dead-session check can miss the landed snapshot).
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
// only after the runner exits 0 — so a completed download isn't
// downgraded to crashed. This background poll runs blind (no live
// stream to debounce against), so unlike the reconnect loop it keys
// off the conclusive exit sentinel only, never the `/snapshots/` path,
// which can be printed mid-stream for multi-file downloads.
const downloadDone = task.type === 'download'
&& String(task.output || '').includes('DOWNLOAD_OK');
const nextStatus = live.status === 'completed'
? 'done'
: (live.status === 'error'
? 'error'
: (live.status === 'stopped'
? (depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
: null));
if (nextStatus && task.status !== nextStatus) {
updates.status = nextStatus;
@@ -3603,6 +3614,7 @@ async function _pollBackgroundStatus() {
updates.status = live.status === 'ready' ? 'ready' : 'running';
}
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
if (live.exit_code != null && live.exit_code !== task.exit_code) updates.exit_code = live.exit_code;
if (live.output_tail) {
const previous = String(task.output || '');
const tail = String(live.output_tail || '');
+2 -2
View File
@@ -531,7 +531,7 @@ function _rerenderCachedModels() {
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
const detectedBackend = _detectBackend(m).backend;
const _allowedBackends = new Set(_isWindows()
? ['llamacpp']
? ['llamacpp', 'diffusers']
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
? ss.backend
@@ -608,7 +608,7 @@ function _rerenderCachedModels() {
// Row 1: Backend + Server + Env
panelHtml += `<div class="hwfit-serve-row">`;
const _backendChoices = _isWindows()
? [['llamacpp','llama.cpp']]
? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]
: _isMetal()
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
+1 -1
View File
@@ -998,7 +998,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
stripe.style.bottom = '0';
stripe.style.width = '10px';
stripe.style.cursor = 'col-resize';
stripe.style.zIndex = '9999';
stripe.style.zIndex = '261';
stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
stripe.style.pointerEvents = 'auto';
stripe.style.touchAction = 'none';
+3
View File
@@ -1099,6 +1099,9 @@ export function openPanel() {
if (_open) return;
_open = true;
_editingId = null;
// Reset the search filter — the rebuilt pane's search input renders empty, so a
// stale _searchQuery would silently hide non-matching notes after a reopen.
_searchQuery = '';
_clearViewedReminderGlows();
_firedDotDismissedAt = Date.now();
try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {}
+1
View File
@@ -118,6 +118,7 @@ const _ENDPOINT_LABELS = [
[/(^|\.)together\.(ai|xyz)$/i, "Together"],
[/(^|\.)fireworks\.ai$/i, "Fireworks"],
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
[/(^|\.)nvidia\.com$/i, "NVIDIA"],
[/(^|\.)x\.ai$/i, "xAI"],
];
+7
View File
@@ -373,6 +373,13 @@ function _buildPanelHTML() {
<span id="research-no-past-hint" style="display:none;font-size:11px;opacity:0.7;position:relative;top:-4px;"> past runs in <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
</p>
<textarea id="research-query" class="research-query" placeholder="${_pickResearchHint()}" rows="4"></textarea>
<div class="research-category-row" id="research-category-row">
<button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
<button class="research-cat" data-cat="product">Product</button>
<button class="research-cat" data-cat="comparison">Compare</button>
<button class="research-cat" data-cat="howto">How-to</button>
<button class="research-cat" data-cat="factcheck">Fact-check</button>
</div>
<button id="research-settings-toggle" class="research-settings-toggle${chevronCls}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.85;flex-shrink:0;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings<span class="research-settings-chevron">${_chevronIcon}</span>
</button>
+5 -1
View File
@@ -3853,7 +3853,11 @@ async function initUnifiedIntegrations() {
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-api-save').addEventListener('click', async () => {
const presetKey = preset.value || undefined;
const body = { name: name.value, base_url: url.value, auth_type: auth.value, auth_header: header.value, preset: presetKey };
const nameValue = name.value.trim();
const urlValue = url.value.trim();
if (!nameValue) { el('uf-api-msg').textContent = 'Name required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
if (!urlValue) { el('uf-api-msg').textContent = 'Base URL required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
const body = { name: nameValue, base_url: urlValue, auth_type: auth.value, auth_header: header.value, preset: presetKey };
if (key.value) body.api_key = key.value;
try {
const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
+6
View File
@@ -524,6 +524,8 @@ function _buildBuiltinCards() {
card.addEventListener('click', (e) => {
if (e.target.closest('button, input, textarea')) return;
// Editing in progress → don't collapse on an outside-the-textarea click.
if (card.querySelector('.skill-md-editor')) return;
_expandBuiltinCard(card, b.name);
});
return card;
@@ -796,6 +798,10 @@ function renderSkillsList() {
card.addEventListener('click', (e) => {
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
if (e.target.closest('button, input, textarea')) return;
// While editing, a click on the card body (outside the textarea) must
// NOT collapse the card — that silently discards unsaved edits. Only
// Save/Cancel exit edit mode.
if (card.querySelector('.skill-md-editor')) return;
if (_selectMode) {
const cb = card.querySelector('.skill-select-cb');
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
+56 -6
View File
@@ -17,6 +17,7 @@ import chatRenderer from './chatRenderer.js';
import spinnerModule from './spinner.js';
import themeModule from './theme.js';
import documentModule from './document.js';
import workspaceModule from './workspace.js';
import settingsModule from './settings.js';
import cookbookModule from './cookbook.js';
import { EVAL_PROMPTS } from './compare/index.js';
@@ -43,6 +44,7 @@ const PROVIDER_PATTERNS = [
{ re: /^gsk_/, name: 'Groq', url: 'https://api.groq.com/openai/v1' },
{ re: /^AIza/, name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
{ re: /^xai-/, name: 'xAI', url: 'https://api.x.ai/v1' },
{ re: /^nvapi-/, name: 'NVIDIA', url: 'https://integrate.api.nvidia.com/v1' },
];
const SETUP_PROVIDER_URLS = {
deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1' },
@@ -56,8 +58,9 @@ const SETUP_PROVIDER_URLS = {
google: { name: 'Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai' },
'opencode-zen': { name: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1' },
'opencode-go': { name: 'OpenCode Go', url: 'https://opencode.ai/zen/go/v1' },
nvidia: { name: 'NVIDIA', url: 'https://integrate.api.nvidia.com/v1' },
};
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go'];
const SETUP_PROVIDER_NAMES = ['deepseek', 'openai', 'openrouter', 'ollama', 'xai', 'anthropic', 'groq', 'gemini', 'opencode-zen', 'opencode-go', 'nvidia'];
const SETUP_DEVICE_AUTH_PROVIDERS = [
{ key: 'copilot', name: 'GitHub Copilot', aliases: ['github'], command: '/setup copilot' },
{ key: 'chatgpt-subscription', name: 'ChatGPT Subscription', aliases: ['chatgptsubscription', 'chatgpt-sub', 'codex'], command: '/setup chatgpt-subscription' },
@@ -97,6 +100,7 @@ function _setupProviderFromInput(input) {
google: 'gemini',
xai: 'xai',
grok: 'xai',
nvidia: 'nvidia',
};
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
}
@@ -124,6 +128,7 @@ function _extractSetupProviderCredential(input) {
['groq', 'groq'],
['google', 'gemini'], ['gemini', 'gemini'],
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
['nvidia', 'nvidia'],
];
for (const [alias, key] of providerAliases) {
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
@@ -334,10 +339,13 @@ function _submitComposedMessage(text) {
const msgInput = document.getElementById('message');
const form = document.getElementById('chat-form');
if (!msgInput || !form) return false;
msgInput.value = text;
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
if (typeof form.requestSubmit === 'function') form.requestSubmit();
else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
// The slash handler and app-level form debounce must both release before
// sending the pinned prompt, otherwise the follow-up submit is dropped.
setTimeout(() => {
msgInput.value = text;
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}, 350);
return true;
}
@@ -376,7 +384,7 @@ function _slashFooter(msgEl) {
copyBtn.innerHTML = _copySvg;
copyBtn.onclick = (e) => {
e.stopPropagation();
uiModule.copyToClipboard(msgEl.dataset.raw || msgEl.querySelector('.body')?.textContent || '');
uiModule.copyToClipboard(chatRenderer.copyMessageText(msgEl));
copyBtn.innerHTML = _checkSvg;
setTimeout(() => { copyBtn.innerHTML = _copySvg; }, 1500);
};
@@ -1225,6 +1233,40 @@ async function _cmdToggleDoc(args, ctx) {
return true;
}
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean -
// show / set <path> / clear / pick (open the directory browser).
async function _cmdWorkspace(args, ctx) {
const sub = (args[0] || '').toLowerCase();
const rest = args.slice(1).join(' ').trim();
const cur = workspaceModule.getWorkspace();
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
return true;
}
if (sub === 'set' || sub === 'cd' || sub === 'use') {
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
// Validate server-side before persisting so the pill never claims a
// workspace the backend will refuse to bind (typo, file path, deleted
// folder, sensitive dir, filesystem root).
workspaceModule.vetAndSetWorkspace(rest).then(({ ok, path }) => {
if (ok) slashReply(`Workspace set: <code>${uiModule.esc(path)}</code>`);
else slashReply(`Not a usable workspace folder: <code>${uiModule.esc(rest)}</code>. It must be an existing directory, not a filesystem root or sensitive path.`);
});
return true;
}
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
workspaceModule.clearWorkspace();
slashReply('Workspace cleared.');
return true;
}
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
workspaceModule.openWorkspaceBrowser();
return true;
}
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
return true;
}
async function _cmdToggleShow(args, ctx) {
const name = (args[0] || '').toLowerCase();
const val = (args[1] || '').toLowerCase();
@@ -5727,6 +5769,14 @@ const COMMANDS = {
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
}
},
workspace: {
alias: ['ws'],
category: 'Agent',
help: 'Set the folder the agent works in',
handler: _cmdWorkspace,
noUserBubble: true,
usage: '/workspace [set <path> | clear | pick]',
},
memory: {
alias: ['m'],
category: 'Memory',
+2 -1
View File
@@ -23,7 +23,8 @@ export const KEYS = {
MCP_ACTIVE: 'odysseus-mcp-active',
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density'
DENSITY: 'odysseus-density',
WORKSPACE: 'odysseus-workspace'
};
/**
+21 -13
View File
@@ -6,16 +6,13 @@
* when the cursor is near a snap zone. On release, snaps the modal-content
* to fill that zone with a springy animation.
*
* Snap zones (9):
* - top edge (10% strip) maximize
* - top-left corner top-left quarter
* - top-right corner top-right quarter
* Snap zones:
* - over top edge fullscreen
* - top strip maximize
* - top edge top half
* - left edge left half
* - right edge right half
* - bottom-left corner bottom-left quarter
* - bottom-right corner bottom-right quarter
* - bottom edge bottom half
* - sidebar edge (if present) snap next to the sidebar
*
* Mobile (768px) is excluded the swipe-dismiss UX takes precedence.
*
@@ -24,7 +21,6 @@
*/
const EDGE_THRESHOLD_PX = 24; // how close to an edge counts as "near"
const CORNER_THRESHOLD_PX = 64; // corner box size
const TOP_FULL_STRIP_PX = 8; // top strip → maximize
let _ghost = null;
@@ -111,9 +107,13 @@ function _zoneForPointer(x, y) {
return { name: 'maximize', rect: { left: safe.left, top: safe.top, width: W, height: H } };
}
// Corner quarter-snaps DISABLED (user request) — only the top strip
// (maximize) and the right/bottom half-snaps remain. The LEFT-half snap
// is also disabled (the sidebar lives there; docking over it is awkward).
// Symmetric edge half-snaps. The safe rect already starts to the right of
// the sidebar/rail, so left-half fills the left side of the workspace
// without covering navigation.
if (y <= safe.top + EDGE_THRESHOLD_PX)
return { name: 'top-half', rect: { left: safe.left, top: safe.top, width: W, height: H / 2 } };
if (x <= safe.left + EDGE_THRESHOLD_PX)
return { name: 'left-half', rect: { left: safe.left, top: safe.top, width: W / 2, height: H } };
if (x >= safe.right - EDGE_THRESHOLD_PX)
return { name: 'right-half', rect: { left: safe.left + W / 2, top: safe.top, width: W / 2, height: H } };
if (y >= safe.bottom - EDGE_THRESHOLD_PX)
@@ -131,8 +131,7 @@ function _zoneForContent(content, x, y) {
// flip to top tabs via CSS when the window gets narrow.
if (modal && modal.id === 'settings-modal' && zone.name !== 'right-half') return null;
if (modal && (modal.id === 'cookbook-modal'
|| modal.id === 'theme-modal'
|| modal.id === 'memory-modal')
|| modal.id === 'theme-modal')
&& zone.name !== 'fullscreen') return null;
return zone;
}
@@ -304,6 +303,7 @@ function _reclampAll(animate = false) {
switch (name) {
case 'fullscreen': r = { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; break;
case 'maximize': r = { left: safe.left, top: safe.top, width: W, height: H }; break;
case 'top-half': r = { left: safe.left, top: safe.top, width: W, height: H/2 }; break;
case 'left-half': r = { left: safe.left, top: safe.top, width: W/2, height: H }; break;
case 'right-half': r = { left: safe.left + W/2, top: safe.top, width: W/2, height: H }; break;
case 'bottom-half': r = { left: safe.left, top: safe.top + H/2, width: W, height: H/2 }; break;
@@ -374,6 +374,14 @@ export function clearPreview() {
_activeZone = null;
}
export function _zoneForPointerForTests(x, y) {
return _zoneForPointer(x, y);
}
export function _zoneForContentForTests(content, x, y) {
return _zoneForContent(content, x, y);
}
// Snap a modal (its .modal-content) into a previously-detected zone.
export function snapModalToZone(modal, zone) {
if (!modal || !zone) return;
+1 -1
View File
@@ -61,7 +61,7 @@ export function makeWindowDraggable(modal, options = {}) {
const fsClass = options.fsClass || null;
const onEnterFullscreen = options.onEnterFullscreen || null;
const onExitFullscreen = options.onExitFullscreen || null;
const enableFullscreen = options.enableFullscreen !== false && !!onEnterFullscreen;
const enableFullscreen = false;
const onDragEnd = options.onDragEnd || null;
const onDragStart = options.onDragStart || null;
const skipSelector = options.skipSelector || 'button, input, select';
+208
View File
@@ -0,0 +1,208 @@
// static/js/workspace.js
//
// Workspace picker: browse server directories in a draggable modal, choose a
// folder, and show it as a removable pill in the chat input bar. While set, the
// chat request sends `workspace` so the agent's file/shell tools are confined
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
import Storage, { KEYS } from './storage.js';
import uiModule from './ui.js';
import { makeWindowDraggable } from './windowDrag.js';
const API_BASE = window.location.origin;
// Same folder glyph as the overflow menu item + pill (not an emoji).
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
let _modal = null;
let _curPath = '';
export function getWorkspace() {
return Storage.get(KEYS.WORKSPACE, '') || '';
}
function _basename(p) {
if (!p) return '';
// Handle both POSIX (/) and Windows (\) separators.
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
return parts[parts.length - 1] || p;
}
// Workspace only applies to agent mode (it scopes the file/shell tools), so the
// pill + overflow entry are hidden in chat mode, like the bash toggle.
function _isChatMode() {
const b = document.getElementById('mode-chat-btn');
return !!(b && b.classList.contains('active'));
}
export function syncWorkspaceIndicator(path) {
const chat = _isChatMode();
const pill = document.getElementById('workspace-indicator-btn');
const name = document.getElementById('workspace-indicator-name');
const overflow = document.getElementById('overflow-workspace-btn');
if (pill) {
pill.style.display = (path && !chat) ? '' : 'none';
pill.classList.toggle('active', !!path);
if (path) pill.title = `Workspace: ${path}\nFile tools are confined here; shell commands start here but are not sandboxed and can reach outside it.\nClick to clear.`;
}
if (name) name.textContent = path ? _basename(path) : '';
if (overflow) {
overflow.style.display = chat ? 'none' : '';
overflow.classList.toggle('active', !!path);
}
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
}
// Called by the agent/chat mode toggle so the pill + overflow entry follow mode.
export function applyMode(_mode) {
syncWorkspaceIndicator(getWorkspace());
}
export function setWorkspace(path) {
if (path) Storage.set(KEYS.WORKSPACE, path);
else Storage.remove(KEYS.WORKSPACE);
syncWorkspaceIndicator(path || '');
}
/**
* Validate a manually entered path server-side, then persist the canonical
* form. Returns {ok, path|null}. Without this, a typo / file path / deleted
* folder / filesystem root would be stored and shown as active while the
* backend silently refuses to bind it on every send.
*/
export async function vetAndSetWorkspace(path) {
try {
const res = await fetch(`${API_BASE}/api/workspace/vet?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' });
if (!res.ok) return { ok: false, path: null };
const data = await res.json();
if (data.ok && data.path) {
setWorkspace(data.path);
return { ok: true, path: data.path };
}
return { ok: false, path: null };
} catch (e) {
return { ok: false, path: null };
}
}
export function clearWorkspace() {
setWorkspace('');
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
}
async function _load(path) {
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
return res.json();
}
function _render(data) {
_curPath = data.path;
const body = _modal.querySelector('#workspace-body');
const pathEl = _modal.querySelector('#workspace-cur-path');
if (pathEl) {
// Reflect the resolved (realpath) location back into the editable field.
pathEl.value = data.path;
pathEl.title = data.path;
}
let rows = '';
if (data.parent) {
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
}
for (const d of data.dirs) {
// Backend supplies the full child path (os.path.join → cross-platform).
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
}
if (data.truncated) {
rows += '<div class="workspace-empty">Too many folders to list. Type or paste a path above to jump in.</div>';
}
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
body.querySelectorAll('.workspace-row').forEach((row) => {
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
});
// Filesystem roots (and sensitive dirs) can be browsed through but never
// bound as the workspace; the backend rejects them too.
const useBtn = _modal.querySelector('#workspace-use');
if (useBtn) {
useBtn.disabled = data.selectable === false;
useBtn.title = data.selectable === false ? 'This folder cannot be used as a workspace' : '';
}
}
async function _navigate(path) {
try {
_render(await _load(path));
} catch (e) {
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
}
}
function _getModal() {
if (_modal) return _modal;
_modal = document.createElement('div');
_modal.id = 'workspace-modal';
_modal.className = 'modal';
_modal.style.display = 'none';
_modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
<button class="close-btn" id="workspace-close" aria-label="Close"></button>
</div>
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
placeholder="Type or paste a folder path, then press Enter" />
<p class="muted workspace-note">File tools are <strong>confined</strong> to this folder. Shell commands start here but are <strong>not sandboxed</strong> and can reach outside it. A workspace scopes the tools; it is not a security boundary.</p>
<div class="modal-body workspace-body" id="workspace-body"></div>
<div class="modal-footer workspace-footer">
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
</div>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
// Editable path bar: Enter navigates to a typed/pasted folder.
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const v = e.target.value.trim();
if (v) _navigate(v);
}
});
_modal.querySelector('#workspace-use').addEventListener('click', () => {
setWorkspace(_curPath);
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
closeWorkspaceBrowser();
});
const content = _modal.querySelector('.modal-content');
const header = _modal.querySelector('.modal-header');
if (content && header) makeWindowDraggable(_modal, { content, header });
return _modal;
}
export async function openWorkspaceBrowser() {
const modal = _getModal();
modal.style.display = 'flex';
try {
_render(await _load(getWorkspace() || ''));
} catch (e) {
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
}
}
export function closeWorkspaceBrowser() {
if (_modal) _modal.style.display = 'none';
}
export function initWorkspace() {
// Restore persisted workspace into the pill on load.
syncWorkspaceIndicator(getWorkspace());
const overflow = document.getElementById('overflow-workspace-btn');
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
const pill = document.getElementById('workspace-indicator-btn');
if (pill) pill.addEventListener('click', clearWorkspace);
}
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, vetAndSetWorkspace, clearWorkspace, syncWorkspaceIndicator, applyMode };