Changeset 0.28.6 (#192)

This commit is contained in:
2026-03-15 01:33:38 +00:00
parent 6f0ad1355c
commit bffda043db
59 changed files with 3022 additions and 77 deletions

96
src/js/broadcast-admin.js Normal file
View 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';
}
});
});

View File

@@ -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();

View 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);

View File

@@ -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);

View File

@@ -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');

View File

@@ -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

View File

@@ -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
View 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);