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

210
src/css/channel-models.css Normal file
View File

@@ -0,0 +1,210 @@
/* ==========================================
Channel Models — v0.20.0 Phase 2
Model pills, @mention autocomplete, add dialog
========================================== */
/* ── Model Pills (chat header) ───────────── */
.ch-model-pills {
display: none; /* shown by JS when roster > 1 */
align-items: center;
gap: 4px;
padding: 0 8px;
flex-shrink: 1;
overflow-x: auto;
scrollbar-width: none;
}
.ch-model-pills::-webkit-scrollbar { display: none; }
.ch-model-pill {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 8px;
border-radius: 12px;
background: var(--bg-secondary, #f0f0f0);
border: 1px solid var(--border-color, #ddd);
font-size: 0.75rem;
white-space: nowrap;
cursor: default;
transition: background 0.15s;
}
.ch-model-pill.ch-model-default {
background: var(--accent-bg, #e8f0fe);
border-color: var(--accent-color, #4a8af4);
}
.ch-model-pill-name {
cursor: pointer;
user-select: none;
}
.ch-model-pill-name:hover {
text-decoration: underline;
}
.ch-model-pill-remove {
background: none;
border: none;
cursor: pointer;
font-size: 0.65rem;
color: var(--text-secondary, #888);
padding: 0 2px;
line-height: 1;
opacity: 0.6;
transition: opacity 0.15s;
}
.ch-model-pill-remove:hover {
opacity: 1;
color: var(--error-color, #d32f2f);
}
.ch-model-add-btn {
background: none;
border: 1px dashed var(--border-color, #ccc);
border-radius: 12px;
padding: 2px 8px;
font-size: 0.75rem;
cursor: pointer;
color: var(--text-secondary, #888);
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.ch-model-add-btn:hover {
color: var(--text-primary, #333);
border-color: var(--text-primary, #333);
}
/* ── Add Model Dialog ────────────────────── */
.ch-model-add-dialog {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.4);
z-index: 1100;
}
.ch-model-add-inner {
background: var(--bg-primary, #fff);
border-radius: 12px;
padding: 24px;
min-width: 320px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
}
.ch-model-add-inner h3 {
margin: 0 0 16px;
font-size: 1rem;
}
.ch-model-add-inner label {
display: block;
margin-bottom: 12px;
font-size: 0.85rem;
color: var(--text-secondary, #666);
}
.ch-model-add-inner select,
.ch-model-add-inner input[type="text"] {
display: block;
width: 100%;
margin-top: 4px;
padding: 8px;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
box-sizing: border-box;
}
.ch-model-add-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
/* ── @mention Autocomplete ───────────────── */
.mention-ac {
position: fixed;
z-index: 1050;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
}
.mention-ac-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
cursor: pointer;
gap: 12px;
transition: background 0.1s;
}
.mention-ac-item:hover,
.mention-ac-item.mention-ac-active {
background: var(--bg-secondary, #f5f5f5);
}
.mention-ac-name {
font-weight: 500;
font-size: 0.85rem;
}
.mention-ac-model {
font-size: 0.75rem;
color: var(--text-secondary, #999);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
/* ── Model Attribution (multi-model messages) ─ */
.msg-model-label {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-secondary, #f0f0f0);
color: var(--text-secondary, #666);
margin-left: 6px;
vertical-align: middle;
}
/* Model separator in multi-model stream */
.model-stream-divider {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0 4px;
font-size: 0.8rem;
color: var(--text-secondary, #888);
font-weight: 500;
}
.model-stream-divider::before,
.model-stream-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-color, #eee);
}
/* ── Mobile ──────────────────────────────── */
@media (max-width: 768px) {
.ch-model-pills {
max-width: 50vw;
}
.ch-model-add-inner {
min-width: auto;
margin: 0 16px;
}
.mention-ac {
left: 8px !important;
right: 8px;
min-width: auto !important;
}
}

View File

@@ -0,0 +1,64 @@
/* ==========================================
Notification Preferences — v0.20.0 Phase 3
========================================== */
.notif-pref-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.notif-pref-table th {
text-align: left;
padding: 6px 8px;
border-bottom: 2px solid var(--border-color, #ddd);
font-size: 0.8rem;
color: var(--text-secondary, #888);
font-weight: 500;
}
.notif-pref-table th:nth-child(2),
.notif-pref-table th:nth-child(3) {
text-align: center;
width: 70px;
}
.notif-pref-table td {
padding: 8px;
border-bottom: 1px solid var(--border-color, #eee);
}
.notif-pref-toggle {
text-align: center;
}
.notif-pref-label {
font-size: 0.85rem;
}
/* ── SMTP Config (Admin panel) ────────────── */
.smtp-config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.smtp-config-grid .form-group {
margin-bottom: 0;
}
.smtp-config-grid .form-group.full-width {
grid-column: 1 / -1;
}
.smtp-test-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.smtp-test-status {
font-size: 0.85rem;
color: var(--text-secondary, #888);
}
.smtp-test-status.success { color: var(--success-color, #2e7d32); }
.smtp-test-status.error { color: var(--error-color, #d32f2f); }
@media (max-width: 600px) {
.smtp-config-grid {
grid-template-columns: 1fr;
}
}

249
src/css/notifications.css Normal file
View File

@@ -0,0 +1,249 @@
/* ==========================================
Notifications (v0.20.0)
========================================== */
/* ── Bell + Badge ──────────────────────────── */
.notif-wrap {
position: relative;
display: flex;
align-items: center;
margin-left: auto;
margin-right: 8px;
}
.notif-bell {
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
color: var(--text-secondary, #888);
display: flex;
align-items: center;
position: relative;
transition: background 0.15s;
}
.notif-bell:hover {
background: var(--hover-bg, rgba(128, 128, 128, 0.1));
color: var(--text-primary, #ddd);
}
.notif-badge {
position: absolute;
top: 0;
right: 0;
min-width: 16px;
height: 16px;
line-height: 16px;
font-size: 10px;
font-weight: 700;
text-align: center;
border-radius: 8px;
background: var(--accent-red, #e55);
color: #fff;
padding: 0 4px;
pointer-events: none;
}
/* ── Dropdown ──────────────────────────────── */
.notif-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
width: 360px;
max-height: 480px;
background: var(--bg-surface, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.notif-dd-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color, #333);
font-weight: 600;
font-size: 13px;
}
.notif-mark-all {
background: none;
border: none;
color: var(--accent, #5b9);
cursor: pointer;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.notif-mark-all:hover {
background: var(--hover-bg, rgba(128, 128, 128, 0.1));
}
.notif-dd-list {
overflow-y: auto;
max-height: 380px;
}
.notif-dd-footer {
padding: 8px 14px;
border-top: 1px solid var(--border-color, #333);
text-align: center;
}
.notif-view-all {
background: none;
border: none;
color: var(--accent, #5b9);
cursor: pointer;
font-size: 12px;
}
.notif-view-all:hover {
text-decoration: underline;
}
/* ── Notification Item ─────────────────────── */
.notif-item,
.notif-panel-item {
display: flex;
align-items: flex-start;
padding: 10px 14px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--border-color, #222);
}
.notif-item:hover,
.notif-panel-item:hover {
background: var(--hover-bg, rgba(128, 128, 128, 0.08));
}
.notif-item.notif-unread,
.notif-panel-item.notif-unread {
background: var(--notif-unread-bg, rgba(91, 187, 153, 0.06));
}
.notif-dot {
flex-shrink: 0;
width: 18px;
font-size: 10px;
margin-top: 3px;
color: var(--accent, #5b9);
}
.notif-item:not(.notif-unread) .notif-dot,
.notif-panel-item:not(.notif-unread) .notif-dot {
color: var(--text-tertiary, #555);
}
.notif-body {
flex: 1;
min-width: 0;
}
.notif-title {
font-size: 13px;
line-height: 1.4;
color: var(--text-primary, #ddd);
}
.notif-detail {
font-size: 12px;
color: var(--text-secondary, #888);
margin-top: 2px;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notif-time {
font-size: 11px;
color: var(--text-tertiary, #666);
margin-top: 3px;
}
.notif-empty {
padding: 32px 14px;
text-align: center;
color: var(--text-secondary, #888);
font-size: 13px;
}
/* ── Panel ─────────────────────────────────── */
.notif-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.notif-panel-toolbar {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #333);
}
.notif-panel-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.notif-panel-row {
display: flex;
align-items: flex-start;
width: 100%;
}
.notif-delete {
flex-shrink: 0;
background: none;
border: none;
color: var(--text-tertiary, #555);
cursor: pointer;
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.notif-panel-item:hover .notif-delete {
opacity: 1;
}
.notif-delete:hover {
color: var(--accent-red, #e55);
background: var(--hover-bg, rgba(128, 128, 128, 0.1));
}
.notif-panel-more {
padding: 8px 12px;
text-align: center;
border-top: 1px solid var(--border-color, #333);
}
/* ── Mobile ────────────────────────────────── */
@media (max-width: 768px) {
.notif-dropdown {
position: fixed;
top: 48px;
left: 8px;
right: 8px;
width: auto;
max-height: calc(100vh - 96px);
}
}

View File

@@ -21,6 +21,9 @@
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="css/memory.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="css/notifications.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="css/channel-models.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="css/notification-prefs.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
</head>
<body>
@@ -136,7 +139,15 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<div class="model-caps" id="modelCaps"></div>
<div class="ch-model-pills" id="channelModelPills"></div>
<span class="chat-token-count" id="chatTokenCount"></span>
<div class="notif-wrap" id="notifWrap">
<button class="notif-bell" id="notifBell" onclick="Notifications.toggleDropdown()" title="Notifications">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
</button>
<div class="notif-dropdown" id="notifDropdown"></div>
</div>
</div>
<div class="messages" id="chatMessages">
@@ -394,6 +405,7 @@
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
<button class="settings-tab" data-stab="knowledgeBases" onclick="UI.switchSettingsTab('knowledgeBases')" id="settingsKBTabBtn" style="display:none">Knowledge</button>
<button class="settings-tab" data-stab="memory" onclick="UI.switchSettingsTab('memory')" id="settingsMemoryTabBtn">Memory</button>
<button class="settings-tab" data-stab="notifPrefs" onclick="UI.switchSettingsTab('notifPrefs')">Notifications</button>
</div>
<div class="modal-body">
<!-- General Tab -->
@@ -565,6 +577,14 @@
<div id="settingsMemoryContent"></div>
</section>
</div>
<!-- Notification Preferences Tab (v0.20.0 Phase 3) -->
<div class="settings-tab-content" id="settingsNotifPrefsTab" style="display:none">
<section class="settings-section">
<h3>Delivery Preferences</h3>
<p class="section-hint" style="margin-bottom:8px">Choose how you receive notifications. Email delivery requires admin SMTP configuration.</p>
<div id="notifPrefsBody"></div>
</section>
</div>
</div>
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
</div>
@@ -935,6 +955,30 @@
<h4>🔐 Encryption</h4>
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
</section>
<section class="settings-section">
<h3>Email Notifications (SMTP)</h3>
<label class="checkbox-label"><input type="checkbox" id="adminEmailEnabled"> Enable email notifications</label>
<div id="smtpConfigFields" style="display:none">
<div class="smtp-config-grid" style="margin-top:12px">
<div class="form-group"><label>SMTP Host</label><input type="text" id="adminSmtpHost" placeholder="smtp.example.com"></div>
<div class="form-group"><label>Port</label><input type="number" id="adminSmtpPort" value="587" min="1" max="65535" style="width:100px"></div>
<div class="form-group"><label>Username</label><input type="text" id="adminSmtpUser" placeholder="user@example.com"></div>
<div class="form-group"><label>Password</label><input type="password" id="adminSmtpPassword" placeholder="••••••••"></div>
<div class="form-group"><label>From Address</label><input type="text" id="adminSmtpFrom" placeholder="noreply@chat.example.com"></div>
<div class="form-group"><label>TLS Mode</label>
<select id="adminSmtpTls">
<option value="false">STARTTLS (port 587)</option>
<option value="true">Implicit TLS (port 465)</option>
</select>
</div>
</div>
<div class="smtp-test-row">
<button class="btn-small" id="adminSmtpTestBtn" onclick="NotifPrefs._testEmail()">Send Test Email</button>
<span class="smtp-test-status" id="smtpTestStatus"></span>
</div>
<p class="section-hint" style="margin-top:8px">Test sends to your account's email address. Save settings first, then test.</p>
</div>
</section>
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
</div>
<div class="admin-section-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
@@ -1131,6 +1175,9 @@
<script src="js/memory-ui.js?v=%%APP_VERSION%%"></script>
<script src="js/persona-kb.js?v=%%APP_VERSION%%"></script>
<script src="js/projects-ui.js?v=%%APP_VERSION%%"></script>
<script src="js/channel-models.js?v=%%APP_VERSION%%"></script>
<script src="js/notification-prefs.js?v=%%APP_VERSION%%"></script>
<script src="js/notifications.js?v=%%APP_VERSION%%"></script>
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>

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