Changeset 0.20.0 (#85)

This commit is contained in:
2026-03-01 12:40:15 +00:00
parent eb74180611
commit 817062e5fc
57 changed files with 5435 additions and 72 deletions

View File

@@ -161,6 +161,18 @@ const API = {
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
// Channel models (v0.20.0 — multi-model @mention routing)
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
addChannelModel(channelId, data) { return this._post(`/api/v1/channels/${channelId}/models`, data); },
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
// Notification preferences (v0.20.0 Phase 3)
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
deleteNotifPref(type) { return this._del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`); },
adminTestEmail() { return this._post('/api/v1/admin/notifications/test-email', {}); },
// Projects (v0.19.0)
listProjects(includeArchived) {
const q = includeArchived ? '?include_archived=true' : '';
@@ -748,6 +760,7 @@ const API = {
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _patch(path, body) { return this._authed(path, 'PATCH', body); },
async _authed(path, method = 'GET', body) {
try {

View File

@@ -431,6 +431,7 @@ function initListeners() {
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initSidePanelResize(); // from panels.js
_initDualDivider(); // from panels.js — dual-view split drag

340
src/js/channel-models.js Normal file
View File

@@ -0,0 +1,340 @@
// ==========================================
// Chat Switchboard Channel Models (v0.20.0)
// ==========================================
// Multi-model management per channel.
// - Model pills in chat header (add/remove/set default)
// - @mention autocomplete in chat input
// - Model attribution on assistant messages
// ==========================================
const ChannelModels = {
_roster: [], // current channel's model roster
_channelId: null, // current channel ID
_acVisible: false, // autocomplete dropdown visible
// ── Init / Lifecycle ────────────────────
init() {
// Close autocomplete on click-outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.mention-ac')) {
this.hideAutocomplete();
}
});
},
// ── Roster Management ───────────────────
async load(channelId) {
this._channelId = channelId;
if (!channelId) {
this._roster = [];
this.renderPills();
return;
}
try {
const roster = await API.listChannelModels(channelId);
this._roster = Array.isArray(roster) ? roster : [];
} catch (e) {
console.debug('Channel models not loaded:', e.message);
this._roster = [];
}
this.renderPills();
},
getRoster() { return this._roster; },
getDefault() {
return this._roster.find(m => m.is_default) || this._roster[0] || null;
},
// ── Model Pills UI ──────────────────────
renderPills() {
const container = document.getElementById('channelModelPills');
if (!container) return;
if (this._roster.length <= 1) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = '';
const html = this._roster.map(m => `
<span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}"
data-model-id="${esc(m.id)}" title="${esc(m.model_id)}">
<span class="ch-model-pill-name" onclick="ChannelModels.setDefault('${esc(m.id)}')">${esc(m.display_name)}</span>
<button class="ch-model-pill-remove" onclick="ChannelModels.remove('${esc(m.id)}')" title="Remove model">✕</button>
</span>
`).join('') + `
<button class="ch-model-add-btn" onclick="ChannelModels.showAddDialog()" title="Add model to channel">+ Add</button>
`;
container.innerHTML = html;
},
async remove(recordId) {
if (!this._channelId) return;
try {
const resp = await API.deleteChannelModel(this._channelId, recordId);
this._roster = resp.models || [];
this.renderPills();
} catch (e) {
UI.toast('Failed to remove model: ' + e.message, 'error');
}
},
async setDefault(recordId) {
if (!this._channelId) return;
try {
const resp = await API.updateChannelModel(this._channelId, recordId, { is_default: true });
this._roster = resp.models || [];
this.renderPills();
} catch (e) {
UI.toast('Failed to set default: ' + e.message, 'error');
}
},
// ── Add Model Dialog ────────────────────
showAddDialog() {
if (!App.models || App.models.length === 0) {
UI.toast('No models available — configure a provider first', 'warning');
return;
}
// Filter out models already in the roster
const rosterModelIds = new Set(this._roster.map(m => m.model_id));
const available = App.models.filter(m => !m.isPreset && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
if (available.length === 0) {
UI.toast('All available models are already added to this channel', 'info');
return;
}
const html = `
<div class="ch-model-add-dialog" id="chModelAddDialog">
<div class="ch-model-add-inner">
<h3>Add Model to Channel</h3>
<label>Model
<select id="chModelAddSelect">
${available.map(m => `<option value="${esc(m.baseModelId || m.id)}" data-config="${esc(m.configId || '')}" data-name="${esc(m.name)}">${esc(m.name)}${m.provider ? ` (${esc(m.provider)})` : ''}</option>`).join('')}
</select>
</label>
<label>Display Name (used for @mentions)
<input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50">
</label>
<div class="ch-model-add-actions">
<button class="btn-secondary" onclick="ChannelModels.closeAddDialog()">Cancel</button>
<button class="btn-primary" onclick="ChannelModels.submitAdd()">Add</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
// Auto-fill display name from selected model
const select = document.getElementById('chModelAddSelect');
const nameInput = document.getElementById('chModelAddName');
nameInput.value = _shortName(select.options[0]?.dataset.name || '');
select.addEventListener('change', () => {
nameInput.value = _shortName(select.options[select.selectedIndex]?.dataset.name || '');
});
// Focus and select
nameInput.focus();
nameInput.select();
},
closeAddDialog() {
document.getElementById('chModelAddDialog')?.remove();
},
async submitAdd() {
const select = document.getElementById('chModelAddSelect');
const nameInput = document.getElementById('chModelAddName');
if (!select || !nameInput) return;
const modelId = select.value;
const configId = select.options[select.selectedIndex]?.dataset.config || '';
const displayName = nameInput.value.trim();
if (!displayName) {
UI.toast('Display name is required', 'warning');
return;
}
try {
const resp = await API.addChannelModel(this._channelId, {
model_id: modelId,
provider_config_id: configId,
display_name: displayName,
is_default: this._roster.length === 0
});
this._roster = resp.models || [];
this.renderPills();
this.closeAddDialog();
} catch (e) {
UI.toast('Failed to add model: ' + e.message, 'error');
}
},
// ── @mention Autocomplete ───────────────
/**
* Called from the chat input's keyup/input handler.
* Shows a dropdown of channel models when user types @.
*/
onInput(inputEl) {
if (this._roster.length < 2) return; // no autocomplete for single-model channels
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
// Find the @token being typed (search backward from cursor)
const before = text.slice(0, cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx < 0) { this.hideAutocomplete(); return; }
// @ must be at start or preceded by whitespace
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
this.hideAutocomplete();
return;
}
const partial = before.slice(atIdx + 1).toLowerCase();
const matches = this._roster.filter(m =>
m.display_name.toLowerCase().startsWith(partial) ||
m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' '))
);
if (matches.length === 0) { this.hideAutocomplete(); return; }
this.showAutocomplete(inputEl, atIdx, cursorPos, matches);
},
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
this.hideAutocomplete();
const wrap = document.createElement('div');
wrap.className = 'mention-ac';
wrap.id = 'mentionAc';
// Position relative to input
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
wrap.style.left = inputRect.left + 'px';
wrap.style.minWidth = '200px';
matches.forEach((m, i) => {
const item = document.createElement('div');
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
item.dataset.name = m.display_name;
item.innerHTML = `<span class="mention-ac-name">${esc(m.display_name)}</span><span class="mention-ac-model">${esc(m.model_id)}</span>`;
item.addEventListener('click', () => {
this._insertMention(inputEl, atIdx, cursorPos, m.display_name);
});
wrap.appendChild(item);
});
document.body.appendChild(wrap);
this._acVisible = true;
},
hideAutocomplete() {
document.getElementById('mentionAc')?.remove();
this._acVisible = false;
},
isAutocompleteVisible() { return this._acVisible; },
/**
* Handle arrow keys and Enter when autocomplete is visible.
* Returns true if the event was consumed.
*/
handleKey(e) {
if (!this._acVisible) return false;
const ac = document.getElementById('mentionAc');
if (!ac) return false;
const items = ac.querySelectorAll('.mention-ac-item');
let activeIdx = Array.from(items).findIndex(el => el.classList.contains('mention-ac-active'));
if (e.key === 'ArrowDown') {
e.preventDefault();
items[activeIdx]?.classList.remove('mention-ac-active');
activeIdx = (activeIdx + 1) % items.length;
items[activeIdx]?.classList.add('mention-ac-active');
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
items[activeIdx]?.classList.remove('mention-ac-active');
activeIdx = (activeIdx - 1 + items.length) % items.length;
items[activeIdx]?.classList.add('mention-ac-active');
return true;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const active = items[activeIdx];
if (active) {
active.click();
}
return true;
}
if (e.key === 'Escape') {
this.hideAutocomplete();
return true;
}
return false;
},
_insertMention(inputEl, atIdx, cursorPos, displayName) {
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const before = text.slice(0, atIdx);
const after = text.slice(cursorPos);
const newText = before + '@' + displayName + ' ' + after;
if (typeof inputEl.setValue === 'function') {
inputEl.setValue(newText);
} else {
inputEl.value = newText;
}
// Position cursor after the inserted mention
const newPos = atIdx + 1 + displayName.length + 1;
if (typeof inputEl.setCursorPos === 'function') {
inputEl.setCursorPos(newPos);
} else {
inputEl.selectionStart = inputEl.selectionEnd = newPos;
}
this.hideAutocomplete();
inputEl.focus?.();
},
// ── Model Display Name Resolution ───────
/**
* Resolve a model_id to a display name using the current roster.
* Falls back to App.models lookup, then the raw model_id.
*/
resolveDisplayName(modelId) {
if (!modelId) return 'Assistant';
const cm = this._roster.find(m => m.model_id === modelId);
if (cm?.display_name) return cm.display_name;
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
return m?.name || modelId;
}
};
// ── Helpers ──────────────────────────────────
function _shortName(fullName) {
// "Claude 3 Opus (Anthropic)" → "Claude-3-Opus"
return fullName
.replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)"
.trim()
.replace(/\s+/g, '-'); // spaces → hyphens
}

View File

@@ -37,12 +37,37 @@ const ChatInput = {
return this._textarea;
},
/** Get the visible input element (for positioning, getBoundingClientRect) */
get el() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Get the wrapper DOM element (for resize, etc.) */
getWrapDom() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Get cursor byte offset (for @mention autocomplete) */
getCursorPos() {
if (this._editor) {
const view = this._editor.getView();
return view.state.selection.main.head;
}
return this._textarea?.selectionStart || 0;
},
/** Set cursor byte offset (for @mention autocomplete) */
setCursorPos(pos) {
if (this._editor) {
const view = this._editor.getView();
view.dispatch({ selection: { anchor: pos } });
} else if (this._textarea) {
this._textarea.selectionStart = this._textarea.selectionEnd = pos;
}
},
/** Initialize — try CM6, fall back to textarea */
init() {
this._textarea = document.getElementById('messageInput');
@@ -54,20 +79,35 @@ const ChatInput = {
this._editor = CM.chatInput(wrap, {
placeholder: 'Send a message...',
onSubmit: () => sendMessage(),
onChange: () => updateInputTokens(),
onChange: () => {
updateInputTokens();
// @mention autocomplete (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput);
},
maxHeight: 200,
});
// @mention autocomplete: intercept keys on CM6 contentDOM (v0.20.0)
this._editor.getView().contentDOM.addEventListener('keydown', (e) => {
if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) {
e.preventDefault();
e.stopPropagation();
}
});
DebugLog?.push?.('chat', '[CM6] Chat input initialized');
} else {
// Fallback: textarea with manual event handling
DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback');
this._textarea.addEventListener('keydown', (e) => {
// @mention autocomplete intercepts arrow/enter/escape (v0.20.0)
if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) return;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
this._textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
// @mention autocomplete (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this);
});
}
},
@@ -202,6 +242,9 @@ async function selectChat(chatId) {
// Restore the last-used model/preset for this chat
_restoreChatModel(chatId, chat);
// Load multi-model roster for @mention routing (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chatId);
// Load attachments for message rendering (non-blocking, best-effort)
await loadChannelAttachments(chatId);
@@ -827,6 +870,9 @@ function _initChatListeners() {
// Input — CM6 or textarea fallback
ChatInput.init();
// Multi-model channel roster (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.init();
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {

View File

@@ -0,0 +1,163 @@
/* ─────────────────────────────────────────────
Notification Preferences (v0.20.0 Phase 3)
Settings → Notifications tab
───────────────────────────────────────────── */
// eslint-disable-next-line no-unused-vars
const NotifPrefs = {
_loaded: false,
_prefs: [],
// Known notification types for the UI
_types: [
{ key: 'kb.ready', label: 'Knowledge base ready' },
{ key: 'kb.error', label: 'Knowledge base error' },
{ key: 'role.fallback', label: 'Model fallback' },
{ key: 'grant.changed', label: 'Group membership change' },
{ key: '*', label: 'Default (all others)' },
],
async load() {
try {
this._prefs = await API.listNotifPrefs() || [];
} catch (e) {
console.warn('[notif-prefs] failed to load:', e.message);
this._prefs = [];
}
this._loaded = true;
this.render();
},
render() {
const container = document.getElementById('notifPrefsBody');
if (!container) return;
const prefMap = {};
for (const p of this._prefs) {
prefMap[p.type] = p;
}
let html = '';
for (const t of this._types) {
const p = prefMap[t.key];
const inApp = p ? p.in_app : true;
const email = p ? p.email : false;
html += `<tr class="notif-pref-row" data-type="${esc(t.key)}">
<td class="notif-pref-label">${esc(t.label)}</td>
<td class="notif-pref-toggle">
<label class="checkbox-label">
<input type="checkbox" class="notif-pref-inapp" ${inApp ? 'checked' : ''}>
</label>
</td>
<td class="notif-pref-toggle">
<label class="checkbox-label">
<input type="checkbox" class="notif-pref-email" ${email ? 'checked' : ''}>
</label>
</td>
</tr>`;
}
container.innerHTML = `<table class="notif-pref-table">
<thead><tr>
<th>Notification Type</th>
<th>In-App</th>
<th>Email</th>
</tr></thead>
<tbody>${html}</tbody>
</table>`;
// Attach change listeners
container.querySelectorAll('.notif-pref-row').forEach(row => {
const type = row.dataset.type;
row.querySelector('.notif-pref-inapp').addEventListener('change', (e) => {
this._save(type, { in_app: e.target.checked });
});
row.querySelector('.notif-pref-email').addEventListener('change', (e) => {
this._save(type, { email: e.target.checked });
});
});
},
async _save(type, patch) {
try {
await API.setNotifPref(type, patch);
// Update cache
const existing = this._prefs.find(p => p.type === type);
if (existing) {
Object.assign(existing, patch);
} else {
this._prefs.push({ type, in_app: true, email: false, ...patch });
}
} catch (e) {
UI.toast('Failed to save preference', 'error');
this.render(); // revert checkboxes
}
},
};
// ── Admin SMTP UI helpers ───────────────────
// Called from index.html onclick
// eslint-disable-next-line no-unused-vars
NotifPrefs._testEmail = async function() {
const status = document.getElementById('smtpTestStatus');
const btn = document.getElementById('adminSmtpTestBtn');
if (!status || !btn) return;
btn.disabled = true;
status.textContent = 'Sending…';
status.className = 'smtp-test-status';
try {
const result = await API.adminTestEmail();
status.textContent = `✓ Sent to ${result.sent_to}`;
status.className = 'smtp-test-status success';
} catch (e) {
status.textContent = '✗ ' + (e.message || 'Failed');
status.className = 'smtp-test-status error';
} finally {
btn.disabled = false;
}
};
NotifPrefs._initAdminSmtp = function() {
const toggle = document.getElementById('adminEmailEnabled');
const fields = document.getElementById('smtpConfigFields');
if (!toggle || !fields) return;
toggle.addEventListener('change', () => {
fields.style.display = toggle.checked ? '' : 'none';
});
};
NotifPrefs._loadAdminSmtp = function(cfg) {
if (!cfg) return;
const el = (id) => document.getElementById(id);
if (cfg.email_enabled) el('adminEmailEnabled').checked = true;
if (cfg.smtp_host) el('adminSmtpHost').value = cfg.smtp_host;
if (cfg.smtp_port) el('adminSmtpPort').value = cfg.smtp_port;
if (cfg.smtp_user) el('adminSmtpUser').value = cfg.smtp_user;
// Don't populate password (security)
if (cfg.smtp_from) el('adminSmtpFrom').value = cfg.smtp_from;
if (cfg.smtp_tls != null) el('adminSmtpTls').value = String(cfg.smtp_tls);
// Show/hide SMTP fields
const fields = document.getElementById('smtpConfigFields');
if (fields) fields.style.display = cfg.email_enabled ? '' : 'none';
};
NotifPrefs._getAdminSmtp = function() {
const el = (id) => document.getElementById(id);
const config = {
email_enabled: el('adminEmailEnabled')?.checked || false,
smtp_host: el('adminSmtpHost')?.value?.trim() || '',
smtp_port: parseInt(el('adminSmtpPort')?.value) || 587,
smtp_user: el('adminSmtpUser')?.value?.trim() || '',
smtp_from: el('adminSmtpFrom')?.value?.trim() || '',
smtp_tls: el('adminSmtpTls')?.value === 'true',
};
// Only include password if user typed something new
const pw = el('adminSmtpPassword')?.value;
if (pw) config.smtp_password = pw;
return config;
};

431
src/js/notifications.js Normal file
View File

@@ -0,0 +1,431 @@
// ==========================================
// Chat Switchboard Notifications (v0.20.0)
// ==========================================
// In-app notification bell with dropdown and
// side panel. Real-time push via WebSocket.
//
// Dependencies: API, Events, PanelRegistry, UI
// ==========================================
const Notifications = {
_items: [], // cached notifications (latest first)
_unreadCount: 0,
_loaded: false, // full list fetched at least once
_dropdownOpen: false,
// ── Init ─────────────────────────────────
async init() {
this._bindEvents();
this._registerPanel();
await this._fetchUnreadCount();
},
// ── Data ─────────────────────────────────
async _fetchUnreadCount() {
try {
const data = await API._get('/notifications/unread-count');
this._unreadCount = data.count || 0;
this._renderBadge();
} catch (e) {
// Silently fail — badge just stays at 0
}
},
async _fetchList(opts = {}) {
const limit = opts.limit || 20;
const offset = opts.offset || 0;
const unreadOnly = opts.unreadOnly || false;
try {
const data = await API._get(
`/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
return data;
} catch (e) {
return { data: [], total: 0 };
}
},
async _loadLatest() {
const result = await this._fetchList({ limit: 10 });
this._items = result.data || [];
this._loaded = true;
return result;
},
// ── Badge ────────────────────────────────
_renderBadge() {
const badge = document.getElementById('notifBadge');
if (!badge) return;
if (this._unreadCount > 0) {
badge.textContent = this._unreadCount > 9 ? '9+' : String(this._unreadCount);
badge.style.display = '';
} else {
badge.style.display = 'none';
}
},
// ── Dropdown ─────────────────────────────
async toggleDropdown() {
if (this._dropdownOpen) {
this._closeDropdown();
return;
}
// Lazy-load on first open
if (!this._loaded) {
await this._loadLatest();
}
const dd = document.getElementById('notifDropdown');
if (!dd) return;
dd.innerHTML = this._renderDropdownContent();
dd.style.display = 'block';
this._dropdownOpen = true;
// Click-outside handler
setTimeout(() => {
document.addEventListener('click', this._outsideClickHandler);
}, 0);
},
_closeDropdown() {
const dd = document.getElementById('notifDropdown');
if (dd) dd.style.display = 'none';
this._dropdownOpen = false;
document.removeEventListener('click', this._outsideClickHandler);
},
_outsideClickHandler(e) {
const wrap = document.getElementById('notifWrap');
if (wrap && !wrap.contains(e.target)) {
Notifications._closeDropdown();
}
},
_renderDropdownContent() {
const items = this._items;
if (!items.length) {
return `<div class="notif-empty">No notifications</div>`;
}
let html = `<div class="notif-dd-header">
<span>Notifications</span>
<button class="notif-mark-all" onclick="Notifications.markAllRead()">Mark all ✓</button>
</div><div class="notif-dd-list">`;
for (const n of items.slice(0, 10)) {
const unread = !n.is_read;
const dot = unread ? '●' : '○';
const cls = unread ? 'notif-item notif-unread' : 'notif-item';
const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" onclick="Notifications._onItemClick(this)">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${_esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${_esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
</div>`;
}
html += `</div>`;
html += `<div class="notif-dd-footer">
<button class="notif-view-all" onclick="Notifications.openPanel()">View all →</button>
</div>`;
return html;
},
// ── Item Actions ─────────────────────────
async _onItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
// Mark read
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
}
// Navigate
this._navigate(resType, resId);
this._closeDropdown();
},
_navigate(resourceType, resourceId) {
if (!resourceType || !resourceId) return;
switch (resourceType) {
case 'channel':
if (typeof selectChat === 'function') selectChat(resourceId);
break;
case 'knowledge_base':
// Open admin panel → KB section (if admin)
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('ai', 'knowledge-bases');
}
break;
case 'project':
// Open project detail panel
if (typeof openProjectPanel === 'function') openProjectPanel(resourceId);
break;
case 'group':
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('people', 'groups');
}
break;
}
},
async _markRead(id) {
try {
await API._authed(`/notifications/${id}/read`, 'PATCH');
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === id);
if (item) item.is_read = true;
this._renderBadge();
} catch (e) { /* best effort */ }
},
async markAllRead() {
try {
await API._post('/notifications/mark-all-read');
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._renderBadge();
// Re-render dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Re-render panel if open
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
// ── Side Panel ───────────────────────────
_registerPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotifications';
el.style.display = 'none';
el.innerHTML = `<div class="notif-panel" id="notifPanelInner">
<div class="notif-panel-toolbar">
<button class="btn-small" onclick="Notifications.markAllRead()">Mark all read</button>
<button class="btn-small btn-danger" onclick="Notifications.clearAll()">Clear all</button>
</div>
<div class="notif-panel-list" id="notifPanelList"></div>
<div class="notif-panel-more" id="notifPanelMore" style="display:none">
<button class="btn-small" onclick="Notifications.loadMore()">Load more</button>
</div>
</div>`;
body.appendChild(el);
PanelRegistry.register('notifications', {
element: el,
label: 'Notifications',
onOpen: () => this._onPanelOpen(),
saveState: () => ({ scrollTop: el.querySelector('.notif-panel-list')?.scrollTop || 0 }),
restoreState: (s) => {
const list = el.querySelector('.notif-panel-list');
if (list && s.scrollTop) list.scrollTop = s.scrollTop;
},
});
},
_panelOffset: 0,
_panelTotal: 0,
async _onPanelOpen() {
this._panelOffset = 0;
const result = await this._fetchList({ limit: 30 });
this._panelTotal = result.total || 0;
this._panelOffset = (result.data || []).length;
this._panelItems = result.data || [];
this._renderPanelContent();
},
_panelItems: [],
_renderPanelContent() {
const list = document.getElementById('notifPanelList');
if (!list) return;
if (!this._panelItems.length) {
list.innerHTML = '<div class="notif-empty">No notifications yet</div>';
return;
}
let html = '';
for (const n of this._panelItems) {
const unread = !n.is_read;
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
const dot = unread ? '●' : '○';
const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" onclick="Notifications._onPanelItemClick(this)">
<div class="notif-panel-row">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${_esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${_esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
<button class="notif-delete" onclick="event.stopPropagation(); Notifications._deleteItem('${n.id}')" title="Delete">✕</button>
</div>
</div>`;
}
list.innerHTML = html;
// Show/hide load more
const moreBtn = document.getElementById('notifPanelMore');
if (moreBtn) {
moreBtn.style.display = this._panelOffset < this._panelTotal ? '' : 'none';
}
},
async _onPanelItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
const pItem = this._panelItems.find(n => n.id === id);
if (pItem) pItem.is_read = true;
}
this._navigate(resType, resId);
},
async _deleteItem(id) {
try {
await API._del(`/notifications/${id}`);
// Check if it was unread before removal
const item = this._panelItems.find(n => n.id === id);
if (item && !item.is_read) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
this._renderBadge();
}
this._panelItems = this._panelItems.filter(n => n.id !== id);
this._items = this._items.filter(n => n.id !== id);
this._panelTotal = Math.max(0, this._panelTotal - 1);
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
async loadMore() {
const result = await this._fetchList({ limit: 30, offset: this._panelOffset });
const newItems = result.data || [];
this._panelItems = this._panelItems.concat(newItems);
this._panelOffset += newItems.length;
this._renderPanelContent();
},
async clearAll() {
if (typeof showConfirm === 'function') {
const ok = await showConfirm('Delete all notifications?', 'This cannot be undone.');
if (!ok) return;
}
// Delete visible items (API doesn't have bulk delete, so mark all read and let retention handle it)
await this.markAllRead();
},
openPanel() {
this._closeDropdown();
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('notifications');
}
},
// ── WebSocket Events ─────────────────────
_bindEvents() {
if (typeof Events === 'undefined') return;
// New notification from server
Events.on('notification.new', (payload) => {
// Prepend to cached list
this._items.unshift(payload);
if (this._items.length > 50) this._items.length = 50;
this._unreadCount++;
this._renderBadge();
// Update dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Update panel if it has items
if (this._panelItems.length) {
this._panelItems.unshift(payload);
this._panelTotal++;
this._panelOffset++;
this._renderPanelContent();
}
// Toast for high-priority types
const highPriority = ['kb.error', 'role.fallback'];
if (highPriority.includes(payload.type)) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(payload.title, 'warning');
}
}
});
// Badge sync across tabs (from markRead / markAllRead in another tab)
Events.on('notification.read', (payload) => {
if (payload.action === 'mark_all_read') {
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._panelItems.forEach(n => n.is_read = true);
} else if (payload.id) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === payload.id);
if (item) item.is_read = true;
const pItem = this._panelItems.find(n => n.id === payload.id);
if (pItem) pItem.is_read = true;
}
this._renderBadge();
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
this._renderPanelContent();
});
},
};
// ── Helpers ──────────────────────────────────
function _esc(s) {
if (!s) return '';
const el = document.createElement('span');
el.textContent = s;
return el.innerHTML;
}
function _timeAgo(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
const now = Date.now();
const sec = Math.floor((now - d.getTime()) / 1000);
if (sec < 60) return 'just now';
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
return d.toLocaleDateString();
}

View File

@@ -256,6 +256,12 @@ async function handleSaveAdminSettings() {
}});
await API.adminUpdateSetting('memory_extraction_enabled', { value: memExtractionEnabled });
// Email / SMTP config (v0.20.0 Phase 3)
if (typeof NotifPrefs !== 'undefined' && NotifPrefs._getAdminSmtp) {
const smtpConfig = NotifPrefs._getAdminSmtp();
await API.adminUpdateSetting('notifications', { value: smtpConfig });
}
UI.toast('Settings saved', 'success');
// Live-apply: refresh policies and dependent UI
await initBanners();

View File

@@ -981,6 +981,13 @@ Object.assign(UI, {
const memAutoApprove = document.getElementById('adminMemoryAutoApprove');
if (memAutoApprove) memAutoApprove.checked = !!memExtractionCfg.auto_approve;
// Email / SMTP config (v0.20.0 Phase 3)
const notifCfg = getSetting('notifications', {}) || {};
if (typeof NotifPrefs !== 'undefined') {
NotifPrefs._loadAdminSmtp(notifCfg);
NotifPrefs._initAdminSmtp();
}
if (vaultEl) {
try {
const vault = await API.adminGetVaultStatus();

View File

@@ -598,24 +598,39 @@ const UI = {
const label = modelName || 'Assistant';
const currentModel = App.findModel(App.settings.model);
const streamAvatar = currentModel?.presetAvatar || null;
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
<div class="msg-tools" id="streamTools" style="display:none"></div>
<div class="msg-text" id="streamContent"></div>
</div>
</div>`;
container.appendChild(div);
// Multi-model state: when model_start events arrive, we create
// separate message bubbles per model. Without them, single-bubble.
let multiModel = false;
let activeContentEl = null;
let activeToolsEl = null;
let content = '';
let reasoning = '';
// Create initial single-model bubble (replaced if multi-model)
const _createBubble = (bubbleLabel) => {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">${esc(bubbleLabel)}</span></div>
<div class="msg-tools" style="display:none"></div>
<div class="msg-text"></div>
</div>
</div>`;
container.appendChild(div);
activeContentEl = div.querySelector('.msg-text');
activeToolsEl = div.querySelector('.msg-tools');
return div;
};
_createBubble(label);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
let reasoning = '';
let currentEvent = ''; // SSE event type
while (true) {
@@ -640,16 +655,34 @@ const UI = {
try {
const parsed = JSON.parse(data);
// ── Multi-model: model_start creates a new bubble ──
if (currentEvent === 'model_start') {
if (!multiModel) {
// First model_start: remove the initial empty bubble
multiModel = true;
container.lastElementChild?.remove();
}
content = '';
reasoning = '';
const dn = parsed.display_name || parsed.model_id || 'Model';
_createBubble(dn);
currentEvent = '';
continue;
}
if (currentEvent === 'model_end') {
currentEvent = '';
continue;
}
if (currentEvent === 'tool_use') {
// parsed = array of tool calls
this._renderToolUse(parsed);
this._renderToolUseInEl(activeToolsEl, parsed);
currentEvent = '';
continue;
}
if (currentEvent === 'tool_result') {
// parsed = { tool_call_id, name, content, is_error }
this._renderToolResult(parsed);
this._renderToolResultInEl(activeToolsEl, parsed);
currentEvent = '';
continue;
}
@@ -658,9 +691,8 @@ const UI = {
const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || '';
if (reasoningDelta) {
reasoning += reasoningDelta;
// Render accumulated reasoning + content together
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
this._scrollToBottom();
}
@@ -669,7 +701,7 @@ const UI = {
if (delta) {
content += delta;
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
document.getElementById('streamContent').innerHTML = formatMessage(full);
if (activeContentEl) activeContentEl.innerHTML = formatMessage(full);
this._scrollToBottom();
// Live-update preview panel if open (debounced)
@@ -685,6 +717,43 @@ const UI = {
return content;
},
// Tool rendering helpers that take explicit element refs (for multi-model)
_renderToolUseInEl(toolsEl, toolCalls) {
if (!toolsEl) {
// Fallback to id-based (single model compat)
this._renderToolUse(toolCalls);
return;
}
toolsEl.style.display = '';
for (const tc of toolCalls) {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.dataset.toolId = tc.id;
toolDiv.innerHTML = `
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
<span class="tool-status tool-running">running…</span>`;
toolsEl.appendChild(toolDiv);
}
this._scrollToBottom();
},
_renderToolResultInEl(toolsEl, result) {
if (!toolsEl) {
this._renderToolResult(result);
return;
}
const el = toolsEl.querySelector(`[data-tool-id="${result.tool_call_id}"]`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
statusEl.textContent = result.is_error ? 'error' : 'done';
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
}
}
},
_renderToolUse(toolCalls) {
const el = document.getElementById('streamTools');
if (!el) return;
@@ -693,7 +762,7 @@ const UI = {
const name = tc.function?.name || tc.name || 'unknown';
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-activity';
toolDiv.id = `tool_${tc.id}`;
toolDiv.dataset.toolId = tc.id;
toolDiv.innerHTML = `
<span class="tool-icon">🔧</span>
<span class="tool-name">${esc(name)}</span>
@@ -704,7 +773,8 @@ const UI = {
},
_renderToolResult(result) {
const el = document.getElementById(`tool_${result.tool_call_id}`);
// Search all tool-activity elements by data-tool-id
const el = document.querySelector(`[data-tool-id="${result.tool_call_id}"]`);
if (el) {
const statusEl = el.querySelector('.tool-status');
if (statusEl) {
@@ -1052,6 +1122,11 @@ const UI = {
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Memory UI failed to load.</div>';
}
}
if (tab === 'notifPrefs') {
if (typeof NotifPrefs !== 'undefined') {
NotifPrefs.load();
}
}
},
// ── Export ────────────────────────────────