Changeset 0.20.0 (#85)
This commit is contained in:
210
src/css/channel-models.css
Normal file
210
src/css/channel-models.css
Normal 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;
|
||||
}
|
||||
}
|
||||
64
src/css/notification-prefs.css
Normal file
64
src/css/notification-prefs.css
Normal 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
249
src/css/notifications.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
340
src/js/channel-models.js
Normal 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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
163
src/js/notification-prefs.js
Normal file
163
src/js/notification-prefs.js
Normal 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
431
src/js/notifications.js
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user