Changeset 0.37.12 (#224)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -1,247 +0,0 @@
|
||||
/* ==========================================
|
||||
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-raised);
|
||||
border: 1px solid var(--border);
|
||||
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);
|
||||
}
|
||||
.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-3);
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.ch-model-pill-remove:hover {
|
||||
opacity: 1;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ch-model-add-btn {
|
||||
background: none;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.ch-model-add-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* ── 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-surface);
|
||||
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-3);
|
||||
}
|
||||
.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);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text);
|
||||
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-raised);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.mention-ac-item:hover,
|
||||
.mention-ac-item.mention-ac-active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.mention-ac-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.mention-ac-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.mention-ac-handle {
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent, #6c9fff);
|
||||
font-family: var(--mono);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.mention-ac-model {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* ── Autocomplete avatars (v0.23.0) ──────────── */
|
||||
.mention-ac-avatar {
|
||||
width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
.mention-ac-avatar-fallback {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-hover); font-size: 12px; line-height: 1;
|
||||
}
|
||||
.mention-ac-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
/* ── @mention pills in messages (v0.23.0) ──── */
|
||||
.mention-pill {
|
||||
display: inline;
|
||||
color: var(--accent, #6c9fff);
|
||||
font-weight: 600;
|
||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
/* v0.23.2: User mention pill — teal to distinguish from AI mentions */
|
||||
.mention-pill.mention-user {
|
||||
color: #2dd4bf;
|
||||
background: rgba(45,212,191,0.12);
|
||||
}
|
||||
|
||||
/* ── 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-raised);
|
||||
color: var(--text-3);
|
||||
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-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
.model-stream-divider::before,
|
||||
.model-stream-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
/* ==========================================
|
||||
* Chat Switchboard – Memory UI (v0.18.0)
|
||||
* ========================================== */
|
||||
|
||||
/* ── Summary Stats ─────────────────────── */
|
||||
|
||||
.memory-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.memory-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 8px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.memory-stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.memory-stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────── */
|
||||
|
||||
.memory-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.memory-filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-select {
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-size: 0.82rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.memory-search {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.memory-search::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.memory-actions-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Memory List ───────────────────────── */
|
||||
|
||||
.memory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* ── Memory Card ───────────────────────── */
|
||||
|
||||
.memory-card {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.memory-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.memory-card-pending {
|
||||
border-left: 3px solid var(--warning, #f0ad4e);
|
||||
}
|
||||
|
||||
.memory-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-key {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.memory-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.memory-scope-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.memory-scope-user { background: var(--accent-dim, #1a3a5c); color: var(--accent-text, #6db3f2); }
|
||||
.memory-scope-persona { background: #3a2a1a; color: #f0ad4e; }
|
||||
.memory-scope-persona_user { background: #2a1a3a; color: #c084fc; }
|
||||
|
||||
.memory-confidence {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.memory-confidence-high { color: var(--success, #5cb85c); }
|
||||
.memory-confidence-medium { color: var(--warning, #f0ad4e); }
|
||||
.memory-confidence-low { color: var(--text-3); }
|
||||
|
||||
.memory-owner {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.memory-card-value {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.memory-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memory-date {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.memory-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── Tiny Action Buttons ───────────────── */
|
||||
|
||||
.btn-tiny {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.btn-tiny:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.btn-tiny.btn-approve {
|
||||
color: var(--success, #5cb85c);
|
||||
border-color: var(--success, #5cb85c);
|
||||
}
|
||||
|
||||
.btn-tiny.btn-approve:hover {
|
||||
background: var(--success, #5cb85c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-tiny.btn-reject {
|
||||
color: var(--danger, #d9534f);
|
||||
border-color: var(--danger, #d9534f);
|
||||
}
|
||||
|
||||
.btn-tiny.btn-reject:hover {
|
||||
background: var(--danger, #d9534f);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Edit Form ─────────────────────────── */
|
||||
|
||||
.memory-edit-form {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.memory-edit-form .form-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.memory-edit-input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-1);
|
||||
color: var(--text-1);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.memory-edit-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Persona Memory Section ────────────── */
|
||||
|
||||
.memory-persona-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
/* ── Admin Toolbar ─────────────────────── */
|
||||
|
||||
.admin-toolbar-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-2);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-summary {
|
||||
gap: 8px;
|
||||
}
|
||||
.memory-stat {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.memory-filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.memory-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/* ==========================================
|
||||
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);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-2);
|
||||
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);
|
||||
}
|
||||
.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-2);
|
||||
}
|
||||
.smtp-test-status.success { color: var(--success); }
|
||||
.smtp-test-status.error { color: var(--danger); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.smtp-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/* persona-kb.css — Persona–Knowledge Base picker styles (v0.17.0) */
|
||||
|
||||
.persona-kb-picker {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.persona-kb-picker > label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.persona-kb-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius, 6px);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.persona-kb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.persona-kb-item:hover {
|
||||
background: var(--hover-bg, rgba(255,255,255,0.05));
|
||||
}
|
||||
|
||||
.persona-kb-item.selected {
|
||||
background: var(--selected-bg, rgba(100,160,255,0.1));
|
||||
}
|
||||
|
||||
.persona-kb-item input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persona-kb-item .kb-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.persona-kb-item .kb-meta {
|
||||
color: var(--text-muted, #888);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-search-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-search-toggle.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auto-search-toggle span {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Persona-KB Section (in persona form) ─── */
|
||||
|
||||
.persona-kb-section {
|
||||
margin: 0.75rem 0;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Role Fallback Alert Banner ───────────── */
|
||||
|
||||
.role-fallback-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--warning-bg, #fff3cd);
|
||||
border: 1px solid var(--warning-border, #ffc107);
|
||||
border-radius: 4px;
|
||||
margin: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.role-fallback-banner .fallback-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-fallback-banner .fallback-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.role-fallback-banner .btn-small {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Chat Rename Inline Input ────────────── */
|
||||
|
||||
.chat-rename-input {
|
||||
width: 100%;
|
||||
background: var(--input-bg, #1a1a2e);
|
||||
color: var(--text-color, #e0e0e0);
|
||||
border: 1px solid var(--accent-color, #4a9eff);
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Chat Header Token Count ─────────────── */
|
||||
|
||||
.chat-token-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #888);
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-token-count.warning { color: var(--warning-color, #f0ad4e); }
|
||||
.chat-token-count.danger { color: var(--danger-color, #d9534f); }
|
||||
@@ -1,168 +0,0 @@
|
||||
/* ── splash.css ───────────────────────────────
|
||||
Auth splash, login, PWA install banner
|
||||
──────────────────────────────────────────── */
|
||||
|
||||
/* ── Auth Splash ─────────────────────────── */
|
||||
|
||||
.splash {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Hero Panel (left) */
|
||||
.splash-hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 4rem 3.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 30% 40%, rgba(108,159,255,0.06), transparent),
|
||||
radial-gradient(ellipse 60% 50% at 70% 70%, rgba(167,139,250,0.05), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.splash-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
background-image:
|
||||
linear-gradient(rgba(108,159,255,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(108,159,255,0.04) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
animation: gridDrift 30s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes gridDrift { to { transform: translate(48px, 48px); } }
|
||||
.splash-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 320px; height: 320px;
|
||||
bottom: -80px; right: -60px;
|
||||
background: radial-gradient(circle, rgba(167,139,250,0.08), transparent 70%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content { position: relative; z-index: 1; max-width: 520px; }
|
||||
.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
|
||||
.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; }
|
||||
.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; }
|
||||
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
||||
.hero-wordmark span { color: var(--accent); }
|
||||
|
||||
.hero-headline {
|
||||
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
||||
letter-spacing: -0.03em; margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.hero-sub { font-size: 1.05rem; color: var(--text-2); line-height: 1.6; margin-bottom: 2.5rem; max-width: 440px; }
|
||||
|
||||
.hero-features { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.hero-pill {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 7px 14px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border); border-radius: 100px;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--text-2);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.hero-pill:hover { border-color: var(--border-light); color: var(--text); }
|
||||
.hero-pill .pill-icon { font-size: 0.95rem; line-height: 1; }
|
||||
.hero-pill.accent { border-color: rgba(108,159,255,0.2); color: var(--accent); }
|
||||
.hero-pill.purple { border-color: rgba(167,139,250,0.2); color: var(--purple); }
|
||||
.hero-version { margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono); color: var(--text-3); letter-spacing: 0.04em; }
|
||||
|
||||
/* Auth Panel (right) */
|
||||
.splash-auth {
|
||||
width: 440px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 2rem; background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.auth-card { width: 100%; max-width: 340px; }
|
||||
.auth-card-header { margin-bottom: 2rem; }
|
||||
.auth-card-header h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||
.auth-card-header p { font-size: 0.85rem; color: var(--text-3); }
|
||||
|
||||
.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
|
||||
.auth-tab {
|
||||
flex: 1; background: none; border: none; color: var(--text-3);
|
||||
padding: 10px 0; cursor: pointer; font-family: var(--font);
|
||||
font-size: 0.85rem; font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.auth-tab:hover { color: var(--text-2); }
|
||||
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.auth-error { color: var(--danger); font-size: 0.78rem; min-height: 1.2em; margin: 0.5rem 0; }
|
||||
.splash-error { color: var(--danger); font-size: 0.78rem; text-align: center; margin-top: 0.5rem; line-height: 1.5; }
|
||||
.splash-error strong { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; }
|
||||
.splash-error-hint { color: var(--text-3); font-size: 0.72rem; }
|
||||
.splash-error-hint a { color: var(--accent); text-decoration: underline; cursor: pointer; }
|
||||
.auth-actions { margin-top: 1.5rem; }
|
||||
.auth-footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; }
|
||||
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
|
||||
|
||||
/* Splash entrance animation */
|
||||
.hero-logo-row, .hero-headline, .hero-sub, .hero-features, .hero-version,
|
||||
.auth-card-header, .splash .auth-tabs, #authLoginForm, .splash .auth-actions {
|
||||
opacity: 0; transform: translateY(12px);
|
||||
animation: fadeUp 0.5s ease forwards;
|
||||
}
|
||||
.hero-logo-row { animation-delay: 0.05s; }
|
||||
.hero-headline { animation-delay: 0.12s; }
|
||||
.hero-sub { animation-delay: 0.19s; }
|
||||
.hero-features { animation-delay: 0.26s; }
|
||||
.hero-version { animation-delay: 0.33s; }
|
||||
.auth-card-header { animation-delay: 0.15s; }
|
||||
.splash .auth-tabs { animation-delay: 0.22s; }
|
||||
#authLoginForm { animation-delay: 0.29s; }
|
||||
.splash .auth-actions { animation-delay: 0.36s; }
|
||||
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* Splash responsive */
|
||||
@media (max-width: 860px) {
|
||||
.splash { flex-direction: column; min-height: 100vh; min-height: 100dvh; overflow-y: auto; }
|
||||
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; flex: none; }
|
||||
.hero-headline { font-size: 1.8rem; }
|
||||
.hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; }
|
||||
.hero-version { margin-top: 1.5rem; }
|
||||
.splash-auth { width: 100%; border-left: none; border-top: 1px solid var(--border); padding: 2rem 1.75rem; }
|
||||
.auth-card { max-width: 400px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.splash-hero { padding: 1.5rem 1.25rem 1rem; }
|
||||
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
|
||||
.hero-logo-mark { width: 40px; height: 40px; }
|
||||
.hero-logo-img { width: 40px; height: 40px; }
|
||||
.hero-wordmark { font-size: 1.3rem; }
|
||||
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
|
||||
.hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; }
|
||||
.hero-features { gap: 6px; }
|
||||
.hero-pill { padding: 5px 10px; font-size: 0.72rem; }
|
||||
.hero-version { margin-top: 1rem; }
|
||||
.splash-auth { padding: 1.5rem 1.25rem; }
|
||||
.auth-card-header { margin-bottom: 1rem; }
|
||||
.auth-card-header h2 { font-size: 1.1rem; }
|
||||
.auth-actions { margin-top: 1rem; }
|
||||
}
|
||||
|
||||
|
||||
/* ── PWA Install Banner ──────────────────── */
|
||||
|
||||
.pwa-install-banner {
|
||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg); padding: 10px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 3000;
|
||||
font-size: 13px; color: var(--text-2);
|
||||
animation: pwa-banner-in 0.3s ease;
|
||||
max-width: 90vw;
|
||||
}
|
||||
@keyframes pwa-banner-in { from { opacity: 0; transform: translateX(-50%) translateY(20px); } }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/* ── styles.css ──────────────────────────────
|
||||
DECOMPOSED in v0.22.9 into:
|
||||
variables.css - Theme vars, reset, base
|
||||
layout.css - App shell, workspace, sidebar, responsive
|
||||
primitives.css - Buttons, forms, toasts, popups, toggles, badges
|
||||
modals.css - Modal system, debug, command palette, confirm
|
||||
chat.css - Chat area, markdown, input, files, lightbox
|
||||
panels.css - Side panel, preview, notes, graph
|
||||
surfaces.css - Admin, settings, editor, projects
|
||||
splash.css - Auth splash, PWA banner
|
||||
|
||||
All loaded via base.html. This file is no longer used.
|
||||
──────────────────────────────────────────── */
|
||||
@@ -13,6 +13,9 @@
|
||||
// v0.37.10: Legacy SPA source audit removed (ui-core.js, app.js,
|
||||
// pages.js, settings-handlers.js, ui-admin.js all deleted).
|
||||
// Policy gating now verified via Preact surfaces + admin templates.
|
||||
// v0.37.12: Admin Go templates deleted (Preact since v0.37.6). Template
|
||||
// element ID assertions removed — Preact component source audits
|
||||
// at the bottom of this file cover the same policy keys.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/policy-gating.test.js
|
||||
// ==========================================
|
||||
@@ -133,49 +136,6 @@ describe('Team member dropdown population', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Admin settings field mapping ─────────────
|
||||
// v0.22.5: Server-rendered admin settings template uses new element IDs.
|
||||
// v0.37.10: pages.js deleted — admin settings save now handled by
|
||||
// Preact admin surface. Template elements still required.
|
||||
|
||||
describe('Admin settings field mapping (server templates)', () => {
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
const requiredElements = [
|
||||
'settRegEnabled',
|
||||
'settRegDefaultState',
|
||||
'settUserBYOK',
|
||||
'settUserPersonas',
|
||||
'settBannerEnabled',
|
||||
];
|
||||
|
||||
for (const id of requiredElements) {
|
||||
it(`template has element #${id}`, () => {
|
||||
assert.ok(templateSrc.includes(`id="${id}"`),
|
||||
`MISSING: #${id} in server templates — admin settings incomplete`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Critical HTML elements in templates ───────
|
||||
|
||||
describe('Critical HTML elements exist in server templates', () => {
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
const requiredElements = [
|
||||
// Admin settings — policy toggles (still in Go templates)
|
||||
'settUserBYOK', // Admin toggle for BYOK
|
||||
'settUserPersonas', // Admin toggle for personas
|
||||
];
|
||||
|
||||
for (const id of requiredElements) {
|
||||
it(`#${id} exists in server templates`, () => {
|
||||
assert.ok(templateSrc.includes(`id="${id}"`),
|
||||
`MISSING element: #${id} — UI feature will break`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Chat surface template ────────────────────
|
||||
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
|
||||
// point exists and the old SPA scaffold is gone.
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Drag Resize Utility
|
||||
// ==========================================
|
||||
// v0.25.2: Single drag-resize primitive used by both the workspace
|
||||
// handle (panels.js) and PaneContainer split handles (pane-container.js).
|
||||
//
|
||||
// Fixes vs the two prior implementations:
|
||||
// - Full-viewport overlay during drag prevents iframes / CM6 from
|
||||
// swallowing mousemove events (the "jump to middle" bug).
|
||||
// - Touch support (touchstart / touchmove / touchend) everywhere.
|
||||
// - Transitions on dragged elements are killed on grab and restored
|
||||
// only after release — no mid-animation capture of wrong startW.
|
||||
//
|
||||
// Usage:
|
||||
// initDragResize(handleEl, {
|
||||
// direction: 'horizontal', // or 'vertical'
|
||||
// onStart(clientPos) → ctx | false, // return drag context or false to cancel
|
||||
// onMove(delta, ctx), // delta = px from start (signed)
|
||||
// onEnd(ctx), // cleanup / persist
|
||||
// });
|
||||
//
|
||||
// Exports: window.initDragResize
|
||||
|
||||
|
||||
/**
|
||||
* Attach drag-resize behavior to a handle element.
|
||||
*
|
||||
* @param {HTMLElement} handleEl — the drag grip element
|
||||
* @param {object} opts
|
||||
* @param {'horizontal'|'vertical'} opts.direction
|
||||
* @param {function} opts.onStart — receives initial clientX/Y, returns context or false
|
||||
* @param {function} opts.onMove — receives (delta, ctx)
|
||||
* @param {function} opts.onEnd — receives (ctx)
|
||||
* @param {function} [opts.canDrag] — optional guard; return false to ignore mousedown
|
||||
*/
|
||||
function initDragResize(handleEl, opts) {
|
||||
if (!handleEl) return;
|
||||
|
||||
const isHoriz = (opts.direction || 'horizontal') === 'horizontal';
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
// Optional guard (e.g. "only when .active class is present")
|
||||
if (opts.canDrag && !opts.canDrag()) return;
|
||||
|
||||
e.preventDefault();
|
||||
const clientPos = _clientPos(e, isHoriz);
|
||||
if (clientPos == null) return;
|
||||
|
||||
const ctx = opts.onStart(clientPos);
|
||||
if (ctx === false) return;
|
||||
|
||||
// ── Overlay: blocks iframes / CodeMirror from eating events ──
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;z-index:99999;' +
|
||||
'cursor:' + (isHoriz ? 'col-resize' : 'row-resize') + ';';
|
||||
document.body.appendChild(overlay);
|
||||
document.body.style.cursor = isHoriz ? 'col-resize' : 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (e) => {
|
||||
const pos = _clientPos(e, isHoriz);
|
||||
if (pos == null) return;
|
||||
opts.onMove(pos - clientPos, ctx);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
overlay.remove();
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onUp);
|
||||
if (opts.onEnd) opts.onEnd(ctx);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onUp);
|
||||
};
|
||||
|
||||
handleEl.addEventListener('mousedown', onPointerDown);
|
||||
handleEl.addEventListener('touchstart', onPointerDown, { passive: false });
|
||||
}
|
||||
|
||||
/** Extract the relevant coordinate from a mouse or touch event. */
|
||||
function _clientPos(e, isHoriz) {
|
||||
if (e.touches?.length > 0) {
|
||||
return isHoriz ? e.touches[0].clientX : e.touches[0].clientY;
|
||||
}
|
||||
if (e.changedTouches?.length > 0) {
|
||||
return isHoriz ? e.changedTouches[0].clientX : e.changedTouches[0].clientY;
|
||||
}
|
||||
return isHoriz ? e.clientX : e.clientY;
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.register('initDragResize', initDragResize);
|
||||
@@ -1,175 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — ModelSelector Component
|
||||
// ==========================================
|
||||
// v0.25.0: Extracted from ui-core.js.
|
||||
// Grouped dropdown: personas (global, team, personal), models, BYOK.
|
||||
// Pattern: Go template partial (model-selector.html) + JS factory (this file).
|
||||
//
|
||||
// Usage:
|
||||
// const sel = ModelSelector.create({ id: 'main', onChange: (id, label) => { ... } });
|
||||
// sel.setModels(App.models);
|
||||
// sel.select(modelId);
|
||||
// sel.getSelected(); // → current model ID
|
||||
// sel.destroy();
|
||||
//
|
||||
// Exports: window.ModelSelector
|
||||
|
||||
|
||||
const ModelSelector = {
|
||||
...createComponentRegistry('ModelSelector'),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || '';
|
||||
const instance = componentMixin({
|
||||
id: pfx,
|
||||
dropdownEl: document.getElementById(pfx + 'modelDropdown'),
|
||||
btnEl: document.getElementById(pfx + 'modelDropdownBtn'),
|
||||
labelEl: document.getElementById(pfx + 'modelDropdownLabel'),
|
||||
menuEl: document.getElementById(pfx + 'modelDropdownMenu'),
|
||||
capsEl: document.getElementById(pfx + 'modelCaps'),
|
||||
onChange: opts.onChange || null,
|
||||
_value: '',
|
||||
_models: [],
|
||||
|
||||
// ── Selection ───────────────────────
|
||||
|
||||
getSelected() { return this._value; },
|
||||
|
||||
select(id, label) {
|
||||
this._value = id;
|
||||
if (this.labelEl) this.labelEl.textContent = label || id || 'Select a model';
|
||||
// Highlight
|
||||
if (this.menuEl) {
|
||||
this.menuEl.querySelectorAll('.model-dropdown-item').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.value === id);
|
||||
});
|
||||
}
|
||||
this._updateCaps();
|
||||
if (this.onChange) this.onChange(id, label);
|
||||
},
|
||||
|
||||
// ── Model list ──────────────────────
|
||||
|
||||
setModels(models) {
|
||||
this._models = models || [];
|
||||
this._rebuild();
|
||||
},
|
||||
|
||||
_rebuild() {
|
||||
if (!this.menuEl) return;
|
||||
this.menuEl.innerHTML = '';
|
||||
|
||||
if (this._models.length === 0) {
|
||||
this.menuEl.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||
this.select('', 'No models loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const globalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'global');
|
||||
const teamPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'team');
|
||||
const personalPersonas = this._models.filter(m => m.isPersona && m.personaScope === 'personal');
|
||||
const models = this._models.filter(m => !m.isPersona && !m.hidden);
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'model-dropdown-group';
|
||||
hdr.textContent = label;
|
||||
this.menuEl.appendChild(hdr);
|
||||
items.forEach(m => this.menuEl.appendChild(this._createItem(m)));
|
||||
};
|
||||
|
||||
addGroup('\u26A1 Personas', globalPersonas);
|
||||
|
||||
// Group team personas by team name
|
||||
const teamGroups = {};
|
||||
teamPersonas.forEach(m => {
|
||||
const tn = m.personaTeamName || 'Team';
|
||||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||||
});
|
||||
Object.keys(teamGroups).sort().forEach(tn => {
|
||||
addGroup('\uD83D\uDC65 ' + tn, teamGroups[tn]);
|
||||
});
|
||||
|
||||
addGroup('\uD83D\uDD27 My Personas', personalPersonas);
|
||||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||||
addGroup('\uD83D\uDD11 My Providers', models.filter(m => m.source === 'personal'));
|
||||
},
|
||||
|
||||
_createItem(m) {
|
||||
const self = this;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
const avatar = m.personaAvatar ? '<img src="' + esc(m.personaAvatar) + '" class="dropdown-avatar" alt="">' : '';
|
||||
const handle = m.isPersona && m.personaHandle ? '<span class="item-handle">@' + esc(m.personaHandle) + '</span>' : '';
|
||||
const teamBadge = m.source === 'team' && m.teamName ? '<span class="badge-team" style="font-size:9px;padding:0 4px">\uD83D\uDC65 ' + esc(m.teamName) + '</span>' : '';
|
||||
const provider = m.provider ? '<span class="item-provider">' + esc(m.provider) + '</span>' : '';
|
||||
div.innerHTML = avatar + '<span class="item-label">' + esc(m.name || m.id) + handle + '</span>' + teamBadge + provider;
|
||||
div.addEventListener('click', () => {
|
||||
self.select(m.id, (m.name || m.id) + (m.provider ? ' (' + m.provider + ')' : ''));
|
||||
self.close();
|
||||
});
|
||||
return div;
|
||||
},
|
||||
|
||||
// ── Restore selection ───────────────
|
||||
|
||||
/**
|
||||
* Restore selection from a preferred ID, falling back through
|
||||
* admin default → first visible.
|
||||
*/
|
||||
restore(preferredId, adminDefault) {
|
||||
const visible = this._models.filter(m => !m.hidden);
|
||||
if (visible.length === 0) { this.select('', 'No visible models'); return; }
|
||||
|
||||
const byId = (id) => visible.find(m => m.id === id || m.baseModelId === id);
|
||||
const match = byId(preferredId) || byId(adminDefault) || visible[0];
|
||||
this.select(match.id, (match.name || match.id) + (match.provider ? ' (' + match.provider + ')' : ''));
|
||||
},
|
||||
|
||||
// ── Dropdown toggle ─────────────────
|
||||
|
||||
open() { if (this.menuEl) this.menuEl.classList.add('open'); },
|
||||
close() { if (this.menuEl) this.menuEl.classList.remove('open'); },
|
||||
toggle() { if (this.menuEl) this.menuEl.classList.toggle('open'); },
|
||||
|
||||
// ── Capability badges ───────────────
|
||||
|
||||
_updateCaps() {
|
||||
if (!this.capsEl) return;
|
||||
if (!this._value) { this.capsEl.innerHTML = ''; return; }
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
const caps = m?.capabilities;
|
||||
if (!caps || Object.keys(caps).length === 0) { this.capsEl.innerHTML = ''; return; }
|
||||
// Use global renderCapBadges if available (from ui-core.js)
|
||||
if (typeof renderCapBadges === 'function') {
|
||||
this.capsEl.innerHTML = renderCapBadges(caps);
|
||||
}
|
||||
},
|
||||
|
||||
getSelectedCaps() {
|
||||
const m = this._models.find(m => m.id === this._value);
|
||||
return m?.capabilities || {};
|
||||
},
|
||||
|
||||
// ── Event wiring ────────────────────
|
||||
|
||||
bind() {
|
||||
this._on(this.btnEl, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
this._on(document, 'click', (e) => {
|
||||
if (!e.target.closest('#' + pfx + 'modelDropdown')) this.close();
|
||||
});
|
||||
},
|
||||
}, ModelSelector);
|
||||
|
||||
ModelSelector._register(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('ModelSelector', ModelSelector);
|
||||
@@ -1,186 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - Splash Surface (pages-splash.js)
|
||||
// ==========================================
|
||||
// v0.22.7: Splash init, login, register, animated grid.
|
||||
|
||||
var base = window.__BASE__ || '';
|
||||
|
||||
Pages.initSplash = function() {
|
||||
_initAuthTabs();
|
||||
_initLoginForm();
|
||||
_initRegisterForm();
|
||||
_initGridCanvas();
|
||||
if (typeof Theme !== 'undefined') Theme.init();
|
||||
};
|
||||
|
||||
// -- Save tokens via API singleton (sets sb_token cookie) --
|
||||
function _saveAuth(data) {
|
||||
if (typeof API !== 'undefined') {
|
||||
API.accessToken = data.access_token || null;
|
||||
API.refreshToken = data.refresh_token || null;
|
||||
API.user = data.user || null;
|
||||
API.saveTokens();
|
||||
} else {
|
||||
// Fallback: at minimum set the cookie so page auth works
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('token', data.access_token);
|
||||
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Redirect after auth --
|
||||
function _redirectAfterAuth() {
|
||||
// Check for saved redirect (e.g. deep link that bounced to /login)
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var c = cookies[i].trim();
|
||||
if (c.startsWith('redirect_after_login=')) {
|
||||
var dest = decodeURIComponent(c.substring('redirect_after_login='.length));
|
||||
document.cookie = 'redirect_after_login=; path=/; max-age=0';
|
||||
if (dest && dest !== '/login' && dest !== base + '/login') {
|
||||
window.location.href = dest;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.location.href = base + '/';
|
||||
}
|
||||
|
||||
function _initAuthTabs() {
|
||||
var tabs = document.querySelectorAll('.splash-tab');
|
||||
var loginForm = document.getElementById('authLogin');
|
||||
var regForm = document.getElementById('authRegister');
|
||||
if (!tabs.length) return;
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
tabs.forEach(function(t) { t.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
var target = tab.dataset.tab;
|
||||
if (loginForm) loginForm.style.display = target === 'login' ? '' : 'none';
|
||||
if (regForm) regForm.style.display = target === 'register' ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _initLoginForm() {
|
||||
var btn = document.getElementById('loginBtn');
|
||||
if (!btn) return;
|
||||
var form = document.getElementById('authLogin');
|
||||
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
|
||||
|
||||
btn.addEventListener('click', async function() {
|
||||
var username = _val('loginUsername');
|
||||
var password = _val('loginPassword');
|
||||
var errEl = document.getElementById('loginError');
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Logging in\u2026';
|
||||
try {
|
||||
var resp = await fetch(base + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: username, password: password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var errData = await resp.json().catch(function() { return {}; });
|
||||
throw new Error(errData.error || 'Invalid credentials');
|
||||
}
|
||||
var data = await resp.json();
|
||||
_saveAuth(data);
|
||||
_redirectAfterAuth();
|
||||
} catch (e) {
|
||||
_showErr(errEl, e.message || 'Login failed');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Log In';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _initRegisterForm() {
|
||||
var btn = document.getElementById('regBtn');
|
||||
if (!btn) return;
|
||||
var avatarEl = document.getElementById('avatarUpload');
|
||||
var avatarFile = null;
|
||||
if (avatarEl && typeof mountAvatarUpload === 'function') {
|
||||
mountAvatarUpload(avatarEl, { onUpload: function(file) { avatarFile = file; } });
|
||||
}
|
||||
var form = document.getElementById('authRegister');
|
||||
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
|
||||
|
||||
btn.addEventListener('click', async function() {
|
||||
var username = _val('regUsername');
|
||||
var displayName = _val('regDisplayName');
|
||||
var email = _val('regEmail');
|
||||
var password = _val('regPassword');
|
||||
var confirm = _val('regConfirm');
|
||||
var errEl = document.getElementById('regError');
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
|
||||
if (password !== confirm) { _showErr(errEl, 'Passwords do not match.'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating account\u2026';
|
||||
try {
|
||||
var body = { username: username, password: password, display_name: displayName, email: email };
|
||||
var resp = await fetch(base + '/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var errData = await resp.json().catch(function() { return {}; });
|
||||
throw new Error(errData.error || 'Registration failed');
|
||||
}
|
||||
var data = await resp.json();
|
||||
// Upload avatar before redirect (token needed for auth)
|
||||
if (avatarFile && data.access_token) {
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('avatar', avatarFile);
|
||||
await fetch(base + '/api/v1/users/me/avatar', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + data.access_token },
|
||||
body: fd,
|
||||
});
|
||||
} catch (e) { console.warn('Avatar upload failed:', e); }
|
||||
}
|
||||
_saveAuth(data);
|
||||
_redirectAfterAuth();
|
||||
} catch (e) {
|
||||
_showErr(errEl, e.message || 'Registration failed');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Account';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _initGridCanvas() {
|
||||
var canvas = document.getElementById('gridCanvas');
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var w, h, offset = 0;
|
||||
function resize() {
|
||||
var rect = canvas.parentElement.getBoundingClientRect();
|
||||
w = canvas.width = rect.width;
|
||||
h = canvas.height = rect.height;
|
||||
}
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var gap = 40;
|
||||
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#2e2e35';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (var x = (offset % gap); x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
for (var y = (offset % gap); y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
offset += 0.15;
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
draw();
|
||||
}
|
||||
|
||||
function _val(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
||||
function _showErr(el, msg) { if (!el) return; el.textContent = msg; el.style.display = ''; }
|
||||
@@ -1,141 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Task Sidebar Section
|
||||
// ==========================================
|
||||
// Sidebar section showing user's scheduled tasks with status indicators.
|
||||
// Click opens the task's output channel. Same pattern as WorkflowQueue.
|
||||
|
||||
|
||||
const TaskSidebar = {
|
||||
|
||||
_loaded: false,
|
||||
|
||||
// ── Init ────────────────────────────
|
||||
|
||||
async init() {
|
||||
if (this._loaded) return;
|
||||
this._loaded = true;
|
||||
|
||||
// Insert after Queue section (or after Channels if no queue)
|
||||
var anchor = document.getElementById('sbSectionQueue') ||
|
||||
document.getElementById('sbSectionChannels');
|
||||
if (!anchor) return;
|
||||
|
||||
var section = document.createElement('div');
|
||||
section.className = 'sb-section';
|
||||
section.id = 'sbSectionTasks';
|
||||
section.innerHTML =
|
||||
'<div class="sb-section-header" data-action="UI.toggleSidebarSection" data-args=\'["tasks"]\'>' +
|
||||
'<span class="sb-section-arrow" id="sbArrowTasks">\u25be</span>' +
|
||||
'<span class="sb-section-label">Tasks</span>' +
|
||||
'<span class="sb-queue-badge" id="sbTaskBadge" style="display:none">0</span>' +
|
||||
'</div>' +
|
||||
'<div class="sb-section-body" id="sbBodyTasks"></div>';
|
||||
|
||||
anchor.parentNode.insertBefore(section, anchor.nextSibling);
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
// ── Refresh ─────────────────────────
|
||||
|
||||
async refresh() {
|
||||
var body = document.getElementById('sbBodyTasks');
|
||||
var badge = document.getElementById('sbTaskBadge');
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Personal tasks
|
||||
var resp = await API._get('/api/v1/tasks');
|
||||
var tasks = (resp.data || []).filter(function(t) { return t.is_active; });
|
||||
|
||||
// Team tasks (v0.27.5)
|
||||
try {
|
||||
var teamsResp = await API._get('/api/v1/teams/mine');
|
||||
var teams = teamsResp.data || teamsResp || [];
|
||||
var seen = {};
|
||||
tasks.forEach(function(t) { seen[t.id] = true; });
|
||||
for (var i = 0; i < teams.length; i++) {
|
||||
try {
|
||||
var tr = await API._get('/api/v1/teams/' + teams[i].id + '/tasks');
|
||||
(tr.data || []).forEach(function(t) {
|
||||
if (!seen[t.id] && t.is_active) {
|
||||
t._teamName = teams[i].name;
|
||||
tasks.push(t);
|
||||
seen[t.id] = true;
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
body.innerHTML = '';
|
||||
if (badge) badge.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = tasks.length;
|
||||
badge.style.display = '';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
tasks.forEach(function(t) {
|
||||
// Status indicator
|
||||
var icon = '\u25f7'; // default: clock
|
||||
var statusTip = 'Scheduled';
|
||||
if (t.last_run_at) {
|
||||
// Check if most recent run was recent (within 2x schedule interval)
|
||||
icon = '\u2713'; // checkmark
|
||||
statusTip = 'Last: ' + new Date(t.last_run_at).toLocaleString();
|
||||
}
|
||||
var nextTip = t.next_run_at
|
||||
? 'Next: ' + new Date(t.next_run_at).toLocaleString()
|
||||
: '';
|
||||
|
||||
var channelId = t.output_channel_id || '';
|
||||
var onclick = channelId
|
||||
? 'selectChannel(\'' + channelId + '\')'
|
||||
: 'UI.toast(\'No output channel yet — task has not run\',\'info\')';
|
||||
|
||||
var label = _esc(t.name);
|
||||
if (t._teamName) label = '<span style="opacity:0.6;font-size:10px">[' + _esc(t._teamName) + ']</span> ' + label;
|
||||
|
||||
html += '<div class="sb-queue-item" onclick="' + onclick + '" title="' +
|
||||
_esc(statusTip + (nextTip ? ' \u00b7 ' + nextTip : '')) + '">' +
|
||||
'<span class="sb-queue-icon">' + icon + '</span>' +
|
||||
'<span class="sb-queue-label">' + label + '</span>' +
|
||||
'<button class="btn-small" style="padding:0 4px;font-size:10px" ' +
|
||||
'data-action="TaskSidebar.runNow" data-args=\'' + JSON.stringify([t.id]) + '\' title="Run Now">\u25b6</button>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
body.innerHTML = html;
|
||||
} catch (err) {
|
||||
body.innerHTML = '';
|
||||
if (badge) badge.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Run Now ─────────────────────────
|
||||
|
||||
async runNow(taskId) {
|
||||
try {
|
||||
await API._post('/api/v1/tasks/' + taskId + '/run', {});
|
||||
UI.toast('Task triggered', 'success');
|
||||
} catch (err) {
|
||||
UI.toast(err.message || 'Failed to run task', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
sb.ns('TaskSidebar', TaskSidebar);
|
||||
|
||||
function _esc(s) { return s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// Auto-init when chat surface loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (typeof API !== 'undefined' && API.accessToken) {
|
||||
TaskSidebar.init();
|
||||
}
|
||||
}, 2200); // After WorkflowQueue (2000ms)
|
||||
});
|
||||
@@ -1,628 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI Formatting & Helpers
|
||||
// ==========================================
|
||||
// Markdown rendering, code blocks, preview panel, time formatting.
|
||||
// esc() is in ui-primitives.js (loaded globally in base.html).
|
||||
//
|
||||
// Exports: formatMessage, clearPreview, runExtensionPostRender,
|
||||
// _livePreviewUpdate, _relativeTime, _renderToolCallsHTML,
|
||||
// _registerPreviewPanel,
|
||||
// toggleCodeCollapse, toggleHTMLPreview, downloadCode,
|
||||
// popOutExtBlock (onclick handlers)
|
||||
// ==========================================
|
||||
|
||||
|
||||
// ── Message Formatting ──────────────────────
|
||||
|
||||
function formatMessage(content) {
|
||||
if (!content) return '';
|
||||
|
||||
const thinkingBlocks = [];
|
||||
let text = content;
|
||||
|
||||
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
|
||||
const id = 'think-' + Math.random().toString(36).slice(2, 9);
|
||||
thinkingBlocks.push({ id, content: inner.trim() });
|
||||
// Newlines ensure the placeholder is its own markdown block so adjacent
|
||||
// code fences (e.g. "</think>```html") start on a fresh line for marked.
|
||||
return `\n\nTHINK_PLACEHOLDER_${id}\n\n`;
|
||||
});
|
||||
|
||||
let html;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
html = _formatMarked(text);
|
||||
} else {
|
||||
html = _formatBasic(text);
|
||||
}
|
||||
|
||||
for (const b of thinkingBlocks) {
|
||||
const inner = esc(b.content).replace(/\n/g, '<br>');
|
||||
const openAttr = App.settings.showThinking ? ' open' : '';
|
||||
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
|
||||
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
|
||||
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
|
||||
}
|
||||
|
||||
// @mention highlighting (v0.23.0): style @Name references as pills
|
||||
html = _highlightMentions(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight @mentions in rendered HTML as styled pills.
|
||||
* Matches @Name patterns against the channel model roster.
|
||||
* Only highlights inside text nodes (not inside code/pre/a tags).
|
||||
*/
|
||||
function _highlightMentions(html) {
|
||||
if (typeof ChannelModels === 'undefined') return html;
|
||||
const roster = ChannelModels.getRoster();
|
||||
|
||||
// Build patterns from roster handles and display names (AI mentions)
|
||||
const aiPatterns = [];
|
||||
const seen = new Set();
|
||||
|
||||
if (roster && roster.length >= 2) {
|
||||
for (const m of roster) {
|
||||
if (m.handle) {
|
||||
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped)) {
|
||||
aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped);
|
||||
}
|
||||
}
|
||||
if (m.display_name) {
|
||||
const hyphenated = m.display_name.replace(/\s+/g, '-');
|
||||
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped.toLowerCase())) {
|
||||
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
|
||||
aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @all is special
|
||||
aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
|
||||
// v0.23.2: Build user mention patterns
|
||||
const userPatterns = [];
|
||||
for (const u of (App.users || [])) {
|
||||
const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
if (!seen.has(escaped.toLowerCase())) {
|
||||
userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||
seen.add(escaped.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Don't highlight inside <code>, <pre>, <a> tags
|
||||
const parts = html.split(/(<[^>]+>)/);
|
||||
let inCode = false;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part.startsWith('<')) {
|
||||
const tag = part.toLowerCase();
|
||||
if (tag.startsWith('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
|
||||
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
|
||||
continue;
|
||||
}
|
||||
if (inCode) continue;
|
||||
let text = part;
|
||||
// AI mentions first (persona/model)
|
||||
for (const re of aiPatterns) {
|
||||
text = text.replace(re, '<span class="mention-pill">$1</span>');
|
||||
}
|
||||
// User mentions (different color)
|
||||
for (const re of userPatterns) {
|
||||
text = text.replace(re, '<span class="mention-pill mention-user">$1</span>');
|
||||
}
|
||||
parts[i] = text;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function _formatMarked(text) {
|
||||
// ── Unwrap outer ```markdown wrappers ────────────────────────
|
||||
// LLMs frequently wrap entire responses in ```markdown ... ``` which is
|
||||
// semantically "this content IS markdown". Standard markdown doesn't support
|
||||
// nested fences with the same delimiter, so an inner ```mermaid ... ```
|
||||
// prematurely closes the outer block, splitting the content into broken
|
||||
// fragments. Fix: if the outer fence is ```markdown/```md and contains
|
||||
// nested fences, strip the wrapper and render contents as markdown directly.
|
||||
text = _unwrapMarkdownFence(text);
|
||||
|
||||
// Close any unclosed code fences to prevent raw HTML injection during
|
||||
// streaming (closing ``` hasn't arrived yet) or when the model forgets
|
||||
// to close a fence. An odd number of ``` markers means one is unclosed.
|
||||
const fences = text.match(/^```/gm);
|
||||
if (fences && fences.length % 2 !== 0) {
|
||||
text += '\n```';
|
||||
}
|
||||
|
||||
const rendered = marked.parse(text, { breaks: true, gfm: true });
|
||||
let html = DOMPurify.sanitize(rendered, {
|
||||
// Strict allowlist: only elements that marked.js actually produces.
|
||||
// This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.)
|
||||
// from rendering live even if a code fence fails to parse.
|
||||
ALLOWED_TAGS: [
|
||||
// Block elements
|
||||
'p', 'br', 'hr', 'pre', 'code', 'blockquote',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
// Lists
|
||||
'ul', 'ol', 'li',
|
||||
// Tables
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||
// Inline formatting
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup',
|
||||
'a', 'img', 'span', 'div',
|
||||
// Think blocks (injected post-sanitize, but placeholders may be in <p>)
|
||||
'details', 'summary',
|
||||
],
|
||||
ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title',
|
||||
'colspan', 'rowspan', 'align'],
|
||||
});
|
||||
|
||||
// Process code blocks: add copy button, collapse toggle, HTML preview
|
||||
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
const langMatch = attrs.match(/class="language-(\w+)"/);
|
||||
const lang = langMatch ? langMatch[1].toLowerCase() : '';
|
||||
|
||||
// Extension hook: let extensions claim specific code blocks (mermaid, latex, etc.)
|
||||
// Extensions mark blocks with data-ext-render for post-render DOM processing.
|
||||
if (lang && typeof Extensions !== 'undefined' && Extensions.hasRenderers('block')) {
|
||||
try {
|
||||
const fakeContainer = document.createElement('div');
|
||||
const decoded = _decodeHTML(code);
|
||||
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
|
||||
if (handled && fakeContainer.innerHTML) {
|
||||
const popBtn = `<button class="ext-popout-btn" data-action="popOutExtBlock" data-args='${JSON.stringify([codeId])}'" title="Open in side panel">⧉</button>`;
|
||||
return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`;
|
||||
}
|
||||
if (handled && !fakeContainer.innerHTML) {
|
||||
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Block renderer hook error for lang="${lang}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Count lines for collapse decision
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
const COLLAPSE_THRESHOLD = 15;
|
||||
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
|
||||
|
||||
// Build toolbar buttons
|
||||
const collapseBtn = lineCount > 5
|
||||
? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([codeId])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([codeId])}'">Copy</button>`;
|
||||
toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([codeId, lang])}'">Download</button>`;
|
||||
|
||||
// HTML preview button (only for HTML-like content)
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([codeId])}'">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||||
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function _formatBasic(text) {
|
||||
let html = esc(text);
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const id = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
const COLLAPSE_THRESHOLD = 15;
|
||||
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
|
||||
|
||||
const collapseBtn = lineCount > 5
|
||||
? `<button class="code-collapse-btn" data-action="toggleCodeCollapse" data-args='${JSON.stringify([id])}'" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" data-action="_copyCodeBlock" data-args='${JSON.stringify([id])}'">Copy</button>`;
|
||||
toolbar += `<button class="copy-code-btn" data-action="downloadCode" data-args='${JSON.stringify([id, lang])}'">Download</button>`;
|
||||
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" data-action="toggleHTMLPreview" data-args='${JSON.stringify([id])}'">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||||
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||||
});
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Markdown Fence Unwrapper ──────────────────
|
||||
// LLMs often wrap entire responses in ```markdown ... ```. This is semantically
|
||||
// a no-op ("this markdown IS markdown") but breaks parsing when the content
|
||||
// contains nested code fences — the inner closing ``` prematurely terminates
|
||||
// the outer block, splitting the response into fragments.
|
||||
//
|
||||
// Strategy: if the text is wrapped in ```markdown/```md and the body contains
|
||||
// at least one nested ``` fence, strip the outer wrapper. If there are no
|
||||
// nested fences, leave it alone (the user/LLM may genuinely want to show
|
||||
// markdown source in a code block).
|
||||
function _unwrapMarkdownFence(text) {
|
||||
// The text may start with THINK_PLACEHOLDER_xxx blocks (extracted earlier).
|
||||
// We need to find the ```markdown fence after any placeholders/whitespace.
|
||||
const openMatch = text.match(/^((?:\s|THINK_PLACEHOLDER_\w+)*)(```(?:markdown|md)\s*\n)/i);
|
||||
if (!openMatch) return text;
|
||||
|
||||
const prefix = openMatch[1]; // placeholders + whitespace before the fence
|
||||
const fence = openMatch[2]; // the ```markdown\n itself
|
||||
const afterOpen = prefix.length + fence.length;
|
||||
|
||||
// Find the LAST bare ``` on its own line — this is the outer closing fence.
|
||||
// There may be trailing text (LLM explanations) after it.
|
||||
let closeIdx = -1;
|
||||
const searchRegex = /\n```[ \t]*(?:\n|$)/g;
|
||||
let m;
|
||||
while ((m = searchRegex.exec(text)) !== null) {
|
||||
closeIdx = m.index;
|
||||
}
|
||||
if (closeIdx < afterOpen) return text;
|
||||
|
||||
// Extract inner content and any trailing text after the close
|
||||
const inner = text.slice(afterOpen, closeIdx);
|
||||
const trailing = text.slice(closeIdx).replace(/^\n```[ \t]*\n?/, '\n');
|
||||
|
||||
// Only unwrap if the inner content contains nested fences —
|
||||
// otherwise this might be intentional "show me the markdown source"
|
||||
if (/^```/m.test(inner)) {
|
||||
return prefix + inner + trailing;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
// Decode HTML entities in code block content (for extension renderers that need raw text)
|
||||
function _decodeHTML(html) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = html;
|
||||
return txt.value;
|
||||
}
|
||||
|
||||
// Heuristic: does this code block look like HTML?
|
||||
function _looksLikeHTML(code) {
|
||||
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
|
||||
}
|
||||
|
||||
// ── Code Block Actions ──────────────────────
|
||||
|
||||
function toggleCodeCollapse(codeId) {
|
||||
const pre = document.getElementById('block-' + codeId);
|
||||
if (!pre) return;
|
||||
const isCollapsed = pre.classList.toggle('code-collapsed');
|
||||
const btn = pre.querySelector('.code-collapse-btn');
|
||||
if (btn) {
|
||||
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
|
||||
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
|
||||
btn.title = isCollapsed ? 'Expand' : 'Collapse';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHTMLPreview(codeId) {
|
||||
const codeEl = document.getElementById(codeId);
|
||||
if (!codeEl) return;
|
||||
|
||||
const rawHTML = codeEl.textContent;
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
|
||||
frame.srcdoc = rawHTML;
|
||||
frame.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
PanelRegistry.open('preview');
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
|
||||
if (empty) empty.style.display = '';
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
|
||||
function downloadCode(codeId, lang) {
|
||||
const el = document.getElementById(codeId);
|
||||
if (!el) return;
|
||||
const extMap = {
|
||||
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
|
||||
golang: 'go', go: 'go', rust: 'rs', java: 'java', cpp: 'cpp',
|
||||
c: 'c', csharp: 'cs', css: 'css', html: 'html', htm: 'html',
|
||||
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', sql: 'sql',
|
||||
bash: 'sh', shell: 'sh', sh: 'sh', markdown: 'md', md: 'md',
|
||||
toml: 'toml', dockerfile: 'Dockerfile', makefile: 'Makefile',
|
||||
perl: 'pl', lua: 'lua', swift: 'swift', kotlin: 'kt', php: 'php',
|
||||
};
|
||||
const ext = extMap[lang?.toLowerCase()] || lang || 'txt';
|
||||
const filename = `code.${ext}`;
|
||||
const blob = new Blob([el.textContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename;
|
||||
document.body.appendChild(a); a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
UI.toast('Downloaded ' + filename, 'success');
|
||||
}
|
||||
|
||||
// ── Preview Panel Registration ──────────────
|
||||
|
||||
function _registerPreviewPanel() {
|
||||
const el = document.getElementById('sidePanelPreview');
|
||||
if (!el) return;
|
||||
|
||||
PanelRegistry.register('preview', {
|
||||
element: el,
|
||||
label: 'Preview',
|
||||
actions: [
|
||||
{
|
||||
id: 'sidePanelClearBtn',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
|
||||
title: 'Clear preview',
|
||||
onClickName: 'clearPreview()',
|
||||
visible: _hasPreviewContent,
|
||||
},
|
||||
],
|
||||
saveState() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
return {
|
||||
srcdoc: frame?.srcdoc || '',
|
||||
hasContent: frame?.style.display !== 'none',
|
||||
};
|
||||
},
|
||||
restoreState(state) {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame && state.srcdoc) {
|
||||
frame.srcdoc = state.srcdoc;
|
||||
frame.style.display = state.hasContent ? '' : 'none';
|
||||
if (empty) empty.style.display = state.hasContent ? 'none' : '';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function _hasPreviewContent() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
return frame && frame.style.display !== 'none' && frame.srcdoc;
|
||||
}
|
||||
|
||||
// ── Pop-out for Extension-Rendered Blocks ───
|
||||
|
||||
/**
|
||||
* Open an extension-rendered block (Mermaid, KaTeX, etc.) in the
|
||||
* preview panel at full size.
|
||||
*/
|
||||
function popOutExtBlock(blockId) {
|
||||
const block = document.querySelector(`[data-ext-block="${blockId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
// Clone rendered content (skip the pop-out button itself)
|
||||
const clone = block.cloneNode(true);
|
||||
const btn = clone.querySelector('.ext-popout-btn');
|
||||
if (btn) btn.remove();
|
||||
|
||||
const lang = block.getAttribute('data-ext-lang') || '';
|
||||
|
||||
// Read current theme colors so the iframe respects light/dark mode
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bgColor = cs.getPropertyValue('--bg-surface').trim() || '#fff';
|
||||
const textColor = cs.getPropertyValue('--text').trim() || '#1a1a2e';
|
||||
|
||||
// Wrap in a minimal HTML document for the iframe
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||||
align-items: flex-start; min-height: 100vh; background: ${bgColor}; color: ${textColor};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
body > * { max-width: 100%; }
|
||||
svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head><body>${clone.innerHTML}</body></html>`;
|
||||
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame) {
|
||||
frame.srcdoc = html;
|
||||
frame.style.display = '';
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
PanelRegistry.open('preview');
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
|
||||
// ── Live Preview During Streaming ───────────
|
||||
|
||||
let _livePreviewTimer = null;
|
||||
|
||||
/**
|
||||
* Called on each streaming delta with the accumulated raw markdown
|
||||
* content. If the preview panel is open, extracts the last completed
|
||||
* HTML code block and updates the iframe. Debounced at 500ms.
|
||||
*/
|
||||
function _livePreviewUpdate(rawContent) {
|
||||
// Only live-update if preview is the active panel
|
||||
if (!PanelRegistry.isOpen('preview')) return;
|
||||
|
||||
clearTimeout(_livePreviewTimer);
|
||||
_livePreviewTimer = setTimeout(() => {
|
||||
const html = _extractLastHTMLBlock(rawContent);
|
||||
if (!html) return;
|
||||
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame) {
|
||||
frame.srcdoc = html;
|
||||
frame.style.display = '';
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
PanelRegistry.refreshActions();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last completed fenced HTML code block from raw markdown.
|
||||
* Returns the raw HTML content or null if none found.
|
||||
*
|
||||
* "Completed" means both opening ``` and closing ``` are present.
|
||||
*/
|
||||
function _extractLastHTMLBlock(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML)
|
||||
const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi;
|
||||
let lastMatch = null;
|
||||
let m;
|
||||
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
const blockContent = m[1];
|
||||
const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || '';
|
||||
if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) {
|
||||
lastMatch = blockContent;
|
||||
}
|
||||
}
|
||||
|
||||
return lastMatch;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// Render tool calls from stored message data (history view)
|
||||
function _renderToolCallsHTML(toolCalls) {
|
||||
if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return '';
|
||||
|
||||
const items = toolCalls.map(tc => {
|
||||
const name = tc.function?.name || tc.name || 'unknown';
|
||||
const args = tc.function?.arguments || tc.arguments || tc.input || '';
|
||||
const result = tc.result || '';
|
||||
const isError = tc.is_error || false;
|
||||
const tcId = tc.id || '';
|
||||
|
||||
const statusCls = isError ? 'tool-error' : 'tool-done';
|
||||
const statusText = isError ? 'error' : 'done';
|
||||
|
||||
// Check for note tool — add view link
|
||||
let noteLink = '';
|
||||
if (name.startsWith('note_') && result) {
|
||||
try {
|
||||
const parsed = typeof result === 'string' ? JSON.parse(result) : result;
|
||||
if (parsed.id) {
|
||||
noteLink = `<button class="tool-note-link" data-action="_openNoteFromTool" data-args='${JSON.stringify([esc(parsed.id)])}'">📝 View</button>`;
|
||||
}
|
||||
} catch (e) { /* not JSON */ }
|
||||
}
|
||||
|
||||
// Build collapsible detail
|
||||
let detailHTML = '';
|
||||
if (args || result) {
|
||||
const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
detailHTML = `<div class="tool-detail">`;
|
||||
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
|
||||
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
|
||||
detailHTML += `</div>`;
|
||||
}
|
||||
|
||||
return `<details class="tool-call-block">
|
||||
<summary class="tool-call-summary">
|
||||
<span class="tool-icon">🔧</span>
|
||||
<span class="tool-name">${esc(name)}</span>
|
||||
<span class="tool-status ${statusCls}">${statusText}</span>
|
||||
${noteLink}
|
||||
</summary>
|
||||
${detailHTML}
|
||||
</details>`;
|
||||
});
|
||||
|
||||
return `<div class="msg-tools">${items.join('')}</div>`;
|
||||
}
|
||||
|
||||
function _relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = (now - d) / 1000;
|
||||
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run extension post-renderers on a container after innerHTML is set.
|
||||
* Safe to call even if extensions aren't loaded — no-ops gracefully.
|
||||
*/
|
||||
function runExtensionPostRender(container) {
|
||||
// v0.28.5: Render pipe subsumes extension post-renderers.
|
||||
// The pipe includes compat-shimmed renderers from ctx.renderers.register()
|
||||
// alongside new sw.pipe.render() filters.
|
||||
if (typeof sw !== 'undefined' && sw.pipe) {
|
||||
const renderCtx = {
|
||||
element: container,
|
||||
message: null, // batch render — no single message context
|
||||
channel: {
|
||||
id: typeof App !== 'undefined' ? App.activeId : null,
|
||||
type: typeof App !== 'undefined' ? (App.activeType || 'direct') : 'direct',
|
||||
},
|
||||
};
|
||||
sw.pipe._runRender(renderCtx);
|
||||
return;
|
||||
}
|
||||
// Fallback: SDK not loaded (shouldn't happen, but defensive)
|
||||
if (typeof Extensions !== 'undefined' && Extensions._loaded) {
|
||||
Extensions.runPostRenderers(container);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wrappers for compound onclick expressions ──
|
||||
function _copyCodeBlock(codeId) {
|
||||
const el = document.getElementById(codeId);
|
||||
if (el) navigator.clipboard.writeText(el.textContent).then(() => UI.toast('Copied', 'success'));
|
||||
}
|
||||
function _openNoteFromTool(noteId) {
|
||||
// v0.37.9: old notes.js removed — navigate to notes surface instead
|
||||
if (window.sw?.notesPane) {
|
||||
console.log('[ui-format] Note tool link — navigate to /notes');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
// Cross-file function calls
|
||||
sb.register('formatMessage', formatMessage);
|
||||
sb.register('clearPreview', clearPreview);
|
||||
sb.register('runExtensionPostRender', runExtensionPostRender);
|
||||
sb.register('_livePreviewUpdate', _livePreviewUpdate);
|
||||
sb.register('_relativeTime', _relativeTime);
|
||||
sb.register('_renderToolCallsHTML', _renderToolCallsHTML);
|
||||
sb.register('_registerPreviewPanel', _registerPreviewPanel);
|
||||
// data-action handlers
|
||||
sb.register('toggleCodeCollapse', toggleCodeCollapse);
|
||||
sb.register('toggleHTMLPreview', toggleHTMLPreview);
|
||||
sb.register('downloadCode', downloadCode);
|
||||
sb.register('popOutExtBlock', popOutExtBlock);
|
||||
sb.register('_copyCodeBlock', _copyCodeBlock);
|
||||
sb.register('_openNoteFromTool', _openNoteFromTool);
|
||||
@@ -1,277 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Virtual Scroll
|
||||
// ==========================================
|
||||
// Viewport-windowed message rendering for long conversations.
|
||||
// Renders a window of messages around the viewport. As the user
|
||||
// scrolls, older/newer chunks are rendered on demand and distant
|
||||
// messages are removed from the DOM to keep memory bounded.
|
||||
//
|
||||
// Design: sentinel-based (IntersectionObserver on top/bottom markers)
|
||||
// rather than scroll-listener based. No jank, no debounce.
|
||||
//
|
||||
// Integration: wraps UI._messageHTML() — no rendering duplication.
|
||||
// The container element (#chatMessages) is unchanged.
|
||||
//
|
||||
// Streaming: the actively-streaming message is always in DOM at the
|
||||
// bottom, managed by streamResponse(). VirtualScroll leaves it alone.
|
||||
|
||||
const WINDOW_SIZE = 80; // max messages in DOM at once
|
||||
const BUFFER = 20; // render this many beyond viewport edge
|
||||
const PREPEND_CHUNK = 40; // load this many when scrolling up
|
||||
const LOAD_MORE_THRESHOLD = 100; // only activate for conversations this long
|
||||
|
||||
class VirtualScroll {
|
||||
constructor() {
|
||||
this._messages = []; // full message array (data, not DOM)
|
||||
this._renderFn = null; // (msg, index, dimmed) => html string
|
||||
this._summaryFn = null; // (msg) => html string
|
||||
this._postRenderFn = null; // (container) => void
|
||||
this._container = null;
|
||||
this._observer = null;
|
||||
this._topSentinel = null;
|
||||
this._renderedRange = { start: 0, end: 0 }; // indices into _messages
|
||||
this._enabled = false;
|
||||
this._scrollLock = false; // prevent observer re-entry during DOM mutation
|
||||
this._summaryIdx = -1;
|
||||
}
|
||||
|
||||
// ── Configuration ────────────────────────
|
||||
|
||||
/**
|
||||
* Bind to a container and rendering functions.
|
||||
* @param {HTMLElement} container - the #chatMessages element
|
||||
* @param {Function} renderFn - (msg, index, dimmed?) => html string
|
||||
* @param {Function} summaryFn - (msg) => html string
|
||||
* @param {Function} postRenderFn - (container) => void (extensions, images)
|
||||
*/
|
||||
bind(container, renderFn, summaryFn, postRenderFn) {
|
||||
this._container = container;
|
||||
this._renderFn = renderFn;
|
||||
this._summaryFn = summaryFn;
|
||||
this._postRenderFn = postRenderFn;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────
|
||||
|
||||
/**
|
||||
* Set messages and render. Called by renderMessages().
|
||||
* For short conversations, returns false (caller should use
|
||||
* the original innerHTML path). For long ones, renders the
|
||||
* tail window and returns true.
|
||||
*/
|
||||
setMessages(messages, summaryIdx) {
|
||||
this._messages = messages;
|
||||
this._summaryIdx = summaryIdx;
|
||||
|
||||
if (!this._container || !this._renderFn) return false;
|
||||
|
||||
// Only virtualize long conversations
|
||||
const visible = messages.filter(m => m.role !== 'system');
|
||||
if (visible.length < LOAD_MORE_THRESHOLD) {
|
||||
this._teardown();
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
this._enabled = true;
|
||||
this._renderTail();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether virtual scroll is currently active.
|
||||
*/
|
||||
get active() { return this._enabled; }
|
||||
|
||||
/**
|
||||
* Clean up observers. Call when switching conversations.
|
||||
*/
|
||||
destroy() {
|
||||
this._teardown();
|
||||
this._messages = [];
|
||||
this._enabled = false;
|
||||
}
|
||||
|
||||
// ── Internal: Render ─────────────────────
|
||||
|
||||
/**
|
||||
* Render the tail (most recent) window of messages.
|
||||
* This is the initial render — user sees the latest messages.
|
||||
*/
|
||||
_renderTail() {
|
||||
const msgs = this._filteredMessages();
|
||||
const total = msgs.length;
|
||||
const start = Math.max(0, total - WINDOW_SIZE);
|
||||
const end = total;
|
||||
|
||||
this._renderedRange = { start, end };
|
||||
this._buildDOM(msgs, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend older messages when user scrolls to top.
|
||||
*/
|
||||
_prependChunk() {
|
||||
if (this._scrollLock) return;
|
||||
const msgs = this._filteredMessages();
|
||||
const { start } = this._renderedRange;
|
||||
if (start <= 0) return; // already showing all
|
||||
|
||||
this._scrollLock = true;
|
||||
|
||||
const newStart = Math.max(0, start - PREPEND_CHUNK);
|
||||
const container = this._container;
|
||||
|
||||
// Remember scroll position to restore after prepend
|
||||
const scrollBottom = container.scrollHeight - container.scrollTop;
|
||||
|
||||
// Build HTML for the new chunk
|
||||
let html = '';
|
||||
for (let i = newStart; i < start; i++) {
|
||||
html += this._renderOne(msgs[i], i);
|
||||
}
|
||||
|
||||
// Insert after top sentinel, before existing messages
|
||||
const sentinel = this._topSentinel;
|
||||
if (sentinel) {
|
||||
const frag = document.createRange().createContextualFragment(html);
|
||||
sentinel.after(frag);
|
||||
}
|
||||
|
||||
// Trim bottom if DOM is too large
|
||||
const totalRendered = this._renderedRange.end - newStart;
|
||||
if (totalRendered > WINDOW_SIZE + BUFFER) {
|
||||
const excess = totalRendered - WINDOW_SIZE;
|
||||
this._removeFromBottom(excess);
|
||||
this._renderedRange.end -= excess;
|
||||
}
|
||||
|
||||
this._renderedRange.start = newStart;
|
||||
|
||||
// Restore scroll position (prevent jump)
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight - scrollBottom;
|
||||
if (this._postRenderFn) this._postRenderFn(container);
|
||||
this._scrollLock = false;
|
||||
this._updateSentinelState();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal: DOM ────────────────────────
|
||||
|
||||
_buildDOM(msgs, start, end) {
|
||||
const container = this._container;
|
||||
|
||||
let html = '';
|
||||
|
||||
// Top sentinel (triggers prepend on intersection)
|
||||
html += '<div id="vs-top-sentinel" class="vs-sentinel" style="height:1px"></div>';
|
||||
|
||||
// "Load more" indicator
|
||||
if (start > 0) {
|
||||
html += '<div class="vs-load-more" id="vs-load-more" style="text-align:center;padding:12px 0">' +
|
||||
'<span style="font-size:12px;color:var(--text-3)">' +
|
||||
start + ' earlier messages</span></div>';
|
||||
}
|
||||
|
||||
// Summary boundary
|
||||
if (this._summaryIdx >= 0) {
|
||||
const summaryMsg = this._messages[this._summaryIdx];
|
||||
if (summaryMsg && this._summaryFn) {
|
||||
html += this._summaryFn(summaryMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
for (let i = start; i < end; i++) {
|
||||
html += this._renderOne(msgs[i], i);
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Set up intersection observer on top sentinel
|
||||
this._setupObserver();
|
||||
|
||||
if (this._postRenderFn) this._postRenderFn(container);
|
||||
}
|
||||
|
||||
_renderOne(msg, index) {
|
||||
if (!msg) return '';
|
||||
// Skip system messages and summary messages (summary handled separately)
|
||||
if (msg.role === 'system') return '';
|
||||
if (msg._isSummary) return '';
|
||||
const dimmed = this._summaryIdx >= 0 && index < this._summaryIdx;
|
||||
return this._renderFn(msg, index, dimmed);
|
||||
}
|
||||
|
||||
_removeFromBottom(count) {
|
||||
const container = this._container;
|
||||
const messages = container.querySelectorAll('.message');
|
||||
const toRemove = Array.from(messages).slice(-count);
|
||||
toRemove.forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// ── Observer ─────────────────────────────
|
||||
|
||||
_setupObserver() {
|
||||
this._teardownObserver();
|
||||
|
||||
this._topSentinel = this._container.querySelector('#vs-top-sentinel');
|
||||
if (!this._topSentinel) return;
|
||||
|
||||
this._observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting && entry.target.id === 'vs-top-sentinel') {
|
||||
this._prependChunk();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: this._container,
|
||||
rootMargin: '200px 0px 0px 0px', // trigger 200px before reaching top
|
||||
});
|
||||
|
||||
this._observer.observe(this._topSentinel);
|
||||
}
|
||||
|
||||
_updateSentinelState() {
|
||||
const loadMore = this._container.querySelector('#vs-load-more');
|
||||
if (loadMore) {
|
||||
if (this._renderedRange.start <= 0) {
|
||||
loadMore.style.display = 'none';
|
||||
} else {
|
||||
loadMore.querySelector('span').textContent =
|
||||
this._renderedRange.start + ' earlier messages';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_teardownObserver() {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
}
|
||||
this._topSentinel = null;
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
this._teardownObserver();
|
||||
this._renderedRange = { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns messages with summary detection metadata.
|
||||
* Mirrors the filtering logic from renderMessages().
|
||||
*/
|
||||
_filteredMessages() {
|
||||
return this._messages.filter(m => m.role !== 'system');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Singleton ────────────────────────────
|
||||
|
||||
const virtualScroll = new VirtualScroll();
|
||||
|
||||
window.VirtualScroll = virtualScroll;
|
||||
sb.register('VirtualScroll', virtualScroll);
|
||||
@@ -1,169 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Workflow API Methods
|
||||
// ==========================================
|
||||
// Extends the global API object with workflow CRUD, stage management,
|
||||
// publishing, instance lifecycle, and assignment queue operations.
|
||||
// Loaded after api.js on the chat and admin surfaces.
|
||||
|
||||
// ── Workflow Definitions ────────────────
|
||||
|
||||
API.listWorkflows = function(teamId) {
|
||||
const q = teamId ? `?team_id=${teamId}` : '';
|
||||
return this._get('/api/v1/workflows' + q);
|
||||
};
|
||||
|
||||
API.getWorkflow = function(id) {
|
||||
return this._get(`/api/v1/workflows/${id}`);
|
||||
};
|
||||
|
||||
API.createWorkflow = function(data) {
|
||||
return this._post('/api/v1/workflows', data);
|
||||
};
|
||||
|
||||
API.updateWorkflow = function(id, patch) {
|
||||
return this._patch(`/api/v1/workflows/${id}`, patch);
|
||||
};
|
||||
|
||||
API.deleteWorkflow = function(id) {
|
||||
return this._del(`/api/v1/workflows/${id}`);
|
||||
};
|
||||
|
||||
// ── Stages ──────────────────────────────
|
||||
|
||||
API.listStages = function(workflowId) {
|
||||
return this._get(`/api/v1/workflows/${workflowId}/stages`);
|
||||
};
|
||||
|
||||
API.createStage = function(workflowId, data) {
|
||||
return this._post(`/api/v1/workflows/${workflowId}/stages`, data);
|
||||
};
|
||||
|
||||
API.updateStage = function(workflowId, stageId, data) {
|
||||
return this._put(`/api/v1/workflows/${workflowId}/stages/${stageId}`, data);
|
||||
};
|
||||
|
||||
API.deleteStage = function(workflowId, stageId) {
|
||||
return this._del(`/api/v1/workflows/${workflowId}/stages/${stageId}`);
|
||||
};
|
||||
|
||||
API.reorderStages = function(workflowId, orderedIds) {
|
||||
return this._patch(`/api/v1/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
|
||||
};
|
||||
|
||||
// ── Versioning ──────────────────────────
|
||||
|
||||
API.publishWorkflow = function(workflowId) {
|
||||
return this._post(`/api/v1/workflows/${workflowId}/publish`, {});
|
||||
};
|
||||
|
||||
API.getWorkflowVersion = function(workflowId, version) {
|
||||
return this._get(`/api/v1/workflows/${workflowId}/versions/${version}`);
|
||||
};
|
||||
|
||||
// ── Instances ───────────────────────────
|
||||
|
||||
API.startWorkflow = function(workflowId) {
|
||||
return this._post(`/api/v1/workflows/${workflowId}/start`, {});
|
||||
};
|
||||
|
||||
API.getWorkflowStatus = function(channelId) {
|
||||
return this._get(`/api/v1/channels/${channelId}/workflow/status`);
|
||||
};
|
||||
|
||||
API.advanceWorkflow = function(channelId, data) {
|
||||
return this._post(`/api/v1/channels/${channelId}/workflow/advance`, { data });
|
||||
};
|
||||
|
||||
API.rejectWorkflow = function(channelId, reason) {
|
||||
return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
|
||||
};
|
||||
|
||||
// ── Forms ────────────────────────────────
|
||||
|
||||
API.getWorkflowForm = function(channelId) {
|
||||
return this._get(`/api/v1/w/${channelId}/form`);
|
||||
};
|
||||
|
||||
API.submitWorkflowForm = function(channelId, data) {
|
||||
return this._post(`/api/v1/w/${channelId}/form-submit`, data);
|
||||
};
|
||||
|
||||
// ── Workflow Packages (v0.30.2) ─────────
|
||||
|
||||
API.exportWorkflowPkg = function(workflowId) {
|
||||
// Trigger download via hidden link (binary response)
|
||||
const a = document.createElement('a');
|
||||
a.href = (window.__BASE__ || '') + `/api/v1/admin/workflows/${workflowId}/export`;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
// ── Assignments ─────────────────────────
|
||||
|
||||
API.listMyAssignments = function() {
|
||||
return this._get('/api/v1/workflow-assignments/mine');
|
||||
};
|
||||
|
||||
API.listTeamAssignments = function(teamId, status) {
|
||||
return this._get(`/api/v1/teams/${teamId}/assignments?status=${status || 'unassigned'}`);
|
||||
};
|
||||
|
||||
API.claimAssignment = function(id) {
|
||||
return this._post(`/api/v1/workflow-assignments/${id}/claim`, {});
|
||||
};
|
||||
|
||||
API.completeAssignment = function(id) {
|
||||
return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
|
||||
};
|
||||
|
||||
// ── Team-Scoped Workflow CRUD (v0.31.2) ──
|
||||
|
||||
API.listTeamWorkflows = function(teamId) {
|
||||
return this._get(`/api/v1/teams/${teamId}/workflows`);
|
||||
};
|
||||
|
||||
API.getTeamWorkflow = function(teamId, id) {
|
||||
return this._get(`/api/v1/teams/${teamId}/workflows/${id}`);
|
||||
};
|
||||
|
||||
API.createTeamWorkflow = function(teamId, data) {
|
||||
return this._post(`/api/v1/teams/${teamId}/workflows`, data);
|
||||
};
|
||||
|
||||
API.updateTeamWorkflow = function(teamId, id, patch) {
|
||||
return this._patch(`/api/v1/teams/${teamId}/workflows/${id}`, patch);
|
||||
};
|
||||
|
||||
API.deleteTeamWorkflow = function(teamId, id) {
|
||||
return this._del(`/api/v1/teams/${teamId}/workflows/${id}`);
|
||||
};
|
||||
|
||||
API.listTeamWorkflowStages = function(teamId, workflowId) {
|
||||
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`);
|
||||
};
|
||||
|
||||
API.createTeamWorkflowStage = function(teamId, workflowId, data) {
|
||||
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`, data);
|
||||
};
|
||||
|
||||
API.updateTeamWorkflowStage = function(teamId, workflowId, stageId, data) {
|
||||
return this._put(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`, data);
|
||||
};
|
||||
|
||||
API.deleteTeamWorkflowStage = function(teamId, workflowId, stageId) {
|
||||
return this._del(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`);
|
||||
};
|
||||
|
||||
API.reorderTeamWorkflowStages = function(teamId, workflowId, orderedIds) {
|
||||
return this._patch(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
|
||||
};
|
||||
|
||||
API.publishTeamWorkflow = function(teamId, workflowId) {
|
||||
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/publish`, {});
|
||||
};
|
||||
|
||||
API.getTeamWorkflowVersion = function(teamId, workflowId, version) {
|
||||
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/versions/${version}`);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
// workflow-monitor.js — v0.35.0 Workflow Monitoring Dashboard
|
||||
//
|
||||
// Admin monitoring tab showing active instances, stage funnels,
|
||||
// SLA status indicators, and stale instance detection.
|
||||
|
||||
export function mountMonitorTab(container, { basePath }) {
|
||||
container.innerHTML = `
|
||||
<div class="wf-monitor">
|
||||
<div class="wf-monitor-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h3 style="font-size:16px;font-weight:600">Workflow Monitor</h3>
|
||||
<button id="monRefresh" class="btn btn-sm btn-outline">Refresh</button>
|
||||
</div>
|
||||
<div id="monInstances" style="margin-bottom:24px">Loading…</div>
|
||||
<div id="monStale" style="margin-bottom:24px"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
async function loadInstances() {
|
||||
try {
|
||||
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/instances`);
|
||||
if (!resp.ok) throw new Error(resp.statusText);
|
||||
const { data } = await resp.json();
|
||||
renderInstances(data);
|
||||
} catch (e) {
|
||||
document.getElementById('monInstances').textContent = 'Failed to load: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStale() {
|
||||
try {
|
||||
const resp = await fetch(`${basePath}/api/v1/admin/workflows/monitor/stale`);
|
||||
if (!resp.ok) return;
|
||||
const { data } = await resp.json();
|
||||
renderStale(data);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderInstances(instances) {
|
||||
const el = document.getElementById('monInstances');
|
||||
if (!instances.length) {
|
||||
el.innerHTML = '<p style="color:var(--text-3)">No active workflow instances.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table style="width:100%;border-collapse:collapse;font-size:13px">';
|
||||
html += '<thead><tr style="border-bottom:2px solid var(--border);text-align:left">';
|
||||
html += '<th style="padding:8px">Workflow</th><th style="padding:8px">Stage</th>';
|
||||
html += '<th style="padding:8px">Age</th><th style="padding:8px">SLA</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const inst of instances) {
|
||||
const age = formatDuration(inst.age_seconds);
|
||||
const sla = renderSLA(inst);
|
||||
html += `<tr style="border-bottom:1px solid var(--border)">`;
|
||||
html += `<td style="padding:8px">${esc(inst.workflow_name)}<br><span style="font-size:11px;color:var(--text-3)">${esc(inst.channel_title)}</span></td>`;
|
||||
html += `<td style="padding:8px">${esc(inst.stage_name)} <span style="color:var(--text-3)">(${inst.current_stage})</span></td>`;
|
||||
html += `<td style="padding:8px">${age}</td>`;
|
||||
html += `<td style="padding:8px">${sla}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSLA(inst) {
|
||||
if (!inst.sla_seconds) return '<span style="color:var(--text-3)">—</span>';
|
||||
if (inst.sla_breached) {
|
||||
return '<span style="color:var(--danger,#e74c3c);font-weight:600">BREACHED</span>';
|
||||
}
|
||||
if (inst.sla_remaining_seconds != null) {
|
||||
const remaining = inst.sla_remaining_seconds;
|
||||
const pct = Math.max(0, remaining / inst.sla_seconds);
|
||||
const color = pct > 0.5 ? 'var(--success,#2ecc71)' : pct > 0.2 ? 'var(--warning,#f39c12)' : 'var(--danger,#e74c3c)';
|
||||
return `<span style="color:${color}">${formatDuration(remaining)}</span>`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
function renderStale(instances) {
|
||||
const el = document.getElementById('monStale');
|
||||
if (!instances.length) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
let html = '<h4 style="font-size:14px;color:var(--danger,#e74c3c);margin-bottom:8px">Stale Instances (' + instances.length + ')</h4>';
|
||||
html += '<ul style="list-style:none;padding:0">';
|
||||
for (const inst of instances) {
|
||||
html += `<li style="padding:4px 0;font-size:13px">${esc(inst.workflow_name)} — ${esc(inst.stage_name)} (${formatDuration(inst.age_seconds)} old)</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) return seconds + 's';
|
||||
if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
|
||||
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
|
||||
return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadInstances();
|
||||
loadStale();
|
||||
|
||||
// Auto-refresh every 30s
|
||||
refreshTimer = setInterval(() => {
|
||||
loadInstances();
|
||||
loadStale();
|
||||
}, 30000);
|
||||
|
||||
document.getElementById('monRefresh')?.addEventListener('click', () => {
|
||||
loadInstances();
|
||||
loadStale();
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
};
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Workflow Queue UI
|
||||
// ==========================================
|
||||
// Sidebar section showing:
|
||||
// 1. Active workflow channels (type='workflow' from App.chats)
|
||||
// 2. Claimed assignments from team queues
|
||||
// Loaded on the chat surface after workflow-api.js.
|
||||
|
||||
|
||||
const WorkflowQueue = {
|
||||
|
||||
_loaded: false,
|
||||
_myAssignments: [],
|
||||
|
||||
// ── Init ────────────────────────────
|
||||
|
||||
async init() {
|
||||
if (this._loaded) return;
|
||||
this._loaded = true;
|
||||
|
||||
// Insert sidebar section after channels
|
||||
var channelsSec = document.getElementById('sbSectionChannels');
|
||||
if (!channelsSec) return;
|
||||
|
||||
var section = document.createElement('div');
|
||||
section.className = 'sb-section';
|
||||
section.id = 'sbSectionQueue';
|
||||
section.innerHTML =
|
||||
'<div class="sb-section-header" data-action="UI.toggleSidebarSection" data-args=\'["queue"]\'>' +
|
||||
'<span class="sb-section-arrow" id="sbArrowQueue">▾</span>' +
|
||||
'<span class="sb-section-label">Workflows</span>' +
|
||||
'<span class="sb-queue-badge" id="sbQueueBadge" style="display:none">0</span>' +
|
||||
'</div>' +
|
||||
'<div class="sb-section-body" id="sbBodyQueue"></div>';
|
||||
|
||||
channelsSec.parentNode.insertBefore(section, channelsSec.nextSibling);
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
// ── Pinned set ────────────────────────
|
||||
// Only show workflow channels the user has explicitly opened/pinned.
|
||||
// Stored in localStorage to persist across sessions.
|
||||
|
||||
_getPinned() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('sb_wf_pinned') || '[]');
|
||||
} catch (_) { return []; }
|
||||
},
|
||||
|
||||
_pin(channelId) {
|
||||
var pinned = this._getPinned();
|
||||
if (pinned.indexOf(channelId) === -1) {
|
||||
pinned.push(channelId);
|
||||
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
|
||||
}
|
||||
},
|
||||
|
||||
_unpin(channelId) {
|
||||
var pinned = this._getPinned().filter(function(id) { return id !== channelId; });
|
||||
localStorage.setItem('sb_wf_pinned', JSON.stringify(pinned));
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
// ── Refresh ─────────────────────────
|
||||
// Called after loadChats() and on assignment changes.
|
||||
|
||||
async refresh() {
|
||||
var body = document.getElementById('sbBodyQueue');
|
||||
var badge = document.getElementById('sbQueueBadge');
|
||||
if (!body) return;
|
||||
|
||||
var self = this;
|
||||
var pinned = this._getPinned();
|
||||
|
||||
// 1. Workflow channels — only show pinned ones
|
||||
var allWorkflows = (App.chats || []).filter(function(c) {
|
||||
return c.type === 'workflow';
|
||||
});
|
||||
var workflows = allWorkflows.filter(function(c) {
|
||||
return pinned.indexOf(c.id) !== -1;
|
||||
});
|
||||
|
||||
// 2. Claimed assignments (async, best-effort)
|
||||
var assignments = [];
|
||||
try {
|
||||
var resp = await API.listMyAssignments();
|
||||
assignments = resp.data || [];
|
||||
this._myAssignments = assignments;
|
||||
// Auto-pin channels from assignments
|
||||
assignments.forEach(function(a) {
|
||||
if (a.channel_id) self._pin(a.channel_id);
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-critical — show channels even if assignments fail
|
||||
}
|
||||
|
||||
var total = workflows.length + assignments.length;
|
||||
var unpinnedCount = allWorkflows.length - workflows.length;
|
||||
if (badge) {
|
||||
badge.textContent = total;
|
||||
badge.style.display = total > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// Workflow channels (pinned only)
|
||||
workflows.forEach(function(wf) {
|
||||
var isActive = App.activeId === wf.id;
|
||||
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
|
||||
'data-action="selectChannel" data-args=\'' + JSON.stringify([wf.id]) + '\' ' +
|
||||
'title="' + esc(wf.title) + '">' +
|
||||
'<span class="sb-queue-icon">⚡</span>' +
|
||||
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
|
||||
'<button class="sb-queue-unpin" data-action="WorkflowQueue.unpin" data-args=\'' + JSON.stringify([wf.id]) + '\' title="Remove from sidebar">×</button>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
// Claimed assignments (deduplicated against channel list)
|
||||
if (assignments.length > 0) {
|
||||
assignments.forEach(function(a) {
|
||||
var alreadyShown = workflows.some(function(w) { return w.id === a.channel_id; });
|
||||
if (alreadyShown) return;
|
||||
|
||||
html += '<div class="sb-queue-item" ' +
|
||||
'data-action="WorkflowQueue.openAssignment" data-args=\'' + JSON.stringify([a.id, a.channel_id]) + '\'>' +
|
||||
'<span class="sb-queue-icon">📋</span>' +
|
||||
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
|
||||
'<button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>✓</button>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Browse button — shows when there are unpinned workflows available
|
||||
if (unpinnedCount > 0 || total === 0) {
|
||||
html += '<div class="sb-queue-browse" data-action="WorkflowQueue.browse">' +
|
||||
'<span class="sb-queue-icon">+</span>' +
|
||||
'<span class="sb-queue-label">Browse Workflows' + (unpinnedCount > 0 ? ' (' + unpinnedCount + ')' : '') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Unpin ──────────────────────────
|
||||
|
||||
unpin(channelId) {
|
||||
this._unpin(channelId);
|
||||
},
|
||||
|
||||
// ── Browse dialog ─────────────────────
|
||||
|
||||
browse() {
|
||||
var self = this;
|
||||
var pinned = this._getPinned();
|
||||
var allWorkflows = (App.chats || []).filter(function(c) {
|
||||
return c.type === 'workflow';
|
||||
});
|
||||
var unpinned = allWorkflows.filter(function(c) {
|
||||
return pinned.indexOf(c.id) === -1;
|
||||
});
|
||||
|
||||
if (unpinned.length === 0) {
|
||||
UI.toast('No available workflows', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing browse overlay
|
||||
var existing = document.getElementById('wfBrowseOverlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'wfBrowseOverlay';
|
||||
overlay.className = 'confirm-overlay';
|
||||
|
||||
var rows = '';
|
||||
unpinned.forEach(function(wf) {
|
||||
rows += '<div class="wf-browse-row" data-wf-id="' + wf.id + '">' +
|
||||
'<span class="sb-queue-icon">⚡</span>' +
|
||||
'<span>' + esc(wf.title || 'Workflow') + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="confirm-dialog" style="max-width:400px">' +
|
||||
'<div class="confirm-header">Browse Workflows</div>' +
|
||||
'<div class="confirm-body" style="padding:0">' +
|
||||
'<p style="color:var(--text-2);font-size:13px;margin:0;padding:12px 16px 8px">Select a workflow to add to your sidebar:</p>' +
|
||||
'<div class="wf-browse-dialog">' + rows + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="confirm-footer">' +
|
||||
'<button class="btn-small" data-action="cancel">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
overlay.addEventListener('click', function(e) {
|
||||
var row = e.target.closest('.wf-browse-row');
|
||||
if (row) {
|
||||
var wfId = row.getAttribute('data-wf-id');
|
||||
overlay.remove();
|
||||
self._pin(wfId);
|
||||
self.refresh();
|
||||
if (typeof selectChannel === 'function') selectChannel(wfId);
|
||||
return;
|
||||
}
|
||||
if (e.target === overlay || e.target.closest('[data-action="cancel"]')) {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
|
||||
// ── Open Assignment ─────────────────
|
||||
|
||||
openAssignment(assignmentId, channelId) {
|
||||
this._pin(channelId);
|
||||
if (typeof selectChannel === 'function') {
|
||||
selectChannel(channelId);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Claim ───────────────────────────
|
||||
|
||||
async claim(assignmentId) {
|
||||
try {
|
||||
await API.claimAssignment(assignmentId);
|
||||
UI.toast('Assignment claimed', 'success');
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to claim: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Complete ────────────────────────
|
||||
|
||||
async complete(assignmentId) {
|
||||
try {
|
||||
await API.completeAssignment(assignmentId);
|
||||
UI.toast('Assignment completed', 'success');
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
UI.toast('Failed: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Team Queue Panel ────────────────
|
||||
|
||||
async openTeamPanel(teamId) {
|
||||
try {
|
||||
var unassigned = await API.listTeamAssignments(teamId, 'unassigned');
|
||||
var claimed = await API.listTeamAssignments(teamId, 'claimed');
|
||||
var uItems = unassigned.data || [];
|
||||
var cItems = claimed.data || [];
|
||||
|
||||
if (uItems.length === 0 && cItems.length === 0) {
|
||||
UI.toast('Queue is empty', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<div class="wf-queue-panel"><h4>Team Queue</h4>';
|
||||
|
||||
if (uItems.length > 0) {
|
||||
html += '<h5>Unassigned (' + uItems.length + ')</h5>';
|
||||
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Created</th><th></th></tr></thead><tbody>';
|
||||
uItems.forEach(function(a) {
|
||||
html += '<tr><td>Stage ' + a.stage + '</td>' +
|
||||
'<td>' + new Date(a.created_at).toLocaleDateString() + '</td>' +
|
||||
'<td><button class="btn-small btn-primary" data-action="WorkflowQueue.claim" data-args=\'' + JSON.stringify([a.id]) + '\'>Claim</button></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
if (cItems.length > 0) {
|
||||
html += '<h5>In Progress (' + cItems.length + ')</h5>';
|
||||
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Assigned</th><th></th></tr></thead><tbody>';
|
||||
cItems.forEach(function(a) {
|
||||
html += '<tr><td>Stage ' + a.stage + '</td>' +
|
||||
'<td>' + (a.assigned_to || '—') + '</td>' +
|
||||
'<td><button class="btn-small" data-action="WorkflowQueue.complete" data-args=\'' + JSON.stringify([a.id]) + '\'>Complete</button></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (typeof showModal === 'function') {
|
||||
showModal('Team Queue', html);
|
||||
} else {
|
||||
var el = document.getElementById('adminDynamic');
|
||||
if (el) el.innerHTML = html;
|
||||
}
|
||||
} catch (e) {
|
||||
UI.toast('Failed to load queue: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
sb.ns('WorkflowQueue', WorkflowQueue);
|
||||
|
||||
// Auto-init when chat surface loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (typeof API !== 'undefined' && API.accessToken) {
|
||||
WorkflowQueue.init();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
46
src/sw.js
46
src/sw.js
@@ -11,38 +11,44 @@
|
||||
|
||||
const CACHE_NAME = 'switchboard-%%APP_VERSION%%-%%BUILD_HASH%%';
|
||||
|
||||
// App shell files to pre-cache on install
|
||||
// App shell files to pre-cache on install (cleaned up in v0.37.12)
|
||||
const SHELL_FILES = [
|
||||
'./',
|
||||
'./index.html',
|
||||
// CSS — shared
|
||||
'./css/variables.css',
|
||||
'./css/layout.css',
|
||||
'./css/primitives.css',
|
||||
'./css/modals.css',
|
||||
'./css/chat.css',
|
||||
'./css/panels.css',
|
||||
'./css/surfaces.css',
|
||||
'./css/splash.css',
|
||||
'./js/debug.js',
|
||||
'./css/user-menu.css',
|
||||
'./css/workflow.css',
|
||||
'./css/admin-surfaces.css',
|
||||
'./css/tool-grants.css',
|
||||
'./css/extension-surface.css',
|
||||
// CSS — Preact (sw-*)
|
||||
'./css/sw-primitives.css',
|
||||
'./css/sw-shell.css',
|
||||
'./css/sw-login.css',
|
||||
'./css/sw-chat-pane.css',
|
||||
'./css/sw-chat-surface.css',
|
||||
'./css/sw-notes-pane.css',
|
||||
'./css/sw-notes-surface.css',
|
||||
// JS — platform globals (extension + debug deps)
|
||||
'./js/sb.js',
|
||||
'./js/app-state.js',
|
||||
'./js/events.js',
|
||||
'./js/extensions.js',
|
||||
'./js/api.js',
|
||||
'./js/ui-format.js',
|
||||
'./js/ui-primitives.js',
|
||||
'./js/ui-core.js',
|
||||
'./js/ui-settings.js',
|
||||
'./js/ui-admin.js',
|
||||
'./js/tokens.js',
|
||||
'./js/notes.js',
|
||||
'./js/files.js',
|
||||
'./js/tools-toggle.js',
|
||||
'./js/knowledge-ui.js',
|
||||
'./js/chat.js',
|
||||
'./js/settings-handlers.js',
|
||||
'./js/admin-handlers.js',
|
||||
'./js/app.js',
|
||||
'./js/user-menu.js',
|
||||
'./js/file-tree.js',
|
||||
'./js/code-editor.js',
|
||||
'./js/switchboard-sdk.js',
|
||||
'./js/debug.js',
|
||||
'./js/repl.js',
|
||||
// Vendor
|
||||
'./vendor/marked.min.js',
|
||||
'./vendor/purify.min.js',
|
||||
// Static assets
|
||||
'./favicon.svg',
|
||||
'./favicon.ico',
|
||||
'./favicon-32.png',
|
||||
|
||||
Reference in New Issue
Block a user