Changeset 0.28.6 (#192)
This commit is contained in:
96
src/js/broadcast-admin.js
Normal file
96
src/js/broadcast-admin.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Broadcast Admin Panel
|
||||
// ==========================================
|
||||
// Admin section for composing and sending system announcements.
|
||||
// Registered as ADMIN_LOADERS.broadcast in ui-admin.js.
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div style="max-width:600px">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:20px">' +
|
||||
'Send a system announcement to all active users. Notifications appear in their ' +
|
||||
'bell menu and optionally via email (based on user preferences).' +
|
||||
'</p>' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Title</label>' +
|
||||
'<input type="text" id="broadcastTitle" class="input" placeholder="Scheduled maintenance tonight" autocomplete="off">' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Message</label>' +
|
||||
'<textarea id="broadcastMessage" class="input" rows="4" placeholder="Details about the announcement…" style="resize:vertical;font-family:inherit"></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Level</label>' +
|
||||
'<select id="broadcastLevel" class="input">' +
|
||||
'<option value="info">Info</option>' +
|
||||
'<option value="warning">Warning</option>' +
|
||||
'<option value="critical">Critical</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<button id="broadcastSendBtn" class="btn-md btn-primary" style="margin-top:8px">' +
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
|
||||
' Send Broadcast' +
|
||||
'</button>' +
|
||||
'<div id="broadcastResult" style="margin-top:12px;display:none"></div>' +
|
||||
'</div>';
|
||||
|
||||
sb.register('_loadAdminBroadcast', async function () {
|
||||
var body = document.getElementById('adminDynamic') || document.getElementById('adminContentBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = SCAFFOLD_HTML;
|
||||
|
||||
var sendBtn = document.getElementById('broadcastSendBtn');
|
||||
var resultEl = document.getElementById('broadcastResult');
|
||||
|
||||
sendBtn.addEventListener('click', async function () {
|
||||
var title = document.getElementById('broadcastTitle').value.trim();
|
||||
var message = document.getElementById('broadcastMessage').value.trim();
|
||||
var level = document.getElementById('broadcastLevel').value;
|
||||
|
||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||
if (!message) { UI.toast('Message is required', 'warning'); return; }
|
||||
|
||||
// Confirmation
|
||||
var ok = await UI.confirm(
|
||||
'Send broadcast to all active users?\n\n' +
|
||||
'Level: ' + level + '\n' +
|
||||
'Title: ' + title
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = 'Sending…';
|
||||
resultEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
var resp = await API.post('/admin/notifications/broadcast', {
|
||||
title: title,
|
||||
message: message,
|
||||
level: level,
|
||||
});
|
||||
|
||||
var count = resp.count || 0;
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.className = 'admin-success';
|
||||
resultEl.innerHTML =
|
||||
'<span style="color:var(--success);font-weight:600">✓</span> ' +
|
||||
'Broadcast sent to ' + count + ' user' + (count !== 1 ? 's' : '') + '.';
|
||||
|
||||
// Clear form
|
||||
document.getElementById('broadcastTitle').value = '';
|
||||
document.getElementById('broadcastMessage').value = '';
|
||||
document.getElementById('broadcastLevel').value = 'info';
|
||||
|
||||
UI.toast('Broadcast sent to ' + count + ' users', 'success');
|
||||
} catch (err) {
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.className = 'admin-error';
|
||||
resultEl.innerHTML =
|
||||
'<span style="color:var(--danger);font-weight:600">✗</span> ' +
|
||||
'Failed: ' + (err.message || err);
|
||||
UI.toast('Broadcast failed', 'error');
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = ' Send Broadcast';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -236,6 +236,8 @@ async function loadChats() {
|
||||
|
||||
async function selectChat(chatId) {
|
||||
clearStaged(); // Discard any staged files from previous chat
|
||||
// v0.28.6: Reset virtual scroll when switching conversations
|
||||
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
App.setActive(chatId, chat?.type || 'direct');
|
||||
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
|
||||
@@ -418,6 +420,7 @@ function _cleanStaleChatModel(chatId) {
|
||||
|
||||
async function newChat() {
|
||||
clearStaged(); // Discard any staged files
|
||||
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
|
||||
App.setActive(null);
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
|
||||
180
src/js/git-credentials-ui.js
Normal file
180
src/js/git-credentials-ui.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Git Credentials Settings
|
||||
// ==========================================
|
||||
// Settings section for managing SSH keys used with git workspaces.
|
||||
// Keys are generated server-side — private keys never leave the server.
|
||||
// Users copy the public key into their repo host (GitHub, Gitea, etc.).
|
||||
// Optional persona binding enables per-persona commit attribution.
|
||||
|
||||
var SCAFFOLD_HTML =
|
||||
'<div style="max-width:640px">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;line-height:1.6;margin-bottom:16px">' +
|
||||
'SSH keys for git operations. Keys are generated on the server — the private key ' +
|
||||
'is vault-encrypted and never exposed. Copy the public key into your git host.' +
|
||||
'</p>' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
|
||||
'<h4 style="margin:0">SSH Keys</h4>' +
|
||||
'<button id="gitKeyGenerateBtn" class="btn-small btn-primary">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||
' Generate Key' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div id="gitKeyList" class="admin-list" style="margin-bottom:16px"></div>' +
|
||||
'<div id="gitKeyEmpty" style="display:none;color:var(--text-3);font-size:13px;text-align:center;padding:24px 0">' +
|
||||
'No SSH keys yet. Generate one to get started.' +
|
||||
'</div>' +
|
||||
'<div style="margin-top:16px;padding:14px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
|
||||
'<span style="font-size:12px;font-weight:600">Security</span>' +
|
||||
'</div>' +
|
||||
'<p style="font-size:12px;color:var(--text-3);line-height:1.6;margin:0">' +
|
||||
'Private keys are encrypted with your personal vault key. They are used server-side ' +
|
||||
'for git push/pull operations and cannot be downloaded or viewed.' +
|
||||
'</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// ── Generate key dialog ──
|
||||
var GENERATE_HTML =
|
||||
'<div style="max-width:400px">' +
|
||||
'<div class="form-group">' +
|
||||
'<label>Key Name</label>' +
|
||||
'<input type="text" id="gitKeyName" class="input" placeholder="e.g. GitHub - main" autocomplete="off">' +
|
||||
'</div>' +
|
||||
'<div class="form-group" id="gitKeyPersonaGroup" style="display:none">' +
|
||||
'<label>Persona (optional)</label>' +
|
||||
'<select id="gitKeyPersona" class="input"><option value="">None — use account identity</option></select>' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin:4px 0 0">Commits signed with this key will use the persona\'s name as git author.</p>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
async function _loadSettingsGitKeys() {
|
||||
var body = document.querySelector('.settings-section') || document.getElementById('settingsSection');
|
||||
if (!body) return;
|
||||
body.innerHTML = SCAFFOLD_HTML;
|
||||
|
||||
var listEl = document.getElementById('gitKeyList');
|
||||
var emptyEl = document.getElementById('gitKeyEmpty');
|
||||
var genBtn = document.getElementById('gitKeyGenerateBtn');
|
||||
|
||||
await refreshList();
|
||||
|
||||
genBtn.addEventListener('click', async function () {
|
||||
// Show generate dialog
|
||||
var ok = await new Promise(function (resolve) {
|
||||
UI.modal('Generate SSH Key', GENERATE_HTML, [
|
||||
{ label: 'Cancel', variant: 'ghost', action: function () { resolve(false); } },
|
||||
{ label: 'Generate', variant: 'primary', action: function () { resolve(true); } },
|
||||
]);
|
||||
|
||||
// Load personas for the optional selector
|
||||
API.get('/personas/mine').then(function (d) {
|
||||
var personas = (d.data || d.personas || []);
|
||||
if (personas.length > 0) {
|
||||
var group = document.getElementById('gitKeyPersonaGroup');
|
||||
var sel = document.getElementById('gitKeyPersona');
|
||||
if (group) group.style.display = '';
|
||||
personas.forEach(function (p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name + ' (@' + p.handle + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}).catch(function () {});
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
var name = (document.getElementById('gitKeyName') || {}).value || '';
|
||||
if (!name.trim()) { UI.toast('Key name is required', 'warning'); return; }
|
||||
|
||||
var personaId = (document.getElementById('gitKeyPersona') || {}).value || null;
|
||||
|
||||
try {
|
||||
var payload = { name: name.trim() };
|
||||
if (personaId) payload.persona_id = personaId;
|
||||
var cred = await API.post('/git-credentials/generate', payload);
|
||||
UI.toast('SSH key generated', 'success');
|
||||
|
||||
// Show the public key for copying
|
||||
showPublicKey(cred);
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
UI.toast('Generation failed: ' + (err.message || err), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshList() {
|
||||
try {
|
||||
var d = await API.get('/git-credentials');
|
||||
var creds = d.data || [];
|
||||
if (creds.length === 0) {
|
||||
listEl.innerHTML = '';
|
||||
listEl.style.display = 'none';
|
||||
emptyEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
listEl.style.display = '';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
listEl.innerHTML = creds.map(function (c) {
|
||||
var ts = new Date(c.created_at);
|
||||
var dateStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
var fp = c.fingerprint ? '<span style="font-family:var(--mono);font-size:11px;color:var(--text-3)">' + esc(c.fingerprint) + '</span>' : '';
|
||||
var persona = c.persona_id ? '<span class="badge" style="font-size:10px">persona</span>' : '';
|
||||
return '<div class="admin-list-item" style="display:flex;align-items:center;gap:10px;padding:10px 14px">' +
|
||||
'<div style="flex:1">' +
|
||||
'<div style="font-weight:500;font-size:13px">' + esc(c.name) + ' ' + persona + '</div>' +
|
||||
'<div style="display:flex;gap:12px;margin-top:2px">' +
|
||||
'<span class="badge">' + esc(c.auth_type) + '</span>' +
|
||||
fp +
|
||||
'<span style="font-size:11px;color:var(--text-3)">' + dateStr + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(c.public_key ? '<button class="btn-small" onclick="_gitKeyCopy(\'' + c.id + '\')">Copy Public Key</button>' : '') +
|
||||
'<button class="btn-small btn-ghost" onclick="_gitKeyDelete(\'' + c.id + '\',\'' + esc(c.name) + '\')">Delete</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<div style="color:var(--danger);padding:12px">Failed to load credentials</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function showPublicKey(cred) {
|
||||
if (!cred.public_key) return;
|
||||
UI.modal('Public Key — ' + cred.name,
|
||||
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">Add this key to your git host (GitHub → Settings → SSH Keys):</p>' +
|
||||
'<textarea readonly style="width:100%;height:80px;font-family:var(--mono);font-size:11px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius);padding:8px;color:var(--text);resize:none">' + esc(cred.public_key) + '</textarea>' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin-top:4px">Fingerprint: ' + esc(cred.fingerprint) + '</p>',
|
||||
[{ label: 'Done', variant: 'primary' }]
|
||||
);
|
||||
}
|
||||
|
||||
// Register global handlers for onclick
|
||||
window._gitKeyCopy = async function (id) {
|
||||
try {
|
||||
var d = await API.get('/git-credentials/' + id + '/public-key');
|
||||
await navigator.clipboard.writeText(d.public_key);
|
||||
UI.toast('Public key copied to clipboard', 'success');
|
||||
} catch (err) {
|
||||
UI.toast('Failed to copy: ' + (err.message || err), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window._gitKeyDelete = async function (id, name) {
|
||||
var ok = await UI.confirm('Delete SSH key "' + name + '"?\n\nWorkspaces using this key will lose git access until reassigned.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.del('/git-credentials/' + id);
|
||||
UI.toast('Key deleted', 'success');
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
UI.toast('Delete failed: ' + (err.message || err), 'error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
|
||||
sb.register('_loadSettingsGitKeys', _loadSettingsGitKeys);
|
||||
@@ -9,6 +9,7 @@
|
||||
'<div id="taskAdminTabs" style="display:flex;gap:8px;margin-bottom:16px">' +
|
||||
'<button class="btn-small btn-primary" id="taskTabList">Tasks</button>' +
|
||||
'<button class="btn-small" id="taskTabConfig">Configuration</button>' +
|
||||
'<button class="btn-small" id="taskTabSystem">+ System Task</button>' +
|
||||
'</div>' +
|
||||
'<div id="taskListPane">' +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">' +
|
||||
@@ -47,6 +48,28 @@
|
||||
'<button class="btn-small btn-danger" id="taskKillBtn">Kill Active Run</button>' +
|
||||
'</div>' +
|
||||
'<div id="taskRunList" class="admin-list"></div>' +
|
||||
'</div>' +
|
||||
'<div id="taskSystemPane" style="display:none">' +
|
||||
'<h4 style="margin:0 0 12px">Create System Task</h4>' +
|
||||
'<p style="font-size:12px;color:var(--text-2);margin:0 0 16px;line-height:1.5">' +
|
||||
'System tasks run built-in Go functions on a schedule. No LLM, no provider — ' +
|
||||
'pure platform operations (cleanup, sweeps, compaction). Admin-only.</p>' +
|
||||
'<div class="settings-section" style="padding:16px">' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:2"><label>Name</label>' +
|
||||
'<input type="text" id="sysTaskName" style="width:100%" placeholder="Nightly Session Cleanup"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Function</label>' +
|
||||
'<select id="sysTaskFunc" style="width:100%"><option value="">Loading…</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div id="sysTaskFuncDesc" style="font-size:11px;color:var(--text-3);margin:4px 0 12px;min-height:16px"></div>' +
|
||||
'<div class="form-row" style="gap:12px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Schedule (cron)</label>' +
|
||||
'<input type="text" id="sysTaskCron" style="width:100%" placeholder="0 3 * * *"></div>' +
|
||||
'<div class="form-group" style="flex:1"><label>Timezone</label>' +
|
||||
'<input type="text" id="sysTaskTZ" style="width:100%" value="UTC"></div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small btn-primary" id="sysTaskCreateBtn" style="margin-top:12px">Create System Task</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
function showTab(tab) {
|
||||
@@ -54,8 +77,10 @@
|
||||
el('taskListPane').style.display = tab === 'list' ? '' : 'none';
|
||||
el('taskConfigPane').style.display = tab === 'config' ? '' : 'none';
|
||||
el('taskRunPane').style.display = tab === 'runs' ? '' : 'none';
|
||||
el('taskSystemPane').style.display = tab === 'system' ? '' : 'none';
|
||||
el('taskTabList').className = 'btn-small' + (tab === 'list' ? ' btn-primary' : '');
|
||||
el('taskTabConfig').className = 'btn-small' + (tab === 'config' ? ' btn-primary' : '');
|
||||
el('taskTabSystem').className = 'btn-small' + (tab === 'system' ? ' btn-primary' : '');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
@@ -89,9 +114,15 @@
|
||||
: '<span class="badge badge-muted">paused</span>';
|
||||
var scope = '<span class="badge">' + t.scope + '</span>';
|
||||
var type = '<span class="badge">' + t.task_type + '</span>';
|
||||
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule;
|
||||
var nextRun = t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var funcBadge = t.task_type === 'system' && t.system_function
|
||||
? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + escapeHtml(t.system_function) + '</span>' : '';
|
||||
var schedule = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
|
||||
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var lastRun = t.last_run_at ? new Date(t.last_run_at).toLocaleString() : 'never';
|
||||
var triggerBtn = '';
|
||||
if (t.schedule === 'webhook' && t.trigger_token) {
|
||||
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + escapeHtml(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
|
||||
}
|
||||
|
||||
return '<div class="admin-row" style="display:flex;align-items:center;gap:12px;padding:10px 12px">' +
|
||||
'<div style="flex:2">' +
|
||||
@@ -100,9 +131,10 @@
|
||||
escapeHtml(schedule) + ' \u00b7 ' + t.run_count + ' runs \u00b7 last: ' + lastRun +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' + type + scope + active + '</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' + type + funcBadge + scope + active + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--text-secondary);min-width:140px;text-align:right">next: ' + nextRun + '</div>' +
|
||||
'<div style="display:flex;gap:4px">' +
|
||||
triggerBtn +
|
||||
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
|
||||
'<button class="btn-small task-history-btn" data-id="' + t.id + '" data-name="' + escapeHtml(t.name) + '" title="History">\ud83d\udccb</button>' +
|
||||
'<button class="btn-small btn-danger task-delete-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
|
||||
@@ -119,6 +151,17 @@
|
||||
list.querySelectorAll('.task-delete-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); deleteTask(btn.dataset.id); });
|
||||
});
|
||||
list.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
UI.toast('Trigger URL copied', 'success');
|
||||
}).catch(function() {
|
||||
prompt('Trigger URL:', url);
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load tasks:', err);
|
||||
}
|
||||
@@ -237,6 +280,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── System functions ──────────────────────
|
||||
var _sysFuncs = [];
|
||||
|
||||
async function loadSystemFuncs() {
|
||||
try {
|
||||
var resp = await API._get('/api/v1/admin/system-functions');
|
||||
_sysFuncs = resp.data || [];
|
||||
var sel = document.getElementById('sysTaskFunc');
|
||||
sel.innerHTML = '<option value="">— select function —</option>';
|
||||
_sysFuncs.forEach(function(f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f.name;
|
||||
opt.textContent = f.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.addEventListener('change', function() {
|
||||
var desc = document.getElementById('sysTaskFuncDesc');
|
||||
var found = _sysFuncs.find(function(f) { return f.name === sel.value; });
|
||||
desc.textContent = found ? found.description : '';
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[task-admin] Failed to load system functions:', err);
|
||||
}
|
||||
|
||||
// Wire create button
|
||||
document.getElementById('sysTaskCreateBtn').addEventListener('click', createSystemTask);
|
||||
}
|
||||
|
||||
async function createSystemTask() {
|
||||
var name = document.getElementById('sysTaskName').value.trim();
|
||||
var func_ = document.getElementById('sysTaskFunc').value;
|
||||
var cron = document.getElementById('sysTaskCron').value.trim();
|
||||
var tz = document.getElementById('sysTaskTZ').value.trim() || 'UTC';
|
||||
|
||||
if (!name) { UI.toast('Name is required', 'warning'); return; }
|
||||
if (!func_) { UI.toast('Select a system function', 'warning'); return; }
|
||||
if (!cron) { UI.toast('Schedule (cron) is required', 'warning'); return; }
|
||||
|
||||
try {
|
||||
await API._post('/api/v1/tasks', {
|
||||
name: name,
|
||||
task_type: 'system',
|
||||
system_function: func_,
|
||||
schedule: cron,
|
||||
timezone: tz,
|
||||
scope: 'global',
|
||||
output_mode: 'channel',
|
||||
});
|
||||
UI.toast('System task created', 'success');
|
||||
showTab('list');
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to create system task', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
sb.register('_loadAdminTasks', async function() {
|
||||
var container = document.getElementById('adminDynamic');
|
||||
if (!container) return;
|
||||
@@ -244,6 +343,7 @@
|
||||
|
||||
document.getElementById('taskTabList').addEventListener('click', function() { showTab('list'); loadTasks(); });
|
||||
document.getElementById('taskTabConfig').addEventListener('click', function() { showTab('config'); loadConfig(); });
|
||||
document.getElementById('taskTabSystem').addEventListener('click', function() { showTab('system'); loadSystemFuncs(); });
|
||||
document.getElementById('taskRunBackBtn').addEventListener('click', function() { showTab('list'); });
|
||||
document.getElementById('taskKillBtn').addEventListener('click', killRun);
|
||||
document.getElementById('taskCfgSaveBtn').addEventListener('click', saveConfig);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ label: 'Every hour', cron: '0 * * * *' },
|
||||
{ label: 'Weekly (Monday 9am)', cron: '0 9 * * 1' },
|
||||
{ label: 'Monthly (1st midnight)', cron: '0 0 1 * *' },
|
||||
{ label: 'Webhook trigger', cron: 'webhook' },
|
||||
{ label: 'Custom...', cron: '' },
|
||||
];
|
||||
|
||||
@@ -73,12 +74,16 @@
|
||||
html += tasks.map(function(t) {
|
||||
var status = t.is_active ? '<span class="badge badge-success">active</span>' : '<span class="badge badge-muted">paused</span>';
|
||||
var teamBadge = t._source === 'team' ? '<span class="badge" style="background:var(--accent-dim);color:var(--accent)">' + esc(t._teamName || 'team') + '</span>' : '';
|
||||
var sched = t.schedule === 'once' ? 'One-shot' : t.schedule;
|
||||
var sched = t.schedule === 'once' ? 'One-shot' : t.schedule === 'webhook' ? 'Webhook trigger' : t.schedule;
|
||||
var lastStatus = '';
|
||||
if (t.last_run_at) {
|
||||
lastStatus = ' \u00b7 last: ' + new Date(t.last_run_at).toLocaleString();
|
||||
}
|
||||
var nextRun = t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var nextRun = t.schedule === 'webhook' ? 'on trigger' : t.next_run_at ? new Date(t.next_run_at).toLocaleString() : '\u2014';
|
||||
var triggerBtn = '';
|
||||
if (t.schedule === 'webhook' && t.trigger_token) {
|
||||
triggerBtn = '<button class="btn-small task-trigger-btn" data-token="' + esc(t.trigger_token) + '" title="Copy Trigger URL">\ud83d\udd17</button>';
|
||||
}
|
||||
|
||||
return '<div class="settings-section" style="padding:12px 16px;margin-bottom:8px">' +
|
||||
'<div style="display:flex;align-items:center;gap:12px">' +
|
||||
@@ -89,6 +94,7 @@
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:6px;align-items:center">' +
|
||||
teamBadge + status +
|
||||
triggerBtn +
|
||||
'<button class="btn-small task-run-btn" data-id="' + t.id + '" title="Run Now">\u25b6</button>' +
|
||||
'<button class="btn-small task-toggle-btn" data-id="' + t.id + '" data-active="' + t.is_active + '" title="' + (t.is_active ? 'Pause' : 'Resume') + '">' + (t.is_active ? '\u23f8' : '\u25b6') + '</button>' +
|
||||
'<button class="btn-small btn-danger task-del-btn" data-id="' + t.id + '" title="Delete">\u2715</button>' +
|
||||
@@ -123,6 +129,16 @@
|
||||
mount.querySelectorAll('.task-del-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() { deleteTask(btn.dataset.id); });
|
||||
});
|
||||
mount.querySelectorAll('.task-trigger-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var url = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + btn.dataset.token;
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
UI.toast('Trigger URL copied', 'success');
|
||||
}).catch(function() {
|
||||
prompt('Trigger URL:', url);
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
mount.innerHTML = '<p style="color:var(--danger)">Failed to load tasks: ' + esc(err.message) + '</p>';
|
||||
}
|
||||
@@ -164,6 +180,17 @@
|
||||
'<div class="form-group" style="flex:1" id="taskWebhookGroup" style="display:none"><label>Webhook URL</label>' +
|
||||
'<input type="text" id="taskWebhookURL" style="width:100%" placeholder="https://..."></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group" style="margin-top:8px" id="taskWebhookSecretGroup" style="display:none"><label>Webhook Secret (HMAC signing)</label>' +
|
||||
'<input type="text" id="taskWebhookSecret" style="width:100%" placeholder="auto-generated if empty">' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin:2px 0 0">Used to sign outbound webhook payloads. Leave blank for auto-generated secret.</p>' +
|
||||
'</div>' +
|
||||
'<div id="taskWebhookTriggerInfo" style="display:none;margin-top:12px;padding:12px;background:var(--bg-raised);border-radius:var(--radius);border:1px solid var(--border)">' +
|
||||
'<div style="font-weight:600;font-size:12px;margin-bottom:4px">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>' +
|
||||
' Webhook-triggered task</div>' +
|
||||
'<p style="font-size:12px;color:var(--text-2);margin:0;line-height:1.5">This task runs when an external service sends a POST request to its trigger URL. ' +
|
||||
'The trigger URL will be shown after creation — copy it into your CI, cron, or another task\\u2019s webhook URL for task-to-task chaining.</p>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="gap:12px;margin-top:8px">' +
|
||||
'<div class="form-group" style="flex:1"><label>Max Tokens</label>' +
|
||||
'<input type="number" id="taskMaxTokens" value="" style="width:100%" placeholder="default"></div>' +
|
||||
@@ -183,19 +210,28 @@
|
||||
// Toggle custom cron visibility
|
||||
var preset = document.getElementById('taskPreset');
|
||||
var customGroup = document.getElementById('taskCustomCronGroup');
|
||||
var triggerInfo = document.getElementById('taskWebhookTriggerInfo');
|
||||
preset.addEventListener('change', function() {
|
||||
customGroup.style.display = preset.value === '' ? '' : 'none';
|
||||
if (preset.value !== '') document.getElementById('taskCron').value = preset.value;
|
||||
var isWebhook = preset.value === 'webhook';
|
||||
var isCustom = preset.value === '';
|
||||
customGroup.style.display = isCustom ? '' : 'none';
|
||||
triggerInfo.style.display = isWebhook ? '' : 'none';
|
||||
if (!isWebhook && !isCustom) document.getElementById('taskCron').value = preset.value;
|
||||
if (isWebhook) document.getElementById('taskCron').value = 'webhook';
|
||||
});
|
||||
// Initial state
|
||||
customGroup.style.display = preset.value === '' ? '' : 'none';
|
||||
if (preset.value !== '') document.getElementById('taskCron').value = preset.value;
|
||||
triggerInfo.style.display = preset.value === 'webhook' ? '' : 'none';
|
||||
if (preset.value !== '' && preset.value !== 'webhook') document.getElementById('taskCron').value = preset.value;
|
||||
|
||||
// Toggle webhook URL visibility
|
||||
// Toggle webhook URL and secret visibility
|
||||
var output = document.getElementById('taskOutput');
|
||||
var webhookGroup = document.getElementById('taskWebhookGroup');
|
||||
var webhookSecretGroup = document.getElementById('taskWebhookSecretGroup');
|
||||
output.addEventListener('change', function() {
|
||||
webhookGroup.style.display = output.value === 'webhook' ? '' : 'none';
|
||||
var isWH = output.value === 'webhook';
|
||||
webhookGroup.style.display = isWH ? '' : 'none';
|
||||
webhookSecretGroup.style.display = isWH ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -227,12 +263,39 @@
|
||||
if (payload.output_mode === 'webhook' && webhookURL) {
|
||||
payload.webhook_url = webhookURL;
|
||||
}
|
||||
var webhookSecret = document.getElementById('taskWebhookSecret')?.value;
|
||||
if (payload.output_mode === 'webhook' && webhookSecret) {
|
||||
payload.webhook_secret = webhookSecret;
|
||||
}
|
||||
|
||||
try {
|
||||
await API._post('/api/v1/tasks', payload);
|
||||
var created = await API._post('/api/v1/tasks', payload);
|
||||
UI.toast('Task created', 'success');
|
||||
|
||||
// Show trigger URL for webhook-scheduled tasks
|
||||
if (created.schedule === 'webhook' && created.trigger_token) {
|
||||
var triggerURL = location.origin + (window.__BASE__ || '') + '/api/v1/hooks/t/' + created.trigger_token;
|
||||
UI.modal('Webhook Trigger URL',
|
||||
'<p style="font-size:12px;color:var(--text-2);margin-bottom:8px">POST to this URL to trigger the task. ' +
|
||||
'You can use this in CI pipelines, cron jobs, or as another task\\u2019s webhook URL for chaining.</p>' +
|
||||
'<div style="display:flex;gap:8px;align-items:center">' +
|
||||
'<input type="text" readonly value="' + esc(triggerURL) + '" style="flex:1;font-family:var(--mono);font-size:11px" id="_triggerURLInput">' +
|
||||
'<button class="btn-small btn-primary" id="_copyTriggerBtn">Copy</button>' +
|
||||
'</div>' +
|
||||
'<p style="font-size:11px;color:var(--text-3);margin-top:8px">The request body is passed to the task as trigger_payload. ' +
|
||||
'For task-to-task chaining: set Task A\\u2019s webhook_url to Task B\\u2019s trigger URL.</p>',
|
||||
[{ label: 'Done', variant: 'primary' }]
|
||||
);
|
||||
setTimeout(function() {
|
||||
var copyBtn = document.getElementById('_copyTriggerBtn');
|
||||
if (copyBtn) copyBtn.addEventListener('click', function() {
|
||||
var input = document.getElementById('_triggerURLInput');
|
||||
if (input) { navigator.clipboard.writeText(input.value); UI.toast('Copied', 'success'); }
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
loadTaskList();
|
||||
// Refresh sidebar
|
||||
if (typeof TaskSidebar !== 'undefined') TaskSidebar.refresh();
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to create task', 'error');
|
||||
|
||||
@@ -11,7 +11,7 @@ const ADMIN_SECTIONS = {
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
workflows: ['workflows', 'tasks'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
|
||||
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces', 'broadcast'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ const ADMIN_LABELS = {
|
||||
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
workflows: 'Workflows', tasks: 'Tasks',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
|
||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces', broadcast: 'Broadcast',
|
||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ const ADMIN_LOADERS = {
|
||||
channels: () => UI.loadAdminChannels(),
|
||||
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
|
||||
tasks: () => typeof _loadAdminTasks === 'function' ? _loadAdminTasks() : null,
|
||||
broadcast: () => typeof _loadAdminBroadcast === 'function' ? _loadAdminBroadcast() : null,
|
||||
};
|
||||
|
||||
// Find which category a section belongs to
|
||||
|
||||
@@ -709,6 +709,7 @@ const UI = {
|
||||
renderMessages(messages) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
if (!messages || messages.length === 0) {
|
||||
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
@@ -719,7 +720,30 @@ const UI = {
|
||||
if (_isSummaryMessage(messages[i])) summaryIdx = i;
|
||||
}
|
||||
|
||||
// Render: if summary exists, show collapsed-history toggle + summary + messages after
|
||||
// v0.28.6: Virtual scroll for long conversations
|
||||
if (typeof VirtualScroll !== 'undefined' && el) {
|
||||
// Lazy bind — only once per container
|
||||
if (!VirtualScroll._container) {
|
||||
const self = this;
|
||||
const postRender = (container) => {
|
||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(container);
|
||||
if (typeof loadAuthImages === 'function') loadAuthImages(container);
|
||||
};
|
||||
VirtualScroll.bind(
|
||||
el,
|
||||
(msg, idx, dimmed) => self._messageHTML(msg, idx, dimmed),
|
||||
(msg) => self._summaryHTML(msg),
|
||||
postRender
|
||||
);
|
||||
}
|
||||
if (VirtualScroll.setMessages(messages, summaryIdx)) {
|
||||
this._scrollToBottom(true);
|
||||
return; // VirtualScroll handled it
|
||||
}
|
||||
// Falls through for short conversations
|
||||
}
|
||||
|
||||
// Original path for short conversations
|
||||
let html = '';
|
||||
if (summaryIdx >= 0) {
|
||||
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
|
||||
|
||||
277
src/js/virtual-scroll.js
Normal file
277
src/js/virtual-scroll.js
Normal file
@@ -0,0 +1,277 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Virtual Scroll
|
||||
// ==========================================
|
||||
// Viewport-windowed message rendering for long conversations.
|
||||
// Renders a window of messages around the viewport. As the user
|
||||
// scrolls, older/newer chunks are rendered on demand and distant
|
||||
// messages are removed from the DOM to keep memory bounded.
|
||||
//
|
||||
// Design: sentinel-based (IntersectionObserver on top/bottom markers)
|
||||
// rather than scroll-listener based. No jank, no debounce.
|
||||
//
|
||||
// Integration: wraps UI._messageHTML() — no rendering duplication.
|
||||
// The container element (#chatMessages) is unchanged.
|
||||
//
|
||||
// Streaming: the actively-streaming message is always in DOM at the
|
||||
// bottom, managed by streamResponse(). VirtualScroll leaves it alone.
|
||||
|
||||
const WINDOW_SIZE = 80; // max messages in DOM at once
|
||||
const BUFFER = 20; // render this many beyond viewport edge
|
||||
const PREPEND_CHUNK = 40; // load this many when scrolling up
|
||||
const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long
|
||||
|
||||
class VirtualScroll {
|
||||
constructor() {
|
||||
this._messages = []; // full message array (data, not DOM)
|
||||
this._renderFn = null; // (msg, index, dimmed) => html string
|
||||
this._summaryFn = null; // (msg) => html string
|
||||
this._postRenderFn = null; // (container) => void
|
||||
this._container = null;
|
||||
this._observer = null;
|
||||
this._topSentinel = null;
|
||||
this._renderedRange = { start: 0, end: 0 }; // indices into _messages
|
||||
this._enabled = false;
|
||||
this._scrollLock = false; // prevent observer re-entry during DOM mutation
|
||||
this._summaryIdx = -1;
|
||||
}
|
||||
|
||||
// ── Configuration ────────────────────────
|
||||
|
||||
/**
|
||||
* Bind to a container and rendering functions.
|
||||
* @param {HTMLElement} container - the #chatMessages element
|
||||
* @param {Function} renderFn - (msg, index, dimmed?) => html string
|
||||
* @param {Function} summaryFn - (msg) => html string
|
||||
* @param {Function} postRenderFn - (container) => void (extensions, images)
|
||||
*/
|
||||
bind(container, renderFn, summaryFn, postRenderFn) {
|
||||
this._container = container;
|
||||
this._renderFn = renderFn;
|
||||
this._summaryFn = summaryFn;
|
||||
this._postRenderFn = postRenderFn;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────
|
||||
|
||||
/**
|
||||
* Set messages and render. Called by renderMessages().
|
||||
* For short conversations, returns false (caller should use
|
||||
* the original innerHTML path). For long ones, renders the
|
||||
* tail window and returns true.
|
||||
*/
|
||||
setMessages(messages, summaryIdx) {
|
||||
this._messages = messages;
|
||||
this._summaryIdx = summaryIdx;
|
||||
|
||||
if (!this._container || !this._renderFn) return false;
|
||||
|
||||
// Only virtualize long conversations
|
||||
const visible = messages.filter(m => m.role !== 'system');
|
||||
if (visible.length < LOAD_MORE_THRESHOLD) {
|
||||
this._teardown();
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this._enabled = true;
|
||||
this._renderTail();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether virtual scroll is currently active.
|
||||
*/
|
||||
get active() { return this._enabled; }
|
||||
|
||||
/**
|
||||
* Clean up observers. Call when switching conversations.
|
||||
*/
|
||||
destroy() {
|
||||
this._teardown();
|
||||
this._messages = [];
|
||||
this._enabled = false;
|
||||
}
|
||||
|
||||
// ── Internal: Render ─────────────────────
|
||||
|
||||
/**
|
||||
* Render the tail (most recent) window of messages.
|
||||
* This is the initial render — user sees the latest messages.
|
||||
*/
|
||||
_renderTail() {
|
||||
const msgs = this._filteredMessages();
|
||||
const total = msgs.length;
|
||||
const start = Math.max(0, total - WINDOW_SIZE);
|
||||
const end = total;
|
||||
|
||||
this._renderedRange = { start, end };
|
||||
this._buildDOM(msgs, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend older messages when user scrolls to top.
|
||||
*/
|
||||
_prependChunk() {
|
||||
if (this._scrollLock) return;
|
||||
const msgs = this._filteredMessages();
|
||||
const { start } = this._renderedRange;
|
||||
if (start <= 0) return; // already showing all
|
||||
|
||||
this._scrollLock = true;
|
||||
|
||||
const newStart = Math.max(0, start - PREPEND_CHUNK);
|
||||
const container = this._container;
|
||||
|
||||
// Remember scroll position to restore after prepend
|
||||
const scrollBottom = container.scrollHeight - container.scrollTop;
|
||||
|
||||
// Build HTML for the new chunk
|
||||
let html = '';
|
||||
for (let i = newStart; i < start; i++) {
|
||||
html += this._renderOne(msgs[i], i);
|
||||
}
|
||||
|
||||
// Insert after top sentinel, before existing messages
|
||||
const sentinel = this._topSentinel;
|
||||
if (sentinel) {
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
sentinel.after(frag);
|
||||
}
|
||||
|
||||
// Trim bottom if DOM is too large
|
||||
const totalRendered = this._renderedRange.end - newStart;
|
||||
if (totalRendered > WINDOW_SIZE + BUFFER) {
|
||||
const excess = totalRendered - WINDOW_SIZE;
|
||||
this._removeFromBottom(excess);
|
||||
this._renderedRange.end -= excess;
|
||||
}
|
||||
|
||||
this._renderedRange.start = newStart;
|
||||
|
||||
// Restore scroll position (prevent jump)
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight - scrollBottom;
|
||||
if (this._postRenderFn) this._postRenderFn(container);
|
||||
this._scrollLock = false;
|
||||
this._updateSentinelState();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal: DOM ────────────────────────
|
||||
|
||||
_buildDOM(msgs, start, end) {
|
||||
const container = this._container;
|
||||
|
||||
let html = '';
|
||||
|
||||
// Top sentinel (triggers prepend on intersection)
|
||||
html += '<div id="vs-top-sentinel" class="vs-sentinel" style="height:1px"></div>';
|
||||
|
||||
// "Load more" indicator
|
||||
if (start > 0) {
|
||||
html += '<div class="vs-load-more" id="vs-load-more" style="text-align:center;padding:12px 0">' +
|
||||
'<span style="font-size:12px;color:var(--text-3)">' +
|
||||
start + ' earlier messages</span></div>';
|
||||
}
|
||||
|
||||
// Summary boundary
|
||||
if (this._summaryIdx >= 0) {
|
||||
const summaryMsg = this._messages[this._summaryIdx];
|
||||
if (summaryMsg && this._summaryFn) {
|
||||
html += this._summaryFn(summaryMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
for (let i = start; i < end; i++) {
|
||||
html += this._renderOne(msgs[i], i);
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Set up intersection observer on top sentinel
|
||||
this._setupObserver();
|
||||
|
||||
if (this._postRenderFn) this._postRenderFn(container);
|
||||
}
|
||||
|
||||
_renderOne(msg, index) {
|
||||
if (!msg) return '';
|
||||
// Skip system messages and summary messages (summary handled separately)
|
||||
if (msg.role === 'system') return '';
|
||||
if (msg._isSummary) return '';
|
||||
const dimmed = this._summaryIdx >= 0 && index < this._summaryIdx;
|
||||
return this._renderFn(msg, index, dimmed);
|
||||
}
|
||||
|
||||
_removeFromBottom(count) {
|
||||
const container = this._container;
|
||||
const messages = container.querySelectorAll('.message');
|
||||
const toRemove = Array.from(messages).slice(-count);
|
||||
toRemove.forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// ── Observer ─────────────────────────────
|
||||
|
||||
_setupObserver() {
|
||||
this._teardownObserver();
|
||||
|
||||
this._topSentinel = this._container.querySelector('#vs-top-sentinel');
|
||||
if (!this._topSentinel) return;
|
||||
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && entry.target.id === 'vs-top-sentinel') {
|
||||
this._prependChunk();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: this._container,
|
||||
rootMargin: '200px 0px 0px 0px', // trigger 200px before reaching top
|
||||
});
|
||||
|
||||
this._observer.observe(this._topSentinel);
|
||||
}
|
||||
|
||||
_updateSentinelState() {
|
||||
const loadMore = this._container.querySelector('#vs-load-more');
|
||||
if (loadMore) {
|
||||
if (this._renderedRange.start <= 0) {
|
||||
loadMore.style.display = 'none';
|
||||
} else {
|
||||
loadMore.querySelector('span').textContent =
|
||||
this._renderedRange.start + ' earlier messages';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_teardownObserver() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
}
|
||||
this._topSentinel = null;
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
this._teardownObserver();
|
||||
this._renderedRange = { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns messages with summary detection metadata.
|
||||
* Mirrors the filtering logic from renderMessages().
|
||||
*/
|
||||
_filteredMessages() {
|
||||
return this._messages.filter(m => m.role !== 'system');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton ────────────────────────────
|
||||
|
||||
const virtualScroll = new VirtualScroll();
|
||||
|
||||
window.VirtualScroll = virtualScroll;
|
||||
sb.register('VirtualScroll', virtualScroll);
|
||||
Reference in New Issue
Block a user