Changeset 0.37.10 (#222)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -1,412 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — ChatPane Component CSS
|
||||
==========================================
|
||||
v0.25.0-cs11.1: Self-contained styles for the ChatPane
|
||||
component. Works in any context — sidebar chat, editor
|
||||
assist pane, standalone embed. No external CSS dependencies.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.chat-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Header Bar (standalone mode) ──────────── */
|
||||
|
||||
.chat-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
flex-shrink: 0;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.chat-pane-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-pane-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-pane-chat-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane-chat-select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-pane-new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #999);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-new-btn:hover {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
}
|
||||
|
||||
/* Model selector in header */
|
||||
.chat-pane-model-select {
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-pane-model-select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Messages Area ─────────────────────────── */
|
||||
|
||||
.chat-pane-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
padding: 12px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Message Bubbles ───────────────────────── */
|
||||
|
||||
.chat-msg {
|
||||
padding: 8px 16px;
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.65;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.chat-msg + .chat-msg {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* User messages — right-aligned bubble */
|
||||
.chat-msg--user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chat-msg--user .chat-msg__content {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
padding: 8px 14px;
|
||||
border-radius: 12px 12px 2px 12px;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Assistant messages — left-aligned */
|
||||
.chat-msg--assistant {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chat-msg--assistant .chat-msg__content {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* ── Streaming indicator ───────────────────── */
|
||||
|
||||
.chat-msg--streaming .chat-msg__content::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: var(--accent, #b38a4e);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cp-cursor-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes cp-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Typing dots ───────────────────────────── */
|
||||
|
||||
.chat-msg--typing {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* System/error messages */
|
||||
.chat-msg--system {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3, #555);
|
||||
animation: cp-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-msg--typing .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-msg--typing .typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes cp-dot-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────── */
|
||||
|
||||
.chat-msg__content p { margin: 0 0 0.5em; }
|
||||
.chat-msg__content p:last-child { margin-bottom: 0; }
|
||||
|
||||
.chat-msg__content pre {
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.chat-msg__content code {
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.88em;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.chat-msg__content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.chat-msg__content ul, .chat-msg__content ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.chat-msg__content blockquote {
|
||||
border-left: 3px solid var(--accent, #b38a4e);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.chat-msg__content a {
|
||||
color: var(--accent, #b38a4e);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chat-msg__content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-msg__content table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-msg__content th,
|
||||
.chat-msg__content td {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.chat-msg__content th {
|
||||
background: var(--bg-secondary, #151517);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Welcome / Empty State ─────────────────── */
|
||||
|
||||
.chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chat-welcome__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.chat-welcome__hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Input Bar ─────────────────────────────── */
|
||||
|
||||
.chat-pane-input-bar {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.chat-pane-input-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-input-wrap:focus-within {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.chat-pane-input {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
color: var(--text, #eee);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* CM6 editor inside chat-pane-input */
|
||||
.chat-pane-input .cm-editor {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor .cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor .cm-line {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-pane-input .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Textarea fallback */
|
||||
.chat-pane-input textarea {
|
||||
width: 100%;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Send Button ───────────────────────────── */
|
||||
|
||||
.chat-pane-send {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.chat-pane-send:hover {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent-hover, #c9a05e);
|
||||
}
|
||||
|
||||
.chat-pane-send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────── */
|
||||
|
||||
.chat-pane-toolbar {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-pane-toolbar:empty {
|
||||
display: none;
|
||||
margin: 0;
|
||||
}
|
||||
648
src/css/chat.css
648
src/css/chat.css
@@ -1,648 +0,0 @@
|
||||
/* ── chat.css ────────────────────────────────
|
||||
Chat area, model dropdown, markdown, input, files
|
||||
──────────────────────────────────────────── */
|
||||
|
||||
/* ── Chat Area ───────────────────────────── */
|
||||
|
||||
/* .chat-area is now .workspace-primary — alias kept for compat */
|
||||
.chat-area, .workspace-primary { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
|
||||
|
||||
/* Role fallback banner */
|
||||
.role-fallback-banner {
|
||||
background: var(--warning-dim); color: var(--warning);
|
||||
text-align: center; font-size: 12px; font-weight: 500;
|
||||
padding: 6px 12px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat header (model selector + caps + token count + panel toggle) */
|
||||
.chat-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chat-header-right {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.token-count {
|
||||
font-size: 11px; color: var(--text-3);
|
||||
font-family: var(--mono); white-space: nowrap;
|
||||
}
|
||||
.summarized-badge {
|
||||
font-size: 11px; color: var(--text-3); white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
/* v0.23.2: Channel context banner */
|
||||
.channel-context-banner {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.channel-context-banner .ctx-mode {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.channel-context-banner .ctx-mode-btn {
|
||||
cursor: pointer; border: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.channel-context-banner .ctx-mode-btn:hover {
|
||||
background: var(--bg-hover); color: var(--text);
|
||||
}
|
||||
.channel-context-banner .ctx-topic {
|
||||
color: var(--text-2); font-size: 12px;
|
||||
}
|
||||
.channel-context-banner .ctx-hint {
|
||||
color: var(--text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
.ctx-participants-btn {
|
||||
margin-left: auto;
|
||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text-2); font-size: 11px; padding: 2px 8px; cursor: pointer;
|
||||
}
|
||||
.ctx-participants-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.hover-bg:hover { background: var(--bg-hover); }
|
||||
|
||||
/* Message container (scrollable) */
|
||||
.msg-container {
|
||||
flex: 1; overflow-y: auto; scroll-behavior: smooth;
|
||||
padding-top: 12px; padding-bottom: 8px;
|
||||
}
|
||||
.msg-container::-webkit-scrollbar { width: 5px; }
|
||||
.msg-container::-webkit-scrollbar-track { background: transparent; }
|
||||
.msg-container::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border); }
|
||||
|
||||
/* Streaming tools display */
|
||||
.stream-tools {
|
||||
padding: 8px 24px; flex-shrink: 0;
|
||||
font-size: 12px; color: var(--text-3);
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
|
||||
.model-bar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; flex-shrink: 0;
|
||||
}
|
||||
.model-bar select {
|
||||
background: transparent; border: 1px solid transparent;
|
||||
color: var(--text); font-size: 14px; font-weight: 500;
|
||||
padding: 4px 8px; border-radius: var(--radius); cursor: pointer;
|
||||
font-family: var(--font); max-width: 300px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.model-bar select:hover { border-color: var(--border); }
|
||||
.model-bar select:focus { outline: none; border-color: var(--accent); }
|
||||
.model-bar select option { background: var(--bg-surface); color: var(--text); }
|
||||
|
||||
/* ── Custom Model Dropdown ───────────────── */
|
||||
|
||||
.model-dropdown { position: relative; }
|
||||
.model-dropdown-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: none; border: 1px solid transparent;
|
||||
color: var(--text); font-size: 14px; font-weight: 500;
|
||||
padding: 4px 8px; border-radius: var(--radius); cursor: pointer;
|
||||
font-family: var(--font); max-width: 340px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.model-dropdown-btn:hover { border-color: var(--border); }
|
||||
.model-dropdown-btn svg { flex-shrink: 0; opacity: 0.5; }
|
||||
.model-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.model-dropdown-menu {
|
||||
display: none; position: absolute; top: 100%; left: 0; z-index: 200;
|
||||
min-width: 280px; max-width: 400px; max-height: 400px; overflow-y: auto;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius); padding: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.model-dropdown-menu.open { display: block; }
|
||||
.model-dropdown-group {
|
||||
padding: 6px 10px 4px; font-size: 11px; font-weight: 600;
|
||||
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.model-dropdown-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; border-radius: 4px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
color: var(--text-2); white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.model-dropdown-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); }
|
||||
.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); }
|
||||
.dropdown-avatar { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.model-caps {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
margin-left: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.cap-badge {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
font-size: 10px; line-height: 1; padding: 2px 6px;
|
||||
border-radius: 3px; white-space: nowrap;
|
||||
background: var(--bg-raised); color: var(--text-3);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.cap-badge.cap-accent {
|
||||
color: var(--accent); border-color: color-mix(in srgb, var(--accent), transparent 80%);
|
||||
background: color-mix(in srgb, var(--accent), transparent 92%);
|
||||
}
|
||||
.cap-badge.cap-context {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Messages — Bubble Layout (matching prototype) */
|
||||
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
|
||||
|
||||
.message { padding: 10px 24px; }
|
||||
|
||||
.msg-inner { max-width: 768px; margin: 0 auto; display: flex; gap: 10px; }
|
||||
|
||||
/* User messages: same fixed-width container, mirrored */
|
||||
.message.user .msg-inner { flex-direction: row-reverse; }
|
||||
.message.user .msg-body {
|
||||
background: var(--user-bubble);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.message.user .msg-head { flex-direction: row-reverse; }
|
||||
.message.user .msg-role { color: var(--accent); }
|
||||
.message.user .msg-avatar { color: var(--accent); }
|
||||
|
||||
/* Assistant messages: left-aligned bubble */
|
||||
.message.assistant .msg-body {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; flex-shrink: 0; margin-top: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg-avatar-img {
|
||||
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
|
||||
}
|
||||
.message.user .msg-avatar { background: var(--accent-dim); }
|
||||
.message.assistant .msg-avatar { background: var(--bg-raised); }
|
||||
|
||||
/* Other human's messages — left-aligned like assistant, teal accent */
|
||||
.message.other-user .msg-body {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.message.other-user .msg-role { color: #2dd4bf; }
|
||||
.message.other-user .msg-avatar { background: rgba(45,212,191,0.12); color: #2dd4bf; }
|
||||
|
||||
.msg-body { flex: 1; min-width: 0; }
|
||||
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.msg-role { font-size: 13px; font-weight: 600; }
|
||||
.msg-time { font-size: 11px; color: var(--text-3); }
|
||||
.msg-actions {
|
||||
margin-left: auto; display: flex; gap: 2px; opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
.message:hover .msg-actions { opacity: 1; }
|
||||
.msg-action-btn {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
padding: 2px 6px; border-radius: 4px; font-size: 11px;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* Branch navigation indicator */
|
||||
.branch-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.branch-arrow {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
padding: 0 5px;
|
||||
font-size: 16px;
|
||||
line-height: 18px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.branch-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
||||
.branch-arrow:disabled { opacity: 0.25; cursor: default; }
|
||||
.branch-pos {
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Inline message editing */
|
||||
.msg-edit-input {
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font);
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
max-height: 400px;
|
||||
outline: none;
|
||||
}
|
||||
.msg-edit-input:focus { border-color: var(--accent-hover); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
.msg-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-edit-cancel, .msg-edit-submit {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.msg-edit-cancel {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.msg-edit-submit {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.msg-edit-submit:hover { background: var(--accent-hover); }
|
||||
|
||||
.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; }
|
||||
|
||||
/* ── Markdown Content ────────────────────── */
|
||||
|
||||
.msg-text p { margin: 0.4em 0; }
|
||||
.msg-text p:first-child { margin-top: 0; }
|
||||
.msg-text p:last-child { margin-bottom: 0; }
|
||||
|
||||
.msg-text pre {
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem; border-radius: var(--radius);
|
||||
overflow-x: auto; margin: 0.75rem 0; position: relative;
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg-text code {
|
||||
font-family: var(--mono); font-size: 0.9em;
|
||||
background: var(--bg-raised); padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.msg-text pre code { background: none; padding: 0; font-size: inherit; }
|
||||
|
||||
/* Code block toolbar */
|
||||
.code-toolbar {
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
display: flex; gap: 4px; align-items: center;
|
||||
opacity: 0; transition: opacity var(--transition);
|
||||
}
|
||||
.msg-text pre:hover .code-toolbar { opacity: 1; }
|
||||
.code-block.code-collapsed .code-toolbar { opacity: 1; }
|
||||
.copy-code-btn {
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
color: var(--text-3); border-radius: 4px;
|
||||
padding: 2px 10px; font-size: 11px; cursor: pointer;
|
||||
transition: color var(--transition), border-color var(--transition);
|
||||
}
|
||||
.copy-code-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
.code-collapse-btn {
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
color: var(--text-3); border-radius: 4px;
|
||||
padding: 2px 10px; font-size: 11px; cursor: pointer;
|
||||
font-family: var(--mono); white-space: nowrap;
|
||||
transition: color var(--transition), border-color var(--transition);
|
||||
}
|
||||
.code-collapse-btn:hover { color: var(--text); border-color: var(--border-light); }
|
||||
|
||||
/* Collapsed code block */
|
||||
.code-block.code-collapsed code {
|
||||
max-height: 3.6em; overflow: hidden; display: block;
|
||||
mask-image: linear-gradient(to bottom, #000 40%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 40%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Language label */
|
||||
.code-lang {
|
||||
position: absolute; top: 6px; left: 10px;
|
||||
font-size: 10px; color: var(--text-3); font-family: var(--mono);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; user-select: none;
|
||||
}
|
||||
|
||||
/* ── Extension-rendered blocks ──────── */
|
||||
|
||||
.ext-rendered {
|
||||
margin: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
.ext-popout-btn {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
color: var(--text-3); cursor: pointer; font-size: 14px;
|
||||
width: 26px; height: 26px; border-radius: var(--radius);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity 0.15s, background 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
.ext-rendered:hover .ext-popout-btn { opacity: 1; }
|
||||
.ext-popout-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* HTML preview */
|
||||
|
||||
/* ── Input Area ──────────────────────────── */
|
||||
|
||||
.input-area { padding: 0 1rem 1rem; flex-shrink: 0; }
|
||||
.input-area:empty { padding: 0; min-height: 0; }
|
||||
|
||||
/* Token counter below input */
|
||||
.input-meta { display: flex; justify-content: space-between; align-items: center; padding: 2px 12px 0; min-height: 18px; }
|
||||
.input-token-count { font-size: 0.68rem; color: var(--text-3); transition: color 0.2s; }
|
||||
.input-token-count.warning { color: var(--warning); }
|
||||
.input-token-count.danger { color: var(--danger); }
|
||||
|
||||
/* Context length warning banner */
|
||||
.context-warning {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; margin-bottom: 8px; border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
font-size: 0.78rem; color: var(--text-2); animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.context-warning.danger {
|
||||
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
}
|
||||
.context-warning-icon { flex-shrink: 0; }
|
||||
.context-warning-text { flex: 1; }
|
||||
.context-warning-dismiss { background: none; border: none; color: var(--text-3); cursor: pointer; padding: 0 2px; font-size: 0.85rem; }
|
||||
.context-warning-dismiss:hover { color: var(--text); }
|
||||
.context-warning-action {
|
||||
flex-shrink: 0; padding: 3px 10px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--bg-surface); color: var(--text); cursor: pointer;
|
||||
font-size: 0.75rem; white-space: nowrap; transition: all var(--transition);
|
||||
}
|
||||
.context-warning-action:hover { background: var(--accent); color: var(--text-on-color); border-color: var(--accent); }
|
||||
.context-warning-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.context-warning-action:disabled:hover { background: var(--bg-surface); color: var(--text); border-color: var(--border); }
|
||||
.context-warning-toggle {
|
||||
display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||
font-size: 0.72rem; color: var(--text-3); cursor: pointer; white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.context-warning-toggle input[type="checkbox"] { margin: 0; cursor: pointer; }
|
||||
/* Summary message node */
|
||||
.message-summary {
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
border-radius: 8px; padding: 10px 14px; margin: 4px 0;
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
font-size: 0.85rem; position: relative;
|
||||
}
|
||||
.message-summary .summary-header {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
|
||||
font-size: 0.75rem; color: var(--text-3); font-weight: 500;
|
||||
}
|
||||
.message-summary .summary-toggle {
|
||||
background: none; border: none; color: var(--accent); cursor: pointer;
|
||||
font-size: 0.75rem; padding: 0; text-decoration: underline;
|
||||
}
|
||||
.message-summary .summary-toggle:hover { color: var(--text); }
|
||||
.msg-dimmed { opacity: 0.5; }
|
||||
.message-summary-divider {
|
||||
text-align: center; padding: 8px 0; margin: 4px 0;
|
||||
}
|
||||
.message-summary-divider .summary-toggle {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
font-size: 0.78rem; padding: 4px 12px; border-radius: 4px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.message-summary-divider .summary-toggle:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.input-wrap {
|
||||
max-width: 768px; margin: 0 auto; width: 100%; box-sizing: border-box;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 0;
|
||||
display: flex; align-items: flex-end;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.input-wrap:focus-within { border-color: var(--border-light); }
|
||||
|
||||
.input-wrap textarea {
|
||||
flex: 1; resize: none; background: none; border: none;
|
||||
color: var(--text); padding: 12px 0 12px 14px;
|
||||
font-family: var(--font); font-size: 14px;
|
||||
max-height: 200px; line-height: 1.5;
|
||||
}
|
||||
.input-wrap textarea:focus { outline: none; }
|
||||
.input-wrap textarea::placeholder { color: var(--text-3); }
|
||||
|
||||
/* CM6 chat input — match textarea layout within .input-wrap */
|
||||
#messageInputWrap {
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
#messageInputWrap .cm-editor {
|
||||
background: transparent;
|
||||
padding: 12px 0 12px 8px;
|
||||
max-height: 200px;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#messageInputWrap .cm-editor.cm-focused { outline: none; }
|
||||
#messageInputWrap .cm-scroller { overflow-y: auto; }
|
||||
|
||||
/* CM6 extension editor containers */
|
||||
.ext-edit-form .cm-editor {
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; }
|
||||
|
||||
/* Chat input area wrapper (bottom pinned, padded) */
|
||||
.chat-input-area {
|
||||
padding: 0 16px 16px; flex-shrink: 0;
|
||||
}
|
||||
.chat-footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 4px 4px 0; max-width: 768px; margin: 0 auto;
|
||||
min-height: 18px;
|
||||
}
|
||||
.chat-version { font-size: 10px; color: var(--text-3); }
|
||||
.input-token-count {
|
||||
font-size: 10px; color: var(--text-3);
|
||||
font-family: var(--mono); margin-right: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Left-side toolbar: attach, tools, KB — single container handles alignment */
|
||||
.input-toolbar {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 4px 6px; flex-shrink: 0;
|
||||
max-width: 768px; margin: 0 auto; width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
padding: 6px; border-radius: var(--radius);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.send-btn { color: var(--accent); }
|
||||
.send-btn:hover { background: var(--accent-dim); color: var(--accent-hover); }
|
||||
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.stop-btn { display: none; color: var(--warning); }
|
||||
.stop-btn.visible { display: flex; }
|
||||
|
||||
/* ── Files ─────────────────────────── */
|
||||
|
||||
.attach-btn { color: var(--text-3); }
|
||||
.attach-btn:hover { color: var(--text); }
|
||||
|
||||
.file-strip {
|
||||
max-width: 768px; margin: 0 auto; padding: 6px 8px 2px;
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.att-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 4px 6px;
|
||||
font-size: 12px; max-width: 220px; position: relative;
|
||||
}
|
||||
.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
|
||||
.att-chip.att-error { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
|
||||
|
||||
.att-thumb {
|
||||
width: 32px; height: 32px; object-fit: cover;
|
||||
border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; }
|
||||
|
||||
.att-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; }
|
||||
.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.att-remove {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px;
|
||||
flex-shrink: 0; margin-left: auto;
|
||||
}
|
||||
.att-remove:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* Drag-and-drop overlay on chat area */
|
||||
.messages.drag-over {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
background: color-mix(in srgb, var(--accent) 4%, var(--bg));
|
||||
}
|
||||
|
||||
/* ── Message Files ────────────────────── */
|
||||
|
||||
.msg-files {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.msg-att-image {
|
||||
cursor: pointer; position: relative; border-radius: var(--radius);
|
||||
overflow: hidden; border: 1px solid var(--border);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.msg-att-image:hover { border-color: var(--accent); }
|
||||
.msg-att-image img {
|
||||
display: block; max-height: 280px; border-radius: var(--radius);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.msg-att-image img.att-load-error {
|
||||
min-width: 120px; min-height: 60px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; color: var(--text-3);
|
||||
}
|
||||
|
||||
.att-vision-hint {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
||||
border-radius: 4px; padding: 2px 5px; font-size: 14px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.msg-att-doc {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); background: var(--bg-raised);
|
||||
text-decoration: none; color: var(--text);
|
||||
max-width: 280px; transition: border-color var(--transition);
|
||||
}
|
||||
.msg-att-doc:hover { border-color: var(--accent); }
|
||||
.att-doc-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.att-doc-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.att-doc-name {
|
||||
font-size: 13px; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.att-doc-size { font-size: 11px; color: var(--text-3); }
|
||||
.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; }
|
||||
|
||||
/* ── Image Lightbox ─────────────────────────── */
|
||||
|
||||
.lightbox-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 10000;
|
||||
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
|
||||
}
|
||||
.lightbox-overlay.active { display: flex; }
|
||||
.lightbox-overlay img {
|
||||
max-width: 92vw; max-height: 90vh; object-fit: contain;
|
||||
border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.lightbox-close {
|
||||
position: absolute; top: 16px; right: 20px;
|
||||
background: rgba(255,255,255,0.15); border: none; color: var(--text-on-color);
|
||||
font-size: 22px; width: 40px; height: 40px; border-radius: 50%;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
|
||||
|
||||
/* ── Chat type indicators (v0.23.0) ────────── */
|
||||
.chat-type-icon { font-size: 11px; opacity: 0.7; }
|
||||
.mono-input { font-family: var(--mono) !important; font-size: 12px !important; }
|
||||
|
||||
/* Handle hints in dropdowns and pickers */
|
||||
.item-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 6px; opacity: 0.75; }
|
||||
.group-persona-handle { font-size: 10px; color: var(--accent); font-family: var(--mono); margin-left: 4px; opacity: 0.75; }
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
/* ==========================================
|
||||
Notifications (v0.20.0)
|
||||
========================================== */
|
||||
|
||||
/* ── Bell + Badge ──────────────────────────── */
|
||||
|
||||
.notif-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notif-bell {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 7px;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.notif-bell:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 0 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Dropdown ──────────────────────────────── */
|
||||
|
||||
.notif-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-height: 480px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notif-dd-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notif-mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.notif-mark-all:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-dd-list {
|
||||
overflow-y: auto;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.notif-dd-footer {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-view-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notif-view-all:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Notification Item ─────────────────────── */
|
||||
|
||||
.notif-item,
|
||||
.notif-panel-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notif-item:hover,
|
||||
.notif-panel-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-item.notif-unread,
|
||||
.notif-panel-item.notif-unread {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.notif-dot {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notif-item:not(.notif-unread) .notif-dot,
|
||||
.notif-panel-item:not(.notif-unread) .notif-dot {
|
||||
color: var(--text-tertiary, #555);
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notif-detail {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #666);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
padding: 32px 14px;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Panel ─────────────────────────────────── */
|
||||
|
||||
.notif-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notif-panel-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notif-panel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notif-panel-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notif-delete {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary, #555);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.notif-panel-item:hover .notif-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notif-delete:hover {
|
||||
color: var(--danger);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-panel-more {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Mobile ────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.notif-dropdown {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
max-height: calc(100vh - 96px);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Pane Container
|
||||
==========================================
|
||||
v0.25.0: Styles for the multi-pane layout system.
|
||||
Covers: pane container, leaf panes, tabbed panes,
|
||||
drag handles, and tab bars.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.pane-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Split ─────────────────────────────────── */
|
||||
|
||||
.pane-split--horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane-split--vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Leaf Pane ─────────────────────────────── */
|
||||
|
||||
.pane-leaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pane-minimized {
|
||||
max-width: 0 !important;
|
||||
max-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ── Tabbed Pane ───────────────────────────── */
|
||||
|
||||
.pane-tabbed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.pane-tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.pane-tab-bar::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.pane-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #777);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.pane-tab-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.pane-tab-btn--active {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-bottom-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.pane-tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pane-tab-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* When panel is hidden (inactive tab), override absolute positioning */
|
||||
.pane-tab-panel[style*="display: none"] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* ── Drag Handle ───────────────────────────── */
|
||||
|
||||
.pane-handle {
|
||||
flex-shrink: 0;
|
||||
background: var(--border, #2a2a2e);
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pane-handle--horizontal {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.pane-handle--vertical {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.pane-handle:hover,
|
||||
.pane-handle--active {
|
||||
background: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* On mobile, splits collapse to vertical stack */
|
||||
.pane-split--horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pane-handle--horizontal {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
/* Hide non-primary panes on mobile */
|
||||
.pane-split--horizontal > .pane:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
.pane-split--horizontal > .pane-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/* ── panels.css ──────────────────────────────
|
||||
Side panel, preview, notes, graph, backlinks
|
||||
──────────────────────────────────────────── */
|
||||
|
||||
/* ── Side Panel (Preview + Notes) ──────── */
|
||||
|
||||
/* ── Secondary Pane (was .side-panel, now .workspace-secondary) ── */
|
||||
/* Layout handled by .workspace-secondary above; these are internal styles */
|
||||
|
||||
.side-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-raised); flex-shrink: 0;
|
||||
}
|
||||
.side-panel-actions {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
}
|
||||
.side-panel-action-btn {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; padding: 4px 6px; border-radius: var(--radius);
|
||||
transition: all var(--transition); display: flex; align-items: center;
|
||||
}
|
||||
.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.side-panel-label {
|
||||
font-size: 12px; font-family: var(--font); font-weight: 600;
|
||||
color: var(--text-2); white-space: nowrap; font-style: normal;
|
||||
}
|
||||
.side-panel-btn {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; padding: 4px 6px; border-radius: var(--radius);
|
||||
transition: all var(--transition); display: flex; align-items: center;
|
||||
}
|
||||
.side-panel-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.side-panel-close {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-size: 16px; padding: 2px 6px;
|
||||
border-radius: var(--radius); transition: all var(--transition);
|
||||
}
|
||||
.side-panel-close:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.side-panel-body {
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
.side-panel-page { height: 100%; display: flex; flex-direction: column; }
|
||||
/* .note-panel-root removed in v0.37.9 — see sw-notes-pane.css */
|
||||
.side-panel-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--text-3); font-size: 13px;
|
||||
padding: 2rem; text-align: center;
|
||||
}
|
||||
.preview-frame {
|
||||
flex: 1; width: 100%; border: none; background: var(--bg-surface);
|
||||
}
|
||||
.preview-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--text-3); font-size: 13px;
|
||||
padding: 2rem; text-align: center;
|
||||
}
|
||||
|
||||
/* Notes panel CSS removed in v0.37.9 — see sw-notes-pane.css */
|
||||
|
||||
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
|
||||
.side-panel-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 199;
|
||||
background: var(--overlay);
|
||||
}
|
||||
|
||||
/* Mobile: full-width overlay */
|
||||
@media (max-width: 768px) {
|
||||
.workspace-secondary.open {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: 100vw; min-width: 100vw; z-index: 200;
|
||||
}
|
||||
.workspace-handle { display: none !important; }
|
||||
|
||||
/* Larger touch targets on mobile */
|
||||
.side-panel-close { font-size: 20px; padding: 4px 10px; }
|
||||
.side-panel-action-btn { padding: 6px 8px; }
|
||||
.side-panel-header { padding: 10px 12px; }
|
||||
}
|
||||
|
||||
/* Dual-view removed in workspace refactor (v0.22.0) — workspace
|
||||
handles all pane placement via primary/secondary slots. */
|
||||
|
||||
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||
.msg-text li { margin: 0.2em 0; }
|
||||
.msg-text li > p { margin: 0.2em 0; }
|
||||
|
||||
.msg-text blockquote {
|
||||
border-left: 3px solid var(--accent); padding: 0.25em 0.75em; margin: 0.5em 0;
|
||||
color: var(--text-2); background: var(--accent-dim); border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
|
||||
.msg-text hr { border: none; border-top: 1px solid var(--border); margin: 0.75em 0; }
|
||||
|
||||
.msg-text table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 13px; }
|
||||
.msg-text th, .msg-text td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; }
|
||||
.msg-text th { background: var(--bg-raised); font-weight: 600; }
|
||||
|
||||
.msg-text h1, .msg-text h2, .msg-text h3, .msg-text h4 { margin: 0.75em 0 0.3em; font-weight: 600; }
|
||||
.msg-text h1 { font-size: 1.4em; }
|
||||
.msg-text h2 { font-size: 1.2em; }
|
||||
.msg-text h3 { font-size: 1.05em; }
|
||||
|
||||
.msg-text a { color: var(--accent); }
|
||||
.msg-text img { max-width: 100%; border-radius: var(--radius); }
|
||||
|
||||
/* Thinking blocks */
|
||||
.thinking-block {
|
||||
border: 1px solid var(--accent-dim); border-radius: var(--radius);
|
||||
margin: 0.5rem 0; background: rgba(108,159,255,0.03);
|
||||
}
|
||||
.thinking-block summary {
|
||||
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 12px;
|
||||
color: var(--text-3); user-select: none;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.thinking-block summary:hover { color: var(--text-2); }
|
||||
.thinking-block[open] summary { border-bottom: 1px solid rgba(108,159,255,0.08); }
|
||||
.thinking-content {
|
||||
padding: 0.5rem 0.75rem; font-size: 12px; color: var(--text-3);
|
||||
max-height: 300px; overflow-y: auto; line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tool activity indicators */
|
||||
.msg-tools {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tool-activity {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--text-3);
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
padding: 4px 10px; border-radius: var(--radius);
|
||||
animation: tool-fade-in 0.2s ease;
|
||||
}
|
||||
@keyframes tool-fade-in { from { opacity: 0; transform: translateY(-4px); } }
|
||||
.tool-icon { font-size: 13px; }
|
||||
.tool-name { font-family: var(--mono); font-size: 11px; color: var(--text-2); }
|
||||
.tool-status { font-size: 11px; margin-left: auto; }
|
||||
.tool-running { color: var(--warning); }
|
||||
.tool-running::after {
|
||||
content: ''; display: inline-block; width: 4px; height: 4px;
|
||||
border-radius: 50%; background: var(--warning); margin-left: 4px;
|
||||
animation: tool-pulse 1s infinite;
|
||||
}
|
||||
@keyframes tool-pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
.tool-done { color: var(--success); }
|
||||
.tool-error { color: var(--danger); }
|
||||
.tool-hint {
|
||||
font-size: 11px; color: var(--text-3); margin-left: 6px;
|
||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tool-note-link {
|
||||
background: none; border: none; color: var(--accent); cursor: pointer;
|
||||
font-size: 11px; margin-left: 6px; padding: 0;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.tool-note-link:hover { color: var(--text); text-decoration: underline; }
|
||||
|
||||
/* Tool calls in message history (collapsed) */
|
||||
.tool-call-block {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--bg-raised); font-size: 12px;
|
||||
}
|
||||
.tool-call-summary {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; cursor: pointer; user-select: none;
|
||||
color: var(--text-3); list-style: none;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.tool-call-summary::-webkit-details-marker { display: none; }
|
||||
.tool-call-summary::before {
|
||||
content: '▸'; font-size: 10px; color: var(--text-3);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.tool-call-block[open] .tool-call-summary::before { transform: rotate(90deg); }
|
||||
.tool-call-summary:hover { color: var(--text-2); }
|
||||
.tool-detail {
|
||||
border-top: 1px solid var(--border); padding: 6px 10px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.tool-detail-label {
|
||||
font-size: 10px; color: var(--text-3); text-transform: uppercase;
|
||||
letter-spacing: 0.5px; font-weight: 600;
|
||||
}
|
||||
.tool-detail-pre {
|
||||
margin: 2px 0 0; padding: 6px 8px;
|
||||
background: var(--bg-surface); border-radius: 4px;
|
||||
font-size: 11px; color: var(--text-2); white-space: pre-wrap;
|
||||
word-break: break-all; max-height: 200px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem;
|
||||
}
|
||||
.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||
.empty-logo-img { width: 64px; height: 64px; object-fit: contain; }
|
||||
.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); }
|
||||
.empty-state p { font-size: 0.85rem; }
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-dots { display: flex; gap: 4px; padding: 0.5rem 0; }
|
||||
.typing-dots span {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: var(--text-3);
|
||||
animation: dot-pulse 1.2s infinite;
|
||||
}
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes dot-pulse { 0%,60%,100% { opacity: 0.4; } 30% { opacity: 1; } }
|
||||
|
||||
|
||||
/* ── Old notes CSS removed in v0.37.9 — see sw-notes-pane.css ── */
|
||||
|
||||
@@ -377,3 +377,376 @@
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Stop Button ──────────────────────────── */
|
||||
|
||||
.sw-msg-input__stop {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--danger, #f44336);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-msg-input__stop:hover {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
/* ── Input Buttons Row ────────────────────── */
|
||||
|
||||
.sw-msg-input__buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Emoji Button + Picker ────────────────── */
|
||||
|
||||
.sw-msg-input__emoji-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-msg-input__emoji-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-msg-input__emoji-btn:hover {
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.sw-emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
right: 0;
|
||||
width: 320px;
|
||||
max-height: 360px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 4px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 6px 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__tab--active {
|
||||
border-bottom-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-emoji-picker__search {
|
||||
margin: 6px 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__search:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-emoji-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__emoji {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.1s;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sw-emoji-picker__emoji:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-emoji-picker__empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Markdown Toolbar ─────────────────────── */
|
||||
|
||||
.sw-md-toolbar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 4px 12px 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-md-toolbar__btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sw-md-toolbar__btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Error Banner ─────────────────────────── */
|
||||
|
||||
.sw-chat-pane__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-bottom: 1px solid rgba(244, 67, 54, 0.2);
|
||||
color: var(--danger, #f44336);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-chat-pane__error-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-chat-pane__error-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger, #f44336);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sw-chat-pane__error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Load More ────────────────────────────── */
|
||||
|
||||
.sw-chat-pane__load-more {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sw-chat-pane__load-more-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 12px;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-pane__load-more-btn:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-pane__load-more-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Message Footer (time + actions) ──────── */
|
||||
|
||||
.sw-chat-msg__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-msg:hover .sw-chat-msg__footer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sw-chat-msg__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
/* ── Message Actions Toolbar ──────────────── */
|
||||
|
||||
.sw-chat-msg__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sw-chat-msg__action-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
.sw-chat-msg__action-btn--danger:hover {
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
/* ── Code Block ───────────────────────────── */
|
||||
|
||||
.sw-code-block {
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-code-block__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.sw-code-block__lang {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3, #555);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.sw-code-block__copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sw-code-block__copy:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
.sw-code-block__copy-text {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sw-code-block__pre {
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.sw-code-block__pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── Task List Checkboxes ─────────────────── */
|
||||
|
||||
.sw-task-item {
|
||||
list-style: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
|
||||
.sw-task-checkbox {
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
608
src/css/sw-chat-surface.css
Normal file
608
src/css/sw-chat-surface.css
Normal file
@@ -0,0 +1,608 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Chat Surface CSS
|
||||
==========================================
|
||||
v0.37.10: New Preact chat surface layout.
|
||||
sw-chat-surface__ prefixed, no legacy conflicts.
|
||||
========================================== */
|
||||
|
||||
/* ── Root Layout ──────────────────────────── */
|
||||
|
||||
.sw-chat-surface {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1 1 0%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sw-chat-surface__sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
border: 1px solid var(--accent, #b38a4e);
|
||||
border-radius: 8px;
|
||||
color: var(--accent, #b38a4e);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__collapse-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__collapse-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-chat-surface__new-chat-btn:hover {
|
||||
background: rgba(179, 138, 78, 0.2);
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__search-wrap {
|
||||
position: relative;
|
||||
padding: 4px 12px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-chat-surface__search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3, #555);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sw-chat-surface__search {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 30px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sw-chat-surface__search:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
/* ── Sidebar Body (scrollable) ────────────── */
|
||||
|
||||
.sw-chat-surface__sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Section ──────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__section-header:hover {
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-surface__chevron {
|
||||
display: flex;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-chat-surface__section-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sw-chat-surface__section-body {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__empty {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
/* ── Sidebar Item ─────────────────────────── */
|
||||
|
||||
.sw-chat-surface__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-2, #999);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-surface__item--active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-surface__item--indent {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
.sw-chat-surface__item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item-menu {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item:hover .sw-chat-surface__item-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sw-chat-surface__item-menu:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-secondary, #151517);
|
||||
}
|
||||
|
||||
/* ── Unread Badge ─────────────────────────── */
|
||||
|
||||
.sw-chat-surface__unread {
|
||||
background: var(--accent, #b38a4e);
|
||||
color: var(--bg, #0e0e10);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Folder ───────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__folder {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sw-chat-surface__folder-count {
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
}
|
||||
|
||||
/* ── Context Menu ─────────────────────────── */
|
||||
|
||||
.sw-chat-surface__context-menu {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
padding: 4px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sw-chat-surface__context-menu button:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-chat-surface__ctx-danger {
|
||||
color: var(--danger, #f44336) !important;
|
||||
}
|
||||
|
||||
/* ── Sidebar Footer ───────────────────────── */
|
||||
|
||||
.sw-chat-surface__sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Main Area ────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Workspace Header ─────────────────────── */
|
||||
|
||||
.sw-chat-surface__workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__workspace-header-left,
|
||||
.sw-chat-surface__workspace-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-chat-surface__sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__sidebar-toggle:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Tools Button ─────────────────────────── */
|
||||
|
||||
.sw-chat-surface__tools-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-3, #555);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sw-chat-surface__tools-btn:hover {
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-surface__tools-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: var(--danger, #f44336);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Workspace ────────────────────────────── */
|
||||
|
||||
.sw-chat-surface__workspace {
|
||||
flex: 1 1 0%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-chat-surface__chat-pane {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Overlay (mobile) ─────────────── */
|
||||
|
||||
.sw-chat-surface__sidebar-overlay {
|
||||
display: none; /* Hidden on desktop */
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* ── Tools Popup ──────────────────────────── */
|
||||
|
||||
.sw-tools-popup {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 8px;
|
||||
width: 300px;
|
||||
max-height: 420px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-tools-popup__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px 8px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-tools-popup__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-tools-popup__badge {
|
||||
font-size: 10px;
|
||||
color: var(--danger, #f44336);
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__category {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__cat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__cat-toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sw-tools-popup__cat-toggle:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-tools-popup__chevron {
|
||||
font-size: 8px;
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sw-tools-popup__chevron--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sw-tools-popup__cat-count {
|
||||
color: var(--text-3, #555);
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__cat-switch {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sw-tools-popup__cat-switch input {
|
||||
accent-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-tools-popup__tool {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 6px 4px 22px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__tool:hover {
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
.sw-tools-popup__tool input {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-tools-popup__tool-name {
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sw-tools-popup__tool-desc {
|
||||
color: var(--text-3, #555);
|
||||
font-size: 11px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Responsive: Mobile ───────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sw-chat-surface__sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sw-chat-surface__sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sw-chat-surface__collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createBrowserContext, loadSource } = require('./helpers');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createBrowserContext, loadSource, SRC } = require('./helpers');
|
||||
const vm = require('vm');
|
||||
|
||||
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
|
||||
// Skip all tests if the source file doesn't exist.
|
||||
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
|
||||
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Load Events + Extensions into a browser context.
|
||||
*/
|
||||
@@ -23,7 +30,7 @@ function loadExtensions(overrides = {}) {
|
||||
// KaTeX Renderer Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('KaTeX renderer extension', () => {
|
||||
maybeDescribe('KaTeX renderer extension', () => {
|
||||
function loadWithKaTeX() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -133,7 +140,7 @@ describe('KaTeX renderer extension', () => {
|
||||
// CSV Table Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('CSV Table extension', () => {
|
||||
maybeDescribe('CSV Table extension', () => {
|
||||
function loadWithCSV() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -296,7 +303,7 @@ describe('CSV Table extension', () => {
|
||||
// Diff Viewer Extension
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('Diff Viewer extension', () => {
|
||||
maybeDescribe('Diff Viewer extension', () => {
|
||||
function loadWithDiff() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -427,7 +434,7 @@ describe('Diff Viewer extension', () => {
|
||||
// JS Sandbox Extension (Tool)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('JS Sandbox extension', () => {
|
||||
maybeDescribe('JS Sandbox extension', () => {
|
||||
it('registers js_eval tool handler', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -484,7 +491,7 @@ describe('JS Sandbox extension', () => {
|
||||
// Regex Tester Extension (Tool)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
describe('Regex Tester extension', () => {
|
||||
maybeDescribe('Regex Tester extension', () => {
|
||||
function loadWithRegex() {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { createBrowserContext, loadSource } = require('./helpers');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createBrowserContext, loadSource, SRC } = require('./helpers');
|
||||
const vm = require('vm');
|
||||
|
||||
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
|
||||
// Skip all tests if the source file doesn't exist.
|
||||
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
|
||||
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* Load Events + Extensions into a browser context.
|
||||
* Returns an object with Events, Extensions, and the sandbox.
|
||||
@@ -25,7 +32,7 @@ function loadExtensions(overrides = {}) {
|
||||
|
||||
// ── Registration ─────────────────────────────
|
||||
|
||||
describe('Extensions.register', () => {
|
||||
maybeDescribe('Extensions.register', () => {
|
||||
it('registers an extension by id', () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({ id: 'test-ext', init() {} });
|
||||
@@ -48,7 +55,7 @@ describe('Extensions.register', () => {
|
||||
|
||||
// ── Lifecycle ────────────────────────────────
|
||||
|
||||
describe('Extensions.initAll', () => {
|
||||
maybeDescribe('Extensions.initAll', () => {
|
||||
it('calls init on registered extensions', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let initialized = false;
|
||||
@@ -84,7 +91,7 @@ describe('Extensions.initAll', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extensions.destroyAll', () => {
|
||||
maybeDescribe('Extensions.destroyAll', () => {
|
||||
it('calls destroy and deactivates extensions', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let destroyed = false;
|
||||
@@ -102,7 +109,7 @@ describe('Extensions.destroyAll', () => {
|
||||
|
||||
// ── Context Object ──────────────────────────
|
||||
|
||||
describe('Extension context', () => {
|
||||
maybeDescribe('Extension context', () => {
|
||||
it('provides scoped events', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let receivedPayload = null;
|
||||
@@ -159,7 +166,7 @@ describe('Extension context', () => {
|
||||
|
||||
// ── Tool Registration ───────────────────────
|
||||
|
||||
describe('ctx.tools.handle', () => {
|
||||
maybeDescribe('ctx.tools.handle', () => {
|
||||
it('registers a tool handler', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -177,7 +184,7 @@ describe('ctx.tools.handle', () => {
|
||||
|
||||
// ── Tool Bridge ─────────────────────────────
|
||||
|
||||
describe('Tool bridge', () => {
|
||||
maybeDescribe('Tool bridge', () => {
|
||||
it('routes tool.call.* to registered handler and emits result', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let resultEmitted = null;
|
||||
@@ -269,7 +276,7 @@ describe('Tool bridge', () => {
|
||||
|
||||
// ── Renderer Pipeline ───────────────────────
|
||||
|
||||
describe('Renderer pipeline', () => {
|
||||
maybeDescribe('Renderer pipeline', () => {
|
||||
it('registers and runs block renderers', async () => {
|
||||
const ctx = loadExtensions();
|
||||
let rendered = false;
|
||||
@@ -356,7 +363,7 @@ describe('Renderer pipeline', () => {
|
||||
|
||||
// ── Debug ────────────────────────────────────
|
||||
|
||||
describe('Extensions.debug', () => {
|
||||
maybeDescribe('Extensions.debug', () => {
|
||||
it('returns extension info and renderer count', async () => {
|
||||
const ctx = loadExtensions();
|
||||
ctx.Extensions.register({
|
||||
@@ -376,7 +383,7 @@ describe('Extensions.debug', () => {
|
||||
|
||||
// ── Mermaid Extension ─────────────────────────────
|
||||
|
||||
describe('Mermaid renderer extension', () => {
|
||||
maybeDescribe('Mermaid renderer extension', () => {
|
||||
function loadWithMermaid() {
|
||||
const ctx = loadExtensions();
|
||||
// Load the mermaid extension script into the same VM context
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Loads the vanilla JS source files into a
|
||||
// simulated browser environment so tests run
|
||||
// against the ACTUAL frontend code.
|
||||
//
|
||||
// v0.37.10: Removed loadAppModules (api.js + app.js deleted).
|
||||
// ==========================================
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -104,23 +106,24 @@ function createBrowserContext(overrides = {}) {
|
||||
* Returns the sandbox for inspection.
|
||||
*/
|
||||
function loadSource(sandbox, filename) {
|
||||
const code = fs.readFileSync(path.join(SRC, filename), 'utf-8');
|
||||
const filepath = path.join(SRC, filename);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
throw new Error(`Source file not found: ${filename} (deleted in v0.37.10?)`);
|
||||
}
|
||||
const code = fs.readFileSync(filepath, 'utf-8');
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(code, ctx, { filename });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads API + App modules into a fresh context.
|
||||
* Returns { API, App, sandbox }.
|
||||
* Safely read a source file, returning empty string if deleted.
|
||||
* For tests that audit source code content.
|
||||
*/
|
||||
function loadAppModules(overrides = {}) {
|
||||
const sandbox = createBrowserContext(overrides);
|
||||
loadSource(sandbox, 'sb.js');
|
||||
loadSource(sandbox, 'app-state.js');
|
||||
loadSource(sandbox, 'api.js');
|
||||
loadSource(sandbox, 'app.js');
|
||||
return { API: sandbox.API, App: sandbox.App, sandbox };
|
||||
function readSourceSafe(filename) {
|
||||
const filepath = path.join(SRC, filename);
|
||||
if (!fs.existsSync(filepath)) return '';
|
||||
return fs.readFileSync(filepath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +161,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
module.exports = {
|
||||
createBrowserContext,
|
||||
loadSource,
|
||||
loadAppModules,
|
||||
readSourceSafe,
|
||||
processModelsResponse,
|
||||
SRC,
|
||||
};
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
// policy exists but the frontend doesn't
|
||||
// check it.
|
||||
//
|
||||
// v0.22.5: Updated for server-rendered Go
|
||||
// templates. HTML is now in server/pages/
|
||||
// templates/ — not in src/index.html.
|
||||
// v0.22.5: Updated for server-rendered Go templates.
|
||||
// v0.37.5: Settings surface moved to Preact — legacy SPA tests
|
||||
// replaced with component source audits.
|
||||
// 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.
|
||||
//
|
||||
// Run: node --test src/js/__tests__/policy-gating.test.js
|
||||
// ==========================================
|
||||
@@ -36,94 +39,6 @@ function readAllTemplates() {
|
||||
return files.join('\n');
|
||||
}
|
||||
|
||||
// ── Source code audits ───────────────────────
|
||||
// These tests read the actual source files and verify that required
|
||||
// wiring exists. If someone removes a policy check, CI breaks.
|
||||
|
||||
describe('Policy wiring audit — source code', () => {
|
||||
// Read all UI files (ui-core.js + ui-settings.js + ui-admin.js replace old ui.js)
|
||||
const uiSrc = ['ui-core.js', 'ui-settings.js', 'ui-admin.js', 'ui-format.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
// Read all app-side files (app.js + extracted handler files replace old monolith app.js)
|
||||
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js', 'chat.js', 'tokens.js', 'notes.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
// Pages.js — server-rendered page handlers (v0.22.5+)
|
||||
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
// ── allow_user_byok ──
|
||||
|
||||
it('UI has checkUserProvidersAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserProvidersAllowed reads App.policies.allow_user_byok', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_byok'),
|
||||
'MISSING: allow_user_byok check in UI');
|
||||
});
|
||||
|
||||
// ── allow_user_personas ──
|
||||
|
||||
it('UI has checkUserPersonasAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed — persona button will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserPersonasAllowed reads App.policies.allow_user_personas', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas check in UI');
|
||||
});
|
||||
|
||||
it('admin settings template has user-personas toggle', () => {
|
||||
assert.ok(templateSrc.includes('settUserPersonas'),
|
||||
'MISSING: settUserPersonas toggle in admin settings template');
|
||||
});
|
||||
|
||||
it('Pages.saveSettings writes allow_user_personas', () => {
|
||||
assert.ok(pagesSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas in Pages.saveSettings');
|
||||
});
|
||||
|
||||
it('admin settings save writes allow_user_personas (SPA bridge)', () => {
|
||||
assert.ok(appSrc.includes('allow_user_personas'),
|
||||
'MISSING: handleSaveAdminSettings must write allow_user_personas');
|
||||
});
|
||||
|
||||
// ── Settings live-apply ──
|
||||
|
||||
it('admin save calls initBanners for policy refresh', () => {
|
||||
assert.ok(appSrc.includes('await initBanners()'),
|
||||
'MISSING: initBanners call after admin save — policies won\'t refresh');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserProvidersAllowed', () => {
|
||||
// Find in handleSaveAdminSettings
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserPersonasAllowed', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls fetchModels to refresh model list', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('fetchModels'),
|
||||
'MISSING: fetchModels call after admin save — model list stale after settings change');
|
||||
});
|
||||
|
||||
// ── Models tab calls preset check ──
|
||||
|
||||
it('models tab switch calls checkUserPersonasAllowed', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed call on models tab switch');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Policy evaluation logic ──────────────────
|
||||
|
||||
describe('Policy evaluation logic', () => {
|
||||
@@ -158,7 +73,6 @@ describe('Policy evaluation logic', () => {
|
||||
});
|
||||
|
||||
// ── Team member dropdown logic ───────────────
|
||||
// Bug: Empty dropdown because resp.data used instead of resp.users
|
||||
|
||||
describe('Team member dropdown population', () => {
|
||||
function getAvailableUsers(usersResp, membersResp) {
|
||||
@@ -221,72 +135,37 @@ describe('Team member dropdown population', () => {
|
||||
|
||||
// ── Admin settings field mapping ─────────────
|
||||
// v0.22.5: Server-rendered admin settings template uses new element IDs.
|
||||
// Pages.saveSettings() in pages.js is the primary handler.
|
||||
// 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)', () => {
|
||||
// New element IDs used by server-rendered admin/settings.html + pages.js
|
||||
const settingsFieldMap = {
|
||||
'settRegEnabled': 'allow_registration',
|
||||
'settRegDefaultState': 'default_user_active',
|
||||
'settUserBYOK': 'allow_user_byok',
|
||||
'settUserPersonas': 'allow_user_personas',
|
||||
'settBannerEnabled': 'banner',
|
||||
};
|
||||
|
||||
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
|
||||
it(`template has element #${elementId}`, () => {
|
||||
assert.ok(templateSrc.includes(`id="${elementId}"`),
|
||||
`MISSING: #${elementId} in server templates — admin settings incomplete`);
|
||||
});
|
||||
|
||||
it(`Pages.saveSettings writes setting "${settingKey}"`, () => {
|
||||
assert.ok(pagesSrc.includes(settingKey),
|
||||
`MISSING: "${settingKey}" in Pages.saveSettings`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── SPA bridge field mapping (backward compat) ──
|
||||
// The SPA chat surface still loads settings-handlers.js + ui-admin.js.
|
||||
// These use legacy element IDs for handleSaveAdminSettings().
|
||||
// Verified at the JS level (elements are SPA-modal DOM, not templates).
|
||||
|
||||
describe('SPA bridge — admin settings handler references policy keys', () => {
|
||||
const appSrc = ['settings-handlers.js', 'admin-handlers.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
|
||||
const requiredPolicies = [
|
||||
'allow_registration',
|
||||
'default_user_active',
|
||||
'allow_user_byok',
|
||||
'allow_user_personas',
|
||||
const requiredElements = [
|
||||
'settRegEnabled',
|
||||
'settRegDefaultState',
|
||||
'settUserBYOK',
|
||||
'settUserPersonas',
|
||||
'settBannerEnabled',
|
||||
];
|
||||
|
||||
for (const key of requiredPolicies) {
|
||||
it(`SPA bridge writes policy "${key}"`, () => {
|
||||
assert.ok(appSrc.includes(key),
|
||||
`MISSING: "${key}" in SPA bridge handler — policy not saved`);
|
||||
for (const id of requiredElements) {
|
||||
it(`template has element #${id}`, () => {
|
||||
assert.ok(templateSrc.includes(`id="${id}"`),
|
||||
`MISSING: #${id} in server templates — admin settings incomplete`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── HTML element existence checks ────────────
|
||||
// v0.22.5: Elements now live in server templates, not index.html.
|
||||
// v0.37.5: Settings surface elements moved to Preact components
|
||||
// (src/js/sw/surfaces/settings/). Only admin template elements
|
||||
// are checked here. Settings surface policy gating is handled by
|
||||
// the Preact SettingsSurface component (nav gating + section routing).
|
||||
// ── 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 (was adminUserProvidersToggle)
|
||||
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
|
||||
'settUserBYOK', // Admin toggle for BYOK
|
||||
'settUserPersonas', // Admin toggle for personas
|
||||
];
|
||||
|
||||
for (const id of requiredElements) {
|
||||
@@ -297,6 +176,34 @@ describe('Critical HTML elements exist in server templates', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Chat surface template ────────────────────
|
||||
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
|
||||
// point exists and the old SPA scaffold is gone.
|
||||
|
||||
describe('Chat surface template (v0.37.10)', () => {
|
||||
const templateSrc = readAllTemplates();
|
||||
|
||||
it('chat-mount div exists', () => {
|
||||
assert.ok(templateSrc.includes('id="chat-mount"'),
|
||||
'MISSING: #chat-mount — Preact chat surface cannot render');
|
||||
});
|
||||
|
||||
it('old appContainer div is gone', () => {
|
||||
assert.ok(!templateSrc.includes('id="appContainer"'),
|
||||
'STALE: #appContainer still in templates — old SPA scaffold not removed');
|
||||
});
|
||||
|
||||
it('chat template loads Preact surface module', () => {
|
||||
assert.ok(templateSrc.includes('sw/surfaces/chat/index.js'),
|
||||
'MISSING: chat surface module import in template');
|
||||
});
|
||||
|
||||
it('chat template loads SDK boot', () => {
|
||||
assert.ok(templateSrc.includes('sw/sdk/index.js'),
|
||||
'MISSING: SDK boot import in chat template');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Preact surface policy gating ─────────────
|
||||
// v0.37.5: Settings surface elements are now rendered by Preact
|
||||
// components. Verify the components handle policy gating.
|
||||
@@ -326,20 +233,26 @@ describe('Settings Preact surface handles policy gating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── SPA bridge — dynamic DOM elements ────────
|
||||
// adminMemberUser is created by ui-admin.js loadMemberUserDropdown()
|
||||
// which runs inside the SPA chat surface. Verify the JS function exists.
|
||||
// ── Admin Preact surface ─────────────────────
|
||||
// v0.37.6: Admin surface handles settings save via Preact.
|
||||
// Verify the admin surface has policy key handling.
|
||||
|
||||
describe('SPA bridge — dynamic element creators', () => {
|
||||
const uiAdminSrc = fs.readFileSync(path.join(SRC, 'ui-admin.js'), 'utf-8');
|
||||
describe('Admin Preact surface handles settings', () => {
|
||||
const SURFACES = path.join(SRC, 'sw', 'surfaces', 'admin');
|
||||
const settingsPath = path.join(SURFACES, 'settings.js');
|
||||
|
||||
it('ui-admin.js has loadMemberUserDropdown', () => {
|
||||
assert.ok(uiAdminSrc.includes('loadMemberUserDropdown'),
|
||||
'MISSING: loadMemberUserDropdown — team member add will break');
|
||||
});
|
||||
// Only test if the admin settings component exists
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settingsSrc = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
it('loadMemberUserDropdown references adminMemberUser', () => {
|
||||
assert.ok(uiAdminSrc.includes('adminMemberUser'),
|
||||
'MISSING: adminMemberUser reference in loadMemberUserDropdown');
|
||||
});
|
||||
it('admin settings component references allow_user_byok', () => {
|
||||
assert.ok(settingsSrc.includes('allow_user_byok'),
|
||||
'MISSING: allow_user_byok in admin settings component');
|
||||
});
|
||||
|
||||
it('admin settings component references allow_user_personas', () => {
|
||||
assert.ok(settingsSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas in admin settings component');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
996
src/js/api.js
996
src/js/api.js
@@ -1,996 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – API Client
|
||||
// ==========================================
|
||||
// Backend-only mode. Handles auth tokens and
|
||||
// all HTTP calls. No offline fallback.
|
||||
//
|
||||
// BASE_PATH: injected via index.html <script>
|
||||
// at container startup. All API paths are
|
||||
// prefixed automatically.
|
||||
//
|
||||
// Exports: window.API
|
||||
// ==========================================
|
||||
|
||||
|
||||
const BASE = window.__BASE__ || '';
|
||||
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
|
||||
|
||||
const API = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
_refreshing: null,
|
||||
|
||||
// ── Bootstrap ────────────────────────────
|
||||
|
||||
loadTokens() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
|
||||
if (saved) {
|
||||
this.accessToken = saved.accessToken || null;
|
||||
this.refreshToken = saved.refreshToken || null;
|
||||
this.user = saved.user || null;
|
||||
// Unknown age — refresh soon to get a fresh token
|
||||
if (this.refreshToken) this._scheduleRefresh(60);
|
||||
}
|
||||
} catch (e) { /* corrupt storage */ }
|
||||
},
|
||||
|
||||
saveTokens() {
|
||||
localStorage.setItem(_storageKey, JSON.stringify({
|
||||
accessToken: this.accessToken,
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
}));
|
||||
// Sync to cookie for Go template page auth (v0.22.5)
|
||||
if (this.accessToken) {
|
||||
document.cookie = `sb_token=${this.accessToken}; path=/; max-age=900; SameSite=Strict`;
|
||||
} else {
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
}
|
||||
},
|
||||
|
||||
clearTokens() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem(_storageKey);
|
||||
document.cookie = 'sb_token=; path=/; max-age=0'; // clear page auth cookie
|
||||
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
|
||||
},
|
||||
|
||||
get isAuthed() { return !!this.accessToken; },
|
||||
get isAdmin() { return this.user?.role === 'admin'; },
|
||||
|
||||
// ── Auth ─────────────────────────────────
|
||||
|
||||
async health() {
|
||||
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||||
return this._parseJSON(resp, '/api/v1/health');
|
||||
},
|
||||
|
||||
// Detect proxy interception: HTTP 200 but HTML instead of JSON
|
||||
async _parseJSON(resp, context) {
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
// Proxy returned an HTML page (block page, auth wall, etc.)
|
||||
let title = 'unknown';
|
||||
try {
|
||||
const html = await resp.clone().text();
|
||||
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (m) title = m[1].trim();
|
||||
} catch (e) { /* best effort */ }
|
||||
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
|
||||
err.proxyBlocked = true;
|
||||
err.proxyTitle = title;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
|
||||
async login(login, password) {
|
||||
const data = await this._post('/api/v1/auth/login', { login, password }, true);
|
||||
this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||||
if (!data.pending) this._setAuth(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
|
||||
catch (e) { /* best effort */ }
|
||||
this.clearTokens();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
if (this._refreshing) return this._refreshing;
|
||||
this._refreshing = (async () => {
|
||||
try {
|
||||
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
|
||||
this._setAuth(data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.clearTokens();
|
||||
return false;
|
||||
} finally {
|
||||
this._refreshing = null;
|
||||
}
|
||||
})();
|
||||
return this._refreshing;
|
||||
},
|
||||
|
||||
// Centralized 401 recovery: try refresh, redirect to login on failure.
|
||||
// Returns true if refresh succeeded (caller should retry), false otherwise.
|
||||
async _handle401() {
|
||||
if (this.refreshToken) {
|
||||
if (await this.refresh()) return true;
|
||||
}
|
||||
// No refresh token or refresh failed — redirect to login
|
||||
this.clearTokens();
|
||||
const base = window.__BASE__ || '';
|
||||
window.location.href = base + '/login';
|
||||
return false;
|
||||
},
|
||||
|
||||
// Alias for legacy call sites
|
||||
async _refreshAccessToken() {
|
||||
if (!(await this._handle401())) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
},
|
||||
|
||||
_setAuth(data) {
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
this.user = data.user;
|
||||
this.saveTokens();
|
||||
this._scheduleRefresh(data.expires_in || 900);
|
||||
},
|
||||
|
||||
_refreshTimer: null,
|
||||
|
||||
_scheduleRefresh(expiresIn) {
|
||||
if (this._refreshTimer) clearTimeout(this._refreshTimer);
|
||||
// Refresh at 80% of expiry (e.g., 12min for 15min token)
|
||||
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
|
||||
this._refreshTimer = setTimeout(async () => {
|
||||
if (!this.refreshToken) return;
|
||||
console.debug('🔄 Proactive token refresh');
|
||||
try {
|
||||
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
|
||||
this._setAuth(data);
|
||||
} catch (e) {
|
||||
// Proactive refresh failed — keep session alive.
|
||||
// Access token is still valid for ~20% of its lifetime.
|
||||
// When it naturally expires, 401-retry in _authed() handles cleanup.
|
||||
console.warn('⚠ Proactive refresh failed, access token still valid');
|
||||
}
|
||||
}, ms);
|
||||
},
|
||||
|
||||
// ── Channels ──────────────────────────────
|
||||
|
||||
listChannels(page = 1, perPage = 100, type = '') {
|
||||
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
|
||||
if (type) url += `&type=${type}`;
|
||||
return this._get(url);
|
||||
},
|
||||
createChannel(title, model, systemPrompt, type = 'direct') {
|
||||
const body = { title, type };
|
||||
if (model) body.model = model;
|
||||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||
return this._post('/api/v1/channels', body);
|
||||
},
|
||||
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||
markRead(id) { return this._post(`/api/v1/channels/${id}/mark-read`, {}); },
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
|
||||
addChannelModel(channelId, data) { return this._post(`/api/v1/channels/${channelId}/models`, data); },
|
||||
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
|
||||
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
|
||||
|
||||
// Channel participants (v0.23.0 — ICD §3.7)
|
||||
listParticipants(channelId) { return this._get(`/api/v1/channels/${channelId}/participants`); },
|
||||
addParticipant(channelId, data) { return this._post(`/api/v1/channels/${channelId}/participants`, data); },
|
||||
updateParticipant(channelId, participantId, data) { return this._patch(`/api/v1/channels/${channelId}/participants/${participantId}`, data); },
|
||||
removeParticipant(channelId, participantId) { return this._del(`/api/v1/channels/${channelId}/participants/${participantId}`); },
|
||||
|
||||
// Notification preferences (v0.20.0 Phase 3)
|
||||
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
|
||||
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
|
||||
deleteNotifPref(type) { return this._del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`); },
|
||||
adminTestEmail() { return this._post('/api/v1/admin/notifications/test-email', {}); },
|
||||
|
||||
// Projects (v0.19.0)
|
||||
listProjects(includeArchived) {
|
||||
const q = includeArchived ? '?include_archived=true' : '';
|
||||
return this._get(`/api/v1/projects${q}`);
|
||||
},
|
||||
createProject(data) { return this._post('/api/v1/projects', data); },
|
||||
getProject(id) { return this._get(`/api/v1/projects/${id}`); },
|
||||
updateProject(id, data) { return this._put(`/api/v1/projects/${id}`, data); },
|
||||
deleteProject(id) { return this._del(`/api/v1/projects/${id}`); },
|
||||
addChannelToProject(projectId, channelId, position) {
|
||||
return this._post(`/api/v1/projects/${projectId}/channels`, { channel_id: channelId, position: position || 0 });
|
||||
},
|
||||
removeChannelFromProject(projectId, channelId) {
|
||||
return this._del(`/api/v1/projects/${projectId}/channels/${channelId}`);
|
||||
},
|
||||
reorderProjectChannels(projectId, channelIds) {
|
||||
return this._put(`/api/v1/projects/${projectId}/channels/reorder`, { channel_ids: channelIds });
|
||||
},
|
||||
listProjectChannels(projectId) { return this._get(`/api/v1/projects/${projectId}/channels`); },
|
||||
addKBToProject(projectId, kbId, autoSearch) {
|
||||
return this._post(`/api/v1/projects/${projectId}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false });
|
||||
},
|
||||
removeKBFromProject(projectId, kbId) {
|
||||
return this._del(`/api/v1/projects/${projectId}/knowledge-bases/${kbId}`);
|
||||
},
|
||||
listProjectKBs(projectId) { return this._get(`/api/v1/projects/${projectId}/knowledge-bases`); },
|
||||
addNoteToProject(projectId, noteId) {
|
||||
return this._post(`/api/v1/projects/${projectId}/notes`, { note_id: noteId });
|
||||
},
|
||||
removeNoteFromProject(projectId, noteId) {
|
||||
return this._del(`/api/v1/projects/${projectId}/notes/${noteId}`);
|
||||
},
|
||||
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
|
||||
|
||||
// ── Folders (v0.23.1) ────────────────────
|
||||
listFolders() { return this._get('/api/v1/folders'); },
|
||||
createFolder(name, sortOrder = 0) { return this._post('/api/v1/folders', { name, sort_order: sortOrder }); },
|
||||
updateFolder(id, data) { return this._put(`/api/v1/folders/${id}`, data); },
|
||||
deleteFolder(id) { return this._del(`/api/v1/folders/${id}`); },
|
||||
|
||||
// ── Sidebar helpers ───────────────────────
|
||||
listSidebarChannels(page = 1, perPage = 500) {
|
||||
return this._get(`/api/v1/channels?page=${page}&per_page=${perPage}&types=dm,channel`);
|
||||
},
|
||||
|
||||
// ── Presence ──────────────────────────────
|
||||
presenceHeartbeat() { return this._post('/api/v1/presence/heartbeat', {}); },
|
||||
|
||||
// ── Workspaces (v0.21.0) ────────────────
|
||||
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
|
||||
listWorkspaceFiles(wsId, path = '', recursive = false) {
|
||||
let url = `/api/v1/workspaces/${wsId}/files?path=${encodeURIComponent(path)}`;
|
||||
if (recursive) url += '&recursive=true';
|
||||
return this._get(url);
|
||||
},
|
||||
async readWorkspaceFile(wsId, path) {
|
||||
// Returns raw text, not JSON
|
||||
const resp = await fetch(BASE + `/api/v1/workspaces/${wsId}/files/read?path=${encodeURIComponent(path)}`, {
|
||||
headers: this._authHeaders(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw Object.assign(new Error(data.error || `HTTP ${resp.status}`), { status: resp.status });
|
||||
}
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await resp.json();
|
||||
return data.content || '';
|
||||
}
|
||||
return resp.text();
|
||||
},
|
||||
async writeWorkspaceFile(wsId, path, content) {
|
||||
// Backend reads path from query string and content from raw request body
|
||||
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
deleteWorkspaceFile(wsId, path) {
|
||||
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
|
||||
},
|
||||
mkdirWorkspace(wsId, path) {
|
||||
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
|
||||
},
|
||||
|
||||
// Upload a File object to a workspace (binary-safe, v0.23.0)
|
||||
async uploadWorkspaceFile(wsId, file, destPath) {
|
||||
const path = destPath || file.name;
|
||||
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
|
||||
const doFetch = () => fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'Content-Length': file.size.toString(),
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401) {
|
||||
await this._refreshAccessToken();
|
||||
resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
|
||||
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
|
||||
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
|
||||
listWorkspaces() { return this._get('/api/v1/workspaces'); },
|
||||
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
|
||||
updateWorkspace(id, patch) { return this._patch(`/api/v1/workspaces/${id}`, patch); },
|
||||
deleteWorkspace(id) { return this._del(`/api/v1/workspaces/${id}`); },
|
||||
listGitCredentials() { return this._get('/api/v1/git-credentials'); },
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
listMessages(channelId, page = 1, perPage = 200) {
|
||||
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
|
||||
// ── Message Tree (forking) ───────────────
|
||||
|
||||
getActivePath(channelId) {
|
||||
return this._get(`/api/v1/channels/${channelId}/path`);
|
||||
},
|
||||
|
||||
editMessage(channelId, messageId, content) {
|
||||
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||
},
|
||||
|
||||
async streamRegenerate(channelId, messageId, signal, model, personaId, apiConfigId, disabledTools) {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (personaId) body.persona_id = personaId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
if (await this._handle401()) {
|
||||
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy blocked the regeneration request');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy intercepted the regeneration stream');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
updateCursor(channelId, activeLeafId) {
|
||||
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
|
||||
},
|
||||
|
||||
listSiblings(channelId, messageId) {
|
||||
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
|
||||
},
|
||||
|
||||
// ── Summarize & Continue ────────────────
|
||||
summarizeChannel(channelId) {
|
||||
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
|
||||
},
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, fileIds, disabledTools) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (personaId) {
|
||||
body.persona_id = personaId;
|
||||
// Persona resolves the model on backend; still pass model for channel metadata
|
||||
if (model) body.model = model;
|
||||
} else {
|
||||
if (model) body.model = model;
|
||||
}
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
if (fileIds?.length) body.file_ids = fileIds;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
if (await this._handle401()) {
|
||||
resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy blocked the completion request');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
// Also check 200 OK but HTML (proxy returning block page with 200)
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
const err = new Error('Proxy intercepted the completion stream');
|
||||
err.proxyBlocked = true;
|
||||
throw err;
|
||||
}
|
||||
return resp;
|
||||
},
|
||||
|
||||
// ── Tools ────────────────────────────────
|
||||
|
||||
getTools() { return this._get('/api/v1/tools'); },
|
||||
|
||||
// ── Models ───────────────────────────────
|
||||
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
|
||||
setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
|
||||
bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
|
||||
|
||||
// User personas
|
||||
listUserPersonas() { return this._get('/api/v1/personas'); },
|
||||
createUserPersona(persona) { return this._post('/api/v1/personas', persona); },
|
||||
getUserPersonaKBs(personaId) { return this._get(`/api/v1/personas/${personaId}/knowledge-bases`); },
|
||||
setUserPersonaKBs(personaId, data) { return this._put(`/api/v1/personas/${personaId}/knowledge-bases`, data); },
|
||||
updateUserPersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
|
||||
deleteUserPersona(id) { return this._del(`/api/v1/personas/${id}`); },
|
||||
listAllModels() { return this._get('/api/v1/models/enabled'); },
|
||||
|
||||
// ── API Configs (user providers) ─────────
|
||||
|
||||
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||||
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
|
||||
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
return this._post('/api/v1/api-configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
});
|
||||
},
|
||||
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
|
||||
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
|
||||
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
|
||||
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
|
||||
|
||||
// ── Profile & Settings ───────────────────
|
||||
|
||||
async getProfile() {
|
||||
const data = await this._get('/api/v1/profile');
|
||||
// Sync user object with profile data (including avatar)
|
||||
if (data && this.user) {
|
||||
this.user.avatar = data.avatar || null;
|
||||
this.user.display_name = data.display_name;
|
||||
this.saveTokens();
|
||||
}
|
||||
return data;
|
||||
},
|
||||
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||||
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
|
||||
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
|
||||
changePassword(current, newPw) {
|
||||
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||
},
|
||||
getSettings() { return this._get('/api/v1/settings'); },
|
||||
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
|
||||
|
||||
// ── Admin ────────────────────────────────
|
||||
|
||||
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
|
||||
adminCreateUser(username, email, password, role) {
|
||||
return this._post('/api/v1/admin/users', { username, email, password, role });
|
||||
},
|
||||
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
|
||||
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
|
||||
adminToggleActive(id, active, teamIds, teamRole) {
|
||||
const body = { is_active: active };
|
||||
if (teamIds?.length) body.team_ids = teamIds;
|
||||
if (teamRole) body.team_role = teamRole;
|
||||
return this._put(`/api/v1/admin/users/${id}/active`, body);
|
||||
},
|
||||
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
|
||||
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
|
||||
getPublicSettings() { return this._get('/api/v1/settings/public'); },
|
||||
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||
adminGetDashboard() { return this._get('/api/v1/admin/dashboard'); },
|
||||
|
||||
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
|
||||
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
|
||||
return this._post('/api/v1/admin/configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
|
||||
});
|
||||
},
|
||||
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
|
||||
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
|
||||
|
||||
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||||
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
|
||||
adminBulkUpdateModels(visibility) {
|
||||
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
|
||||
return this._put('/api/v1/admin/models/bulk', {
|
||||
visibility,
|
||||
is_enabled: visibility === 'enabled'
|
||||
});
|
||||
},
|
||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||
|
||||
// ── Admin Personas ────────────────────────
|
||||
adminListPersonas() { return this._get('/api/v1/admin/personas'); },
|
||||
adminCreatePersona(persona) { return this._post('/api/v1/admin/personas', persona); },
|
||||
adminUpdatePersona(id, updates) { return this._put(`/api/v1/admin/personas/${id}`, updates); },
|
||||
adminDeletePersona(id) { return this._del(`/api/v1/admin/personas/${id}`); },
|
||||
adminUploadPersonaAvatar(id, base64Image) { return this._post(`/api/v1/admin/personas/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePersonaAvatar(id) { return this._del(`/api/v1/admin/personas/${id}/avatar`); },
|
||||
adminPersonaKBs(personaId) { return this._get(`/api/v1/admin/personas/${personaId}/knowledge-bases`); },
|
||||
adminSetPersonaKBs(personaId, data) { return this._put(`/api/v1/admin/personas/${personaId}/knowledge-bases`, data); },
|
||||
|
||||
// ── Admin Teams ─────────────────────────
|
||||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||
|
||||
// ── Admin Audit ─────────────────────────
|
||||
adminListAudit(params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', params.page);
|
||||
if (params?.per_page) q.set('per_page', params.per_page);
|
||||
if (params?.action) q.set('action', params.action);
|
||||
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||
return this._get(`/api/v1/admin/audit?${q}`);
|
||||
},
|
||||
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
|
||||
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
|
||||
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
|
||||
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
|
||||
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
|
||||
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
|
||||
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
|
||||
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
|
||||
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
|
||||
|
||||
// ── Admin Groups ────────────────────────
|
||||
adminListGroups() { return this._get('/api/v1/admin/groups'); },
|
||||
adminCreateGroup(name, description, scope, teamId) {
|
||||
const body = { name, description, scope };
|
||||
if (teamId) body.team_id = teamId;
|
||||
return this._post('/api/v1/admin/groups', body);
|
||||
},
|
||||
adminGetGroup(id) { return this._get(`/api/v1/admin/groups/${id}`); },
|
||||
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
|
||||
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
|
||||
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
|
||||
|
||||
// Admin — Projects (v0.19.0)
|
||||
adminListProjects(includeArchived) {
|
||||
const q = includeArchived ? '?include_archived=true' : '';
|
||||
return this._get(`/api/v1/admin/projects${q}`);
|
||||
},
|
||||
adminDeleteProject(id) { return this._del(`/api/v1/admin/projects/${id}`); },
|
||||
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
|
||||
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
|
||||
|
||||
// ── Admin Resource Grants ───────────────
|
||||
adminGetGrant(type, id) { return this._get(`/api/v1/admin/grants/${type}/${id}`); },
|
||||
adminSetGrant(type, id, grantScope, grantedGroups) {
|
||||
return this._put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: grantScope, granted_groups: grantedGroups || [] });
|
||||
},
|
||||
adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); },
|
||||
|
||||
// ── Permissions (v0.24.2) ───────────────
|
||||
adminListPermissions() { return this._get('/api/v1/admin/permissions'); },
|
||||
adminGetUserPermissions(userId) { return this._get(`/api/v1/admin/users/${userId}/permissions`); },
|
||||
|
||||
// ── User Groups ─────────────────────────
|
||||
listMyGroups() { return this._get('/api/v1/groups/mine'); },
|
||||
|
||||
// ── User Teams ──────────────────────────
|
||||
listMyTeams() { return this._get('/api/v1/teams/mine'); },
|
||||
|
||||
// ── Team Admin Self-Service ─────────────
|
||||
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
|
||||
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
|
||||
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
|
||||
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
||||
teamListPersonas(teamId) { return this._get(`/api/v1/teams/${teamId}/personas`); },
|
||||
teamCreatePersona(teamId, persona) { return this._post(`/api/v1/teams/${teamId}/personas`, persona); },
|
||||
teamGetPersonaKBs(teamId, personaId) { return this._get(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`); },
|
||||
teamSetPersonaKBs(teamId, personaId, data) { return this._put(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`, data); },
|
||||
teamDeletePersona(teamId, personaId) { return this._del(`/api/v1/teams/${teamId}/personas/${personaId}`); },
|
||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
|
||||
|
||||
// ── Team Providers ──────────────────────
|
||||
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
|
||||
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
|
||||
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
|
||||
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
|
||||
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
|
||||
teamListAudit(teamId, params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', params.page);
|
||||
if (params?.per_page) q.set('per_page', params.per_page);
|
||||
if (params?.action) q.set('action', params.action);
|
||||
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
|
||||
},
|
||||
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
|
||||
|
||||
// ── User Personas ─────────────────────────
|
||||
listPersonas() { return this._get('/api/v1/personas'); },
|
||||
createPersona(persona) { return this._post('/api/v1/personas', persona); },
|
||||
updatePersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
|
||||
deletePersona(id) { return this._del(`/api/v1/personas/${id}`); },
|
||||
|
||||
// ── Notes ────────────────────────────────
|
||||
listNotes(limit = 50, offset = 0, folder, tag, sort) {
|
||||
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
|
||||
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
|
||||
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
|
||||
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
||||
return this._get(url);
|
||||
},
|
||||
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
|
||||
const body = { title, content };
|
||||
if (folderPath) body.folder_path = folderPath;
|
||||
if (tags?.length) body.tags = tags;
|
||||
if (sourceChannelId) body.source_channel_id = sourceChannelId;
|
||||
if (sourceMessageId) body.source_message_id = sourceMessageId;
|
||||
return this._post('/api/v1/notes', body);
|
||||
},
|
||||
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
||||
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
|
||||
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
||||
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
|
||||
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||
searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||||
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
|
||||
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
|
||||
|
||||
// ── Files ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a file to a channel. Uses multipart/form-data (not JSON).
|
||||
* Returns the created file object with id, metadata, etc.
|
||||
*/
|
||||
async uploadFile(channelId, file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
|
||||
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
// No Content-Type — browser sets multipart boundary automatically
|
||||
body: form,
|
||||
});
|
||||
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401) {
|
||||
if (await this._handle401()) resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return this._parseJSON(resp, `/api/v1/channels/${channelId}/files`);
|
||||
},
|
||||
|
||||
getFile(id) { return this._get(`/api/v1/files/${id}`); },
|
||||
|
||||
async downloadFileBlob(id) {
|
||||
const doFetch = () => fetch(BASE + `/api/v1/files/${id}/download`, {
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
});
|
||||
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401) {
|
||||
if (await this._handle401()) resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.blob();
|
||||
},
|
||||
|
||||
deleteFile(id) { return this._del(`/api/v1/files/${id}`); },
|
||||
listChannelFiles(channelId) { return this._get(`/api/v1/channels/${channelId}/files`); },
|
||||
|
||||
// ── Admin Storage ───────────────────────
|
||||
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
|
||||
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
|
||||
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
|
||||
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
|
||||
|
||||
// Vault
|
||||
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
|
||||
|
||||
// ── Admin Roles ─────────────────────────
|
||||
adminListRoles() { return this._get('/api/v1/admin/roles'); },
|
||||
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
|
||||
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
|
||||
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
|
||||
|
||||
// ── Admin Usage & Pricing ───────────────
|
||||
adminGetUsage(params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.period) q.set('period', params.period);
|
||||
if (params?.since) q.set('since', params.since);
|
||||
if (params?.until) q.set('until', params.until);
|
||||
if (params?.group_by) q.set('group_by', params.group_by);
|
||||
return this._get(`/api/v1/admin/usage?${q}`);
|
||||
},
|
||||
adminGetUserUsage(userId, params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.period) q.set('period', params.period);
|
||||
if (params?.group_by) q.set('group_by', params.group_by);
|
||||
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
|
||||
},
|
||||
adminGetTeamUsage(teamId, params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.period) q.set('period', params.period);
|
||||
if (params?.group_by) q.set('group_by', params.group_by);
|
||||
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
|
||||
},
|
||||
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
|
||||
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
|
||||
adminDeletePricing(providerConfigId, modelId) {
|
||||
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
|
||||
},
|
||||
|
||||
// ── Admin Provider Health (v0.22.3) ─────
|
||||
adminGetAllProviderHealth() { return this._get('/api/v1/admin/providers/health'); },
|
||||
adminGetProviderHealth(id) { return this._get(`/api/v1/admin/providers/${id}/health`); },
|
||||
|
||||
// ── Admin Routing Policies (v0.22.3) ────
|
||||
adminListRoutingPolicies() { return this._get('/api/v1/admin/routing/policies'); },
|
||||
adminGetRoutingPolicy(id) { return this._get(`/api/v1/admin/routing/policies/${id}`); },
|
||||
adminCreateRoutingPolicy(policy) { return this._post('/api/v1/admin/routing/policies', policy); },
|
||||
adminUpdateRoutingPolicy(id, policy) { return this._put(`/api/v1/admin/routing/policies/${id}`, policy); },
|
||||
adminDeleteRoutingPolicy(id) { return this._del(`/api/v1/admin/routing/policies/${id}`); },
|
||||
adminTestRouting(model, userId, teamIds) {
|
||||
return this._post('/api/v1/admin/routing/test', { model, user_id: userId, team_ids: teamIds || [] });
|
||||
},
|
||||
|
||||
// ── Admin Capabilities & Provider Types (v0.22.3) ──
|
||||
adminGetModelCapabilities(modelId) { return this._get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`); },
|
||||
adminSetModelCapability(modelId, overrides) { return this._put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, overrides); },
|
||||
adminDeleteModelCapability(modelId, overrideId) { return this._del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${overrideId}`); },
|
||||
adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); },
|
||||
adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); },
|
||||
|
||||
// ── Project Files (v0.22.4) ─────────────
|
||||
async projectUploadFile(projectId, file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
const doFetch = () => fetch(BASE + `/api/v1/projects/${projectId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
body: form,
|
||||
});
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401) {
|
||||
await this._refreshAccessToken();
|
||||
resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
projectListFiles(projectId) { return this._get(`/api/v1/projects/${projectId}/files`); },
|
||||
|
||||
// ── Export (v0.22.4) ────────────────────
|
||||
exportDocument(content, format, filename) {
|
||||
return this._post('/api/v1/export', { content, format, filename });
|
||||
},
|
||||
|
||||
// ── User Usage ──────────────────────────
|
||||
getMyUsage(params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.period) q.set('period', params.period);
|
||||
if (params?.group_by) q.set('group_by', params.group_by);
|
||||
return this._get(`/api/v1/usage?${q}`);
|
||||
},
|
||||
|
||||
// ── Team Roles ──────────────────────────
|
||||
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
|
||||
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
|
||||
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
|
||||
|
||||
// ── Team Usage ──────────────────────────
|
||||
teamGetUsage(teamId, params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.period) q.set('period', params.period);
|
||||
if (params?.group_by) q.set('group_by', params.group_by);
|
||||
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
|
||||
},
|
||||
|
||||
// ── Knowledge Bases ──────────────────────
|
||||
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
|
||||
listDiscoverableKBs() { return this._get('/api/v1/knowledge-bases-discoverable'); },
|
||||
createKnowledgeBase(name, description, scope, teamId) {
|
||||
const body = { name, description: description || '' };
|
||||
if (scope) body.scope = scope;
|
||||
if (teamId) body.team_id = teamId;
|
||||
return this._post('/api/v1/knowledge-bases', body);
|
||||
},
|
||||
getKnowledgeBase(id) { return this._get(`/api/v1/knowledge-bases/${id}`); },
|
||||
updateKnowledgeBase(id, updates) { return this._put(`/api/v1/knowledge-bases/${id}`, updates); },
|
||||
deleteKnowledgeBase(id) { return this._del(`/api/v1/knowledge-bases/${id}`); },
|
||||
searchKnowledgeBase(id, query, limit) {
|
||||
const body = { query };
|
||||
if (limit) body.limit = limit;
|
||||
return this._post(`/api/v1/knowledge-bases/${id}/search`, body);
|
||||
},
|
||||
|
||||
// KB Documents
|
||||
listKBDocuments(kbId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents`); },
|
||||
getKBDocumentStatus(kbId, docId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents/${docId}/status`); },
|
||||
deleteKBDocument(kbId, docId) { return this._del(`/api/v1/knowledge-bases/${kbId}/documents/${docId}`); },
|
||||
|
||||
async uploadKBDocument(kbId, file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
|
||||
const doFetch = () => fetch(BASE + `/api/v1/knowledge-bases/${kbId}/documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||
body: form,
|
||||
});
|
||||
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401) {
|
||||
if (await this._handle401()) resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return this._parseJSON(resp, `/api/v1/knowledge-bases/${kbId}/documents`);
|
||||
},
|
||||
|
||||
// Channel ↔ KB linking
|
||||
getChannelKBs(channelId) { return this._get(`/api/v1/channels/${channelId}/knowledge-bases`); },
|
||||
setChannelKBs(channelId, kbIds) { return this._put(`/api/v1/channels/${channelId}/knowledge-bases`, { kb_ids: kbIds }); },
|
||||
|
||||
// ── Memories (v0.18.0) ───────────────────
|
||||
listMemories(status, query) {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (query) params.set('query', query);
|
||||
const qs = params.toString();
|
||||
return this._get('/api/v1/memories' + (qs ? '?' + qs : ''));
|
||||
},
|
||||
getMemoryCount() { return this._get('/api/v1/memories/count'); },
|
||||
updateMemory(id, data) { return this._put(`/api/v1/memories/${id}`, data); },
|
||||
deleteMemory(id) { return this._del(`/api/v1/memories/${id}`); },
|
||||
approveMemory(id) { return this._post(`/api/v1/memories/${id}/approve`, {}); },
|
||||
rejectMemory(id) { return this._post(`/api/v1/memories/${id}/reject`, {}); },
|
||||
bulkApproveMemories(ids) { return this._post('/api/v1/admin/memories/bulk-approve', { ids }); },
|
||||
adminListPendingMemories() { return this._get('/api/v1/admin/memories/pending'); },
|
||||
|
||||
// ── HTTP Internals ───────────────────────
|
||||
|
||||
_esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
},
|
||||
|
||||
_authHeaders() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
};
|
||||
},
|
||||
|
||||
async _get(path) { return this._authed(path); },
|
||||
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
|
||||
async _put(path, body) { return this._authed(path, 'PUT', body); },
|
||||
async _del(path) { return this._authed(path, 'DELETE'); },
|
||||
async _delete(path) { return this._del(path); }, // alias — `delete` is a JS reserved word
|
||||
async _patch(path, body) { return this._authed(path, 'PATCH', body); },
|
||||
|
||||
async _authed(path, method = 'GET', body) {
|
||||
try {
|
||||
return await this._raw(path, method, body, true);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
if (await this._handle401()) {
|
||||
return this._raw(path, method, body, true);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async _raw(path, method = 'GET', body, auth = false) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
|
||||
const resp = await fetch(BASE + path, opts);
|
||||
if (!resp.ok) {
|
||||
// Check if the error response is also a proxy page
|
||||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.includes('text/html')) {
|
||||
let title = 'unknown';
|
||||
try {
|
||||
const html = await resp.text();
|
||||
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (m) title = m[1].trim();
|
||||
} catch (e) { /* */ }
|
||||
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
|
||||
err.status = resp.status;
|
||||
err.proxyBlocked = true;
|
||||
err.proxyTitle = title;
|
||||
throw err;
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return this._parseJSON(resp, path);
|
||||
}
|
||||
};
|
||||
|
||||
sb.ns('API', API);
|
||||
589
src/js/app.js
589
src/js/app.js
@@ -1,589 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application (Chat Surface)
|
||||
// ==========================================
|
||||
// Boot, auth, init, listener dispatch.
|
||||
// Shared state (App) and model loading (fetchModels) live in app-state.js
|
||||
// (loaded by base.html for all surfaces).
|
||||
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
|
||||
// admin-handlers.js. UI rendering in ui-*.js files.
|
||||
//
|
||||
// Exports: handleLogin, handleRegister, handleLogout, switchAuthTab,
|
||||
// startApp, initBanners
|
||||
|
||||
|
||||
async function init() {
|
||||
console.log('🔀 Chat Switchboard initializing...');
|
||||
initBranding(); // Apply branding before splash is visible
|
||||
API.loadTokens();
|
||||
|
||||
let health = null;
|
||||
try {
|
||||
health = await API.health();
|
||||
console.log('✅ Backend reachable:', health.version);
|
||||
} catch (e) {
|
||||
console.error('❌ Backend unreachable:', e.message);
|
||||
const splashErr = document.getElementById('splashError');
|
||||
if (e.proxyBlocked) {
|
||||
splashErr.innerHTML =
|
||||
`<strong>Network proxy blocked this request</strong><br>` +
|
||||
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
|
||||
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
|
||||
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
|
||||
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
|
||||
splashErr.innerHTML =
|
||||
`<strong>Connection timed out</strong><br>` +
|
||||
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
|
||||
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
|
||||
} else {
|
||||
splashErr.innerHTML =
|
||||
`<strong>Cannot reach server</strong><br>` +
|
||||
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
|
||||
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
|
||||
}
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (API.isAuthed) {
|
||||
try {
|
||||
await API.getProfile();
|
||||
console.log('✅ Session valid for', API.user?.username);
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Session expired, clearing');
|
||||
API.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
if (API.isAuthed) {
|
||||
try {
|
||||
await startApp();
|
||||
} catch (e) {
|
||||
console.error('💥 startApp crashed:', e);
|
||||
// Surface the error visibly (crash banner from chat.html)
|
||||
const b = document.getElementById('crashBanner');
|
||||
const d = document.getElementById('crashDetail');
|
||||
if (b && d) { b.style.display = ''; d.textContent += 'startApp: ' + e.message + '\n' + (e.stack || '') + '\n'; }
|
||||
}
|
||||
} else {
|
||||
showSplash(health);
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
hideSplash();
|
||||
UI.restoreSidebar();
|
||||
|
||||
// v0.25.0: Surface-aware initialization.
|
||||
// Common init runs on all surfaces. Chat-specific init only on chat.
|
||||
const surface = window.__SURFACE__ || 'chat';
|
||||
|
||||
// ── Common init (all surfaces) ──────────
|
||||
// v0.28.5: Ensure SDK is initialized (idempotent — base.html also calls this).
|
||||
if (typeof Switchboard !== 'undefined') Switchboard.init();
|
||||
|
||||
if (typeof loadSettings === 'function') await loadSettings();
|
||||
|
||||
// Guard: if token was invalidated during startup
|
||||
if (!API.isAuthed) {
|
||||
console.warn('⚠️ Auth lost during startup, returning to login');
|
||||
showSplash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updateUser();
|
||||
UI.showAdminButton(sw.isAdmin);
|
||||
UI.restoreAppearance();
|
||||
|
||||
// Load models for all surfaces (editor needs them for model selector)
|
||||
await fetchModels();
|
||||
|
||||
// Connect EventBus WebSocket (non-blocking)
|
||||
try {
|
||||
Events.connect((window.__BASE__ || '') + '/ws');
|
||||
sw.on('ws.connected', () => {
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('EventBus connect error:', e.message);
|
||||
}
|
||||
|
||||
// Signal that common init is complete — surface-specific JS can react
|
||||
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
|
||||
|
||||
// ── Chat surface init ───────────────────
|
||||
if (surface === 'chat') {
|
||||
await _startChatSurface();
|
||||
}
|
||||
|
||||
// Editor, notes, admin, settings — their surface-specific JS handles
|
||||
// the rest via DOMContentLoaded listeners in their own boot scripts.
|
||||
}
|
||||
|
||||
// Chat-surface-specific initialization (extracted from monolithic startApp)
|
||||
async function _startChatSurface() {
|
||||
// v0.23.1: Load folders and channels before rendering sidebar
|
||||
await loadFolders();
|
||||
await loadChannels();
|
||||
|
||||
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
|
||||
// are registered when messages are first rendered.
|
||||
try {
|
||||
await Extensions.loadAll(); // fetch manifests + inject <script> tags
|
||||
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
|
||||
} catch (e) {
|
||||
console.warn('Extension init error:', e.message);
|
||||
}
|
||||
|
||||
await loadChats();
|
||||
await loadProjects();
|
||||
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
|
||||
// Models already loaded in common init; update chat-surface-specific UI
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
}
|
||||
|
||||
// v0.23.2: Load user list for @mention autocomplete
|
||||
await loadUsers();
|
||||
|
||||
await initBanners();
|
||||
initFiles();
|
||||
|
||||
// v0.23.2: Create primary ChatPane instance from server-rendered mount points
|
||||
if (typeof ChatPane !== 'undefined') {
|
||||
ChatPane.primary = ChatPane.create({
|
||||
id: 'main',
|
||||
messagesEl: document.getElementById('chatMessages'),
|
||||
inputEl: document.getElementById('chatInputBar'),
|
||||
standalone: false,
|
||||
});
|
||||
}
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
|
||||
if (typeof KnowledgeUI !== 'undefined') {
|
||||
KnowledgeUI.init();
|
||||
} else {
|
||||
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
|
||||
}
|
||||
UI.renderChatList();
|
||||
UI.renderChannelsSection();
|
||||
UI.restoreSidebarSections();
|
||||
UI.updateModelSelector();
|
||||
|
||||
// Restore last-active conversation from sessionStorage
|
||||
try {
|
||||
const saved = sessionStorage.getItem('cs-active-conversation');
|
||||
if (saved) {
|
||||
const ac = JSON.parse(saved);
|
||||
if (ac?.id) {
|
||||
const inChats = App.chats.some(c => c.id === ac.id);
|
||||
const inChannels = (App.channels || []).some(c => c.id === ac.id);
|
||||
if (inChats) {
|
||||
selectChat(ac.id);
|
||||
} else if (inChannels) {
|
||||
selectChannel(ac.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// If no conversation was restored, show the welcome/empty state
|
||||
if (!App.activeId) {
|
||||
UI.showEmptyState();
|
||||
}
|
||||
|
||||
initListeners();
|
||||
|
||||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||
sw.on('role.fallback', (payload) => {
|
||||
if (!sw.isAdmin) return;
|
||||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||
showFallbackBanner(data);
|
||||
});
|
||||
|
||||
console.log('✅ Chat Switchboard ready');
|
||||
}
|
||||
|
||||
// ── Role Fallback Banner (v0.17.0) ──────────
|
||||
|
||||
function showFallbackBanner(data) {
|
||||
let banner = document.getElementById('roleFallbackBanner');
|
||||
if (!banner) {
|
||||
// Create persistent banner above messages
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'roleFallbackBanner';
|
||||
banner.className = 'role-fallback-banner';
|
||||
const msgs = document.getElementById('chatMessages');
|
||||
if (msgs) msgs.parentNode.insertBefore(banner, msgs);
|
||||
else return;
|
||||
}
|
||||
const msg = data.message || `Role "${data.role}" primary failed — using fallback`;
|
||||
banner.innerHTML = `
|
||||
<span class="fallback-icon">⚠️</span>
|
||||
<span class="fallback-msg">${esc(msg)}</span>
|
||||
<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>`;
|
||||
banner.style.display = '';
|
||||
}
|
||||
|
||||
// ── Modal Helpers ───────────────────────────
|
||||
|
||||
// ── Auth Flow ────────────────────────────────
|
||||
|
||||
function showSplash(health) {
|
||||
// v0.22.6: Server-rendered architecture uses /login page.
|
||||
// Clear stale cookie so the redirect sticks (no loop).
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
const base = window.__BASE__ || '';
|
||||
window.location.href = base + '/login';
|
||||
}
|
||||
|
||||
function hideSplash() {
|
||||
const splash = document.getElementById('splashGate');
|
||||
const app = document.getElementById('appContainer');
|
||||
if (splash) splash.style.display = 'none';
|
||||
if (app) app.style.display = '';
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
const login = document.getElementById('authLogin').value.trim();
|
||||
const password = document.getElementById('authPassword').value;
|
||||
if (!login || !password) return setAuthError('Fill in all fields');
|
||||
setAuthLoading(true);
|
||||
try { await API.login(login, password); await startApp(); }
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const username = document.getElementById('authUsername').value.trim();
|
||||
const email = document.getElementById('authEmail').value.trim();
|
||||
const password = document.getElementById('authRegPassword').value;
|
||||
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||||
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||||
setAuthLoading(true);
|
||||
try {
|
||||
const resp = await API.register(username, email, password);
|
||||
if (resp.pending) {
|
||||
setAuthError('');
|
||||
const errEl = document.getElementById('authError');
|
||||
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
|
||||
errEl.style.color = 'var(--accent)';
|
||||
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
|
||||
} else {
|
||||
await startApp();
|
||||
}
|
||||
}
|
||||
catch (e) { setAuthError(e.message); }
|
||||
finally { setAuthLoading(false); }
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (!await showConfirm('Sign out?')) return;
|
||||
Events.disconnect();
|
||||
Events.clear();
|
||||
await API.logout();
|
||||
App.chats = [];
|
||||
App.setActive(null);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function switchAuthTab(tab) {
|
||||
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
||||
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
||||
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||||
const hdr = document.querySelector('.auth-card-header');
|
||||
if (hdr) {
|
||||
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
hdr.querySelector('p').textContent = tab === 'login'
|
||||
? 'Sign in to continue to your workspace'
|
||||
: 'Get started with Chat Switchboard';
|
||||
}
|
||||
setAuthError('');
|
||||
}
|
||||
|
||||
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
|
||||
function setAuthLoading(on) {
|
||||
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
|
||||
btn.disabled = on;
|
||||
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event Listeners ─────────────────────────
|
||||
// Thin dispatcher — domain listeners extracted to their own files.
|
||||
|
||||
let _listenersInit = false;
|
||||
function initListeners() {
|
||||
if (_listenersInit) return;
|
||||
_listenersInit = true;
|
||||
|
||||
_initChatListeners(); // from chat.js
|
||||
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
|
||||
// _initNotesListeners + _registerNotesPanel removed in v0.37.9 — see sw/components/notes-pane/
|
||||
_initFileListeners(); // from files.js
|
||||
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
|
||||
_registerProjectPanel(); // from projects-ui.js — register project detail panel
|
||||
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
|
||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
||||
_initWorkspaceResize(); // from panels.js — workspace handle drag
|
||||
_initPanelSwipe(); // from panels.js — mobile swipe navigation
|
||||
_initPanelResponsive(); // from panels.js — responsive overlay
|
||||
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
|
||||
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
|
||||
}
|
||||
|
||||
function _initSidebarTabs() {
|
||||
const tabBar = document.getElementById('sidebarTabs');
|
||||
if (!tabBar) return;
|
||||
|
||||
tabBar.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.sidebar-tab');
|
||||
if (!btn) return;
|
||||
const tabName = btn.dataset.tab;
|
||||
|
||||
// Update active tab
|
||||
tabBar.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t === btn));
|
||||
|
||||
// Show/hide panels
|
||||
document.querySelectorAll('.sidebar-tab-panel').forEach(p => {
|
||||
p.style.display = p.dataset.tabPanel === tabName ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Show or hide the Files tab. Called by EditorMode on register/unregister. */
|
||||
function showSidebarFilesTab(show) {
|
||||
const tab = document.getElementById('sidebarFilesTab');
|
||||
if (tab) tab.style.display = show ? '' : 'none';
|
||||
// If hiding and Files was active, switch back to Chats
|
||||
if (!show && tab?.classList.contains('active')) {
|
||||
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
|
||||
if (chatsTab) chatsTab.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _initGlobalKeyboard() {
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Escape: stop generation → close command palette → close side panel → close topmost modal
|
||||
if (e.key === 'Escape') {
|
||||
if (App.isGenerating) { stopGeneration(); return; }
|
||||
if (document.getElementById('lightbox')?.classList.contains('active')) {
|
||||
closeLightbox(); return;
|
||||
}
|
||||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||||
closeCmdPalette(); return;
|
||||
}
|
||||
if (PanelRegistry.isContainerOpen()) {
|
||||
PanelRegistry.closeAll(); return;
|
||||
}
|
||||
const open = [...document.querySelectorAll('.modal-overlay.active')];
|
||||
if (open.length) closeModal(open[open.length - 1].id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+K: command palette
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
toggleCmdPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+\: cycle side panels
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PanelRegistry.cycle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Shift+S: focus sidebar search
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
|
||||
e.preventDefault();
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb.classList.contains('collapsed')) UI.toggleSidebar();
|
||||
document.getElementById('chatSearchInput')?.focus();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Recheck tab overflow on resize / zoom changes
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Branding ────────────────────────────────
|
||||
|
||||
function initBranding() {
|
||||
const b = window.__BRANDING__;
|
||||
if (!b || typeof b !== 'object') return;
|
||||
|
||||
const brandBase = (window.__BASE__ || '') + '/branding/';
|
||||
|
||||
// Accent color → CSS custom property
|
||||
if (b.accent_color) {
|
||||
document.documentElement.style.setProperty('--accent-color', b.accent_color);
|
||||
}
|
||||
|
||||
// Page title
|
||||
if (b.org_name) {
|
||||
document.title = b.org_name;
|
||||
}
|
||||
|
||||
// Sidebar brand text
|
||||
const sidebarText = document.getElementById('brandSidebarText');
|
||||
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
|
||||
|
||||
// Splash hero wordmark
|
||||
const wordmark = document.getElementById('brandWordmark');
|
||||
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
|
||||
|
||||
// Splash headline — replace if branding provides it
|
||||
if (b.headline) {
|
||||
const headline = document.getElementById('brandHeadline');
|
||||
if (headline) headline.textContent = b.headline;
|
||||
}
|
||||
|
||||
// Splash tagline
|
||||
const tagline = document.getElementById('brandTagline');
|
||||
if (tagline && b.tagline) tagline.textContent = b.tagline;
|
||||
|
||||
// Auth card text
|
||||
if (b.org_name) {
|
||||
const authHeader = document.querySelector('.auth-card-header h2');
|
||||
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
|
||||
const authSub = document.querySelector('.auth-card-header p');
|
||||
if (authSub) authSub.textContent = 'Sign in to continue';
|
||||
}
|
||||
const authFooter = document.getElementById('brandAuthFooter');
|
||||
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
|
||||
|
||||
// Logo — replace SVG with <img> in splash hero
|
||||
if (b.logo) {
|
||||
const logoUrl = brandBase + b.logo;
|
||||
|
||||
const logoEl = document.getElementById('brandLogo');
|
||||
if (logoEl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = logoUrl;
|
||||
img.alt = b.org_name || 'Logo';
|
||||
img.className = 'hero-logo-img';
|
||||
img.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
const svg = logoEl.querySelector('svg');
|
||||
if (svg) svg.style.display = '';
|
||||
};
|
||||
const svg = logoEl.querySelector('svg');
|
||||
if (svg) svg.style.display = 'none';
|
||||
logoEl.appendChild(img);
|
||||
}
|
||||
|
||||
// Sidebar logo — replace emoji with small img
|
||||
const sidebarLogo = document.getElementById('brandSidebarLogo');
|
||||
if (sidebarLogo) {
|
||||
const originalEmoji = sidebarLogo.textContent;
|
||||
const img = document.createElement('img');
|
||||
img.src = logoUrl;
|
||||
img.alt = '';
|
||||
img.className = 'brand-logo-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
sidebarLogo.textContent = originalEmoji;
|
||||
};
|
||||
sidebarLogo.textContent = '';
|
||||
sidebarLogo.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
// Favicon
|
||||
if (b.favicon) {
|
||||
const favUrl = brandBase + b.favicon;
|
||||
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
|
||||
link.href = favUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// Feature pills — custom array replaces defaults, empty array hides them
|
||||
if (b.pills !== undefined) {
|
||||
const pillsEl = document.getElementById('brandPills');
|
||||
if (pillsEl) {
|
||||
if (!Array.isArray(b.pills) || b.pills.length === 0) {
|
||||
pillsEl.style.display = 'none';
|
||||
} else {
|
||||
pillsEl.innerHTML = b.pills.map(p => {
|
||||
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
|
||||
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
|
||||
}
|
||||
|
||||
// ── Banners ──────────────────────────────────
|
||||
|
||||
async function initBanners() {
|
||||
try {
|
||||
const data = await API.getPublicSettings?.() || {};
|
||||
|
||||
// Store policies for user-facing checks (allow_user_byok, etc.)
|
||||
App.policies = data.policies || {};
|
||||
|
||||
// Storage capabilities (file upload, vision)
|
||||
App.storageConfigured = !!data.storage_configured;
|
||||
|
||||
// Paste-to-file threshold (synced from backend, default 2000)
|
||||
App.pasteToFileChars = data.paste_to_file_chars || 2000;
|
||||
|
||||
// Also flatten into serverSettings for backward compat
|
||||
App.serverSettings = {};
|
||||
if (data.banner) App.serverSettings.banner = data.banner;
|
||||
if (data.branding) App.serverSettings.branding = data.branding;
|
||||
|
||||
const root = document.documentElement;
|
||||
const banner = data.banner;
|
||||
|
||||
// Clear previous banner state
|
||||
['bannerTop', 'bannerBottom'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.classList.remove('active'); el.textContent = ''; }
|
||||
});
|
||||
root.style.setProperty('--banner-top-height', '0px');
|
||||
root.style.setProperty('--banner-bottom-height', '0px');
|
||||
|
||||
if (!banner || !banner.enabled) return;
|
||||
|
||||
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
|
||||
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
|
||||
|
||||
const text = banner.text || '';
|
||||
|
||||
const top = document.getElementById('bannerTop');
|
||||
top.textContent = text;
|
||||
top.classList.add('active');
|
||||
root.style.setProperty('--banner-top-height', '22px');
|
||||
|
||||
const bot = document.getElementById('bannerBottom');
|
||||
bot.textContent = text;
|
||||
bot.classList.add('active');
|
||||
root.style.setProperty('--banner-bottom-height', '22px');
|
||||
} catch (e) {
|
||||
// Banners are optional — non-admin users may not have access to settings
|
||||
console.debug('Banner init skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boot ─────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.register('handleLogin', handleLogin);
|
||||
sb.register('handleRegister', handleRegister);
|
||||
sb.register('handleLogout', handleLogout);
|
||||
sb.register('switchAuthTab', switchAuthTab);
|
||||
sb.register('startApp', startApp);
|
||||
sb.register('initBanners', initBanners);
|
||||
@@ -1,437 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Channel Models (v0.20.0)
|
||||
// ==========================================
|
||||
// Multi-model management per channel.
|
||||
// - Model pills in chat header (add/remove/set default)
|
||||
// - @mention autocomplete in chat input
|
||||
// - Model attribution on assistant messages
|
||||
// ==========================================
|
||||
//
|
||||
// Exports: window.ChannelModels
|
||||
|
||||
|
||||
const ChannelModels = {
|
||||
|
||||
_roster: [], // current channel's model roster
|
||||
_channelId: null, // current channel ID
|
||||
_acVisible: false, // autocomplete dropdown visible
|
||||
|
||||
// ── Init / Lifecycle ────────────────────
|
||||
|
||||
init() {
|
||||
// Close autocomplete on click-outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.mention-ac')) {
|
||||
this.hideAutocomplete();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Roster Management ───────────────────
|
||||
|
||||
async load(channelId) {
|
||||
this._channelId = channelId;
|
||||
if (!channelId) {
|
||||
this._roster = [];
|
||||
this.renderPills();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const roster = await API.listChannelModels(channelId);
|
||||
const models = roster.models || roster;
|
||||
this._roster = Array.isArray(models) ? models : [];
|
||||
} catch (e) {
|
||||
console.debug('Channel models not loaded:', e.message);
|
||||
this._roster = [];
|
||||
}
|
||||
this.renderPills();
|
||||
},
|
||||
|
||||
getRoster() { return this._roster; },
|
||||
|
||||
getDefault() {
|
||||
return this._roster.find(m => m.is_default) || this._roster[0] || null;
|
||||
},
|
||||
|
||||
// ── Model Pills UI ──────────────────────
|
||||
|
||||
renderPills() {
|
||||
const container = document.getElementById('channelModelPills');
|
||||
if (!container) return;
|
||||
|
||||
if (this._roster.length <= 1) {
|
||||
container.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
const html = this._roster.map(m => `
|
||||
<span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}"
|
||||
data-model-id="${esc(m.id)}" title="${esc(m.model_id)}">
|
||||
<span class="ch-model-pill-name" data-action="ChannelModels.setDefault" data-args='${JSON.stringify([esc(m.id)])}'">${esc(m.display_name)}</span>
|
||||
<button class="ch-model-pill-remove" data-action="ChannelModels.remove" data-args='${JSON.stringify([esc(m.id)])}'" title="Remove model">✕</button>
|
||||
</span>
|
||||
`).join('') + `
|
||||
<button class="ch-model-add-btn" data-action="ChannelModels.showAddDialog" " title="Add model to channel">+ Add</button>
|
||||
`;
|
||||
container.innerHTML = html;
|
||||
},
|
||||
|
||||
async remove(recordId) {
|
||||
if (!this._channelId) return;
|
||||
try {
|
||||
const resp = await API.deleteChannelModel(this._channelId, recordId);
|
||||
this._roster = resp.models || [];
|
||||
this.renderPills();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to remove model: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async setDefault(recordId) {
|
||||
if (!this._channelId) return;
|
||||
try {
|
||||
const resp = await API.updateChannelModel(this._channelId, recordId, { is_default: true });
|
||||
this._roster = resp.models || [];
|
||||
this.renderPills();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to set default: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Add Model Dialog ────────────────────
|
||||
|
||||
showAddDialog() {
|
||||
if (!App.models || App.models.length === 0) {
|
||||
UI.toast('No models available — configure a provider first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out models already in the roster
|
||||
const rosterModelIds = new Set(this._roster.map(m => m.model_id));
|
||||
const available = App.models.filter(m => !m.isPersona && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
|
||||
|
||||
if (available.length === 0) {
|
||||
UI.toast('All available models are already added to this channel', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="ch-model-add-dialog" id="chModelAddDialog">
|
||||
<div class="ch-model-add-inner">
|
||||
<h3>Add Model to Channel</h3>
|
||||
<label>Model
|
||||
<select id="chModelAddSelect">
|
||||
${available.map(m => `<option value="${esc(m.baseModelId || m.id)}" data-config="${esc(m.configId || '')}" data-name="${esc(m.name)}">${esc(m.name)}${m.provider ? ` (${esc(m.provider)})` : ''}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label>Display Name (used for @mentions)
|
||||
<input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50">
|
||||
</label>
|
||||
<div class="ch-model-add-actions">
|
||||
<button class="btn-secondary" data-action="ChannelModels.closeAddDialog" ">Cancel</button>
|
||||
<button class="btn-primary" data-action="ChannelModels.submitAdd" ">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
|
||||
// Auto-fill display name from selected model
|
||||
const select = document.getElementById('chModelAddSelect');
|
||||
const nameInput = document.getElementById('chModelAddName');
|
||||
nameInput.value = _shortName(select.options[0]?.dataset.name || '');
|
||||
select.addEventListener('change', () => {
|
||||
nameInput.value = _shortName(select.options[select.selectedIndex]?.dataset.name || '');
|
||||
});
|
||||
|
||||
// Focus and select
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
},
|
||||
|
||||
closeAddDialog() {
|
||||
document.getElementById('chModelAddDialog')?.remove();
|
||||
},
|
||||
|
||||
async submitAdd() {
|
||||
const select = document.getElementById('chModelAddSelect');
|
||||
const nameInput = document.getElementById('chModelAddName');
|
||||
if (!select || !nameInput) return;
|
||||
|
||||
const modelId = select.value;
|
||||
const configId = select.options[select.selectedIndex]?.dataset.config || '';
|
||||
const displayName = nameInput.value.trim();
|
||||
|
||||
if (!displayName) {
|
||||
UI.toast('Display name is required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await API.addChannelModel(this._channelId, {
|
||||
model_id: modelId,
|
||||
provider_config_id: configId,
|
||||
display_name: displayName,
|
||||
is_default: this._roster.length === 0
|
||||
});
|
||||
this._roster = resp.models || [];
|
||||
this.renderPills();
|
||||
this.closeAddDialog();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to add model: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── @mention Autocomplete ───────────────
|
||||
|
||||
/**
|
||||
* Called from the chat input's keyup/input handler.
|
||||
* Shows a dropdown of channel models when user types @.
|
||||
*/
|
||||
onInput(inputEl) {
|
||||
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
|
||||
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
|
||||
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atIdx = before.lastIndexOf('@');
|
||||
if (atIdx < 0) { this.hideAutocomplete(); return; }
|
||||
|
||||
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
|
||||
this.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
|
||||
if (partial.length === 0) {
|
||||
// Just typed @ — show everything
|
||||
}
|
||||
|
||||
// Build match list from ALL enabled models + personas (not just roster)
|
||||
const allModels = (App.models || []).filter(m => !m.hidden);
|
||||
const modelMatches = allModels.filter(m => {
|
||||
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
|
||||
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
|
||||
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
|
||||
const firstName = name.split(' ')[0] || '';
|
||||
|
||||
if (partial.length === 0) return true;
|
||||
return handle.startsWith(partial)
|
||||
|| modelId.startsWith(partial)
|
||||
|| name.startsWith(partial)
|
||||
|| firstName.startsWith(partial);
|
||||
});
|
||||
|
||||
// v0.24.0: Context-aware user autocomplete.
|
||||
// direct / dm : no users (1:1 AI or already-defined human pair)
|
||||
// group : participants only (defined roster)
|
||||
// channel : full user list (open, ad-hoc)
|
||||
const activeType = App.activeType || 'direct';
|
||||
let userPool = [];
|
||||
if (activeType === 'channel') {
|
||||
userPool = App.users || [];
|
||||
} else if (activeType === 'group') {
|
||||
const pids = App.activeParticipants || [];
|
||||
userPool = (App.users || []).filter(u => pids.includes(u.id));
|
||||
}
|
||||
// direct and dm: userPool stays empty
|
||||
|
||||
const userMatches = userPool.filter(u => {
|
||||
const uhandle = (u.handle || '').toLowerCase();
|
||||
const uname = u.username.toLowerCase();
|
||||
const dname = (u.displayName || '').toLowerCase();
|
||||
if (partial.length === 0) return true;
|
||||
return uhandle.startsWith(partial) || uname.startsWith(partial) || dname.startsWith(partial);
|
||||
}).map(u => ({
|
||||
id: u.handle || u.username,
|
||||
name: u.displayName || u.username,
|
||||
baseModelId: u.handle || u.username,
|
||||
isPersona: false,
|
||||
isUser: true,
|
||||
personaHandle: null,
|
||||
personaAvatar: null,
|
||||
provider: '',
|
||||
}));
|
||||
|
||||
// Resolution order: personas first, then users, then models
|
||||
const personas = modelMatches.filter(m => m.isPersona);
|
||||
const models = modelMatches.filter(m => !m.isPersona);
|
||||
const matches = [...personas, ...userMatches, ...models];
|
||||
|
||||
if (matches.length === 0) { this.hideAutocomplete(); return; }
|
||||
// Cap at 10 to keep dropdown manageable
|
||||
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
|
||||
},
|
||||
|
||||
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
|
||||
this.hideAutocomplete();
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mention-ac';
|
||||
wrap.id = 'mentionAc';
|
||||
|
||||
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
|
||||
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
|
||||
wrap.style.left = inputRect.left + 'px';
|
||||
wrap.style.minWidth = '280px';
|
||||
|
||||
matches.forEach((m, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
|
||||
|
||||
// Determine the @handle to insert
|
||||
const mentionToken = m.isPersona
|
||||
? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id)
|
||||
: (m.baseModelId || m.id);
|
||||
item.dataset.handle = mentionToken;
|
||||
|
||||
const avatar = m.personaAvatar
|
||||
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
|
||||
: m.isPersona
|
||||
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
|
||||
: m.isUser
|
||||
? `<span class="mention-ac-avatar mention-ac-avatar-fallback mention-ac-user">👤</span>`
|
||||
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
|
||||
|
||||
const handleText = m.isPersona && m.personaHandle
|
||||
? `@${esc(m.personaHandle)}`
|
||||
: m.isUser
|
||||
? `@${esc(m.baseModelId)}`
|
||||
: `@${esc(m.baseModelId || m.id)}`;
|
||||
|
||||
const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : '');
|
||||
|
||||
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
|
||||
item.addEventListener('click', () => {
|
||||
this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
|
||||
});
|
||||
wrap.appendChild(item);
|
||||
});
|
||||
|
||||
document.body.appendChild(wrap);
|
||||
this._acVisible = true;
|
||||
},
|
||||
|
||||
hideAutocomplete() {
|
||||
document.getElementById('mentionAc')?.remove();
|
||||
this._acVisible = false;
|
||||
},
|
||||
|
||||
isAutocompleteVisible() { return this._acVisible; },
|
||||
|
||||
/**
|
||||
* Handle arrow keys and Enter when autocomplete is visible.
|
||||
* Returns true if the event was consumed.
|
||||
*/
|
||||
handleKey(e) {
|
||||
if (!this._acVisible) return false;
|
||||
const ac = document.getElementById('mentionAc');
|
||||
if (!ac) return false;
|
||||
|
||||
const items = ac.querySelectorAll('.mention-ac-item');
|
||||
let activeIdx = Array.from(items).findIndex(el => el.classList.contains('mention-ac-active'));
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
items[activeIdx]?.classList.remove('mention-ac-active');
|
||||
activeIdx = (activeIdx + 1) % items.length;
|
||||
items[activeIdx]?.classList.add('mention-ac-active');
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
items[activeIdx]?.classList.remove('mention-ac-active');
|
||||
activeIdx = (activeIdx - 1 + items.length) % items.length;
|
||||
items[activeIdx]?.classList.add('mention-ac-active');
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const active = items[activeIdx];
|
||||
if (active) active.click();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
this.hideAutocomplete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
_insertMention(inputEl, atIdx, cursorPos, displayName) {
|
||||
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
|
||||
const before = text.slice(0, atIdx);
|
||||
const after = text.slice(cursorPos);
|
||||
const newText = before + '@' + displayName + ' ' + after;
|
||||
|
||||
if (typeof inputEl.setValue === 'function') {
|
||||
inputEl.setValue(newText);
|
||||
} else {
|
||||
inputEl.value = newText;
|
||||
}
|
||||
|
||||
// Position cursor after the inserted mention
|
||||
const newPos = atIdx + 1 + displayName.length + 1;
|
||||
if (typeof inputEl.setCursorPos === 'function') {
|
||||
inputEl.setCursorPos(newPos);
|
||||
} else {
|
||||
inputEl.selectionStart = inputEl.selectionEnd = newPos;
|
||||
}
|
||||
|
||||
this.hideAutocomplete();
|
||||
inputEl.focus?.();
|
||||
},
|
||||
|
||||
// ── Model Display Name Resolution ───────
|
||||
|
||||
/**
|
||||
* Resolve a model_id to a display name using the current roster.
|
||||
* Falls back to App.models lookup, then the raw model_id.
|
||||
*/
|
||||
resolveDisplayName(modelId) {
|
||||
if (!modelId) return 'Assistant';
|
||||
const cm = this._roster.find(m => m.model_id === modelId);
|
||||
if (cm?.display_name) return cm.display_name;
|
||||
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
|
||||
return m?.name || modelId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve persona avatar for a channel model roster entry.
|
||||
* Looks up via persona_id in App.models.
|
||||
*/
|
||||
_resolveAvatar(rosterEntry) {
|
||||
if (!rosterEntry.persona_id) return null;
|
||||
const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id);
|
||||
return persona?.personaAvatar || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get persona info for a given model_id from the roster + App.models.
|
||||
* Returns { displayName, avatar, personaId } or null.
|
||||
*/
|
||||
resolvePersonaInfo(modelId) {
|
||||
const cm = this._roster.find(m => m.model_id === modelId);
|
||||
if (!cm) return null;
|
||||
const avatar = this._resolveAvatar(cm);
|
||||
return {
|
||||
displayName: cm.display_name || modelId,
|
||||
avatar: avatar,
|
||||
personaId: cm.persona_id || null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
function _shortName(fullName) {
|
||||
// "Claude 3 Opus (Anthropic)" → "Claude-3-Opus"
|
||||
return fullName
|
||||
.replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)"
|
||||
.trim()
|
||||
.replace(/\s+/g, '-'); // spaces → hyphens
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('ChannelModels', ChannelModels);
|
||||
@@ -1,160 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - ChatPane Component (DOM-binding mode)
|
||||
// ==========================================
|
||||
// v0.37.8: DOM-binding mode only. Standalone mode (mount + streaming)
|
||||
// moved to Preact ChatPane kit (src/js/sw/components/chat-pane/).
|
||||
// This file is retained for app.js ChatPane.primary on the legacy
|
||||
// chat surface. Will be deleted in v0.37.10.
|
||||
//
|
||||
// Exports: window.ChatPane
|
||||
|
||||
|
||||
const ChatPane = {
|
||||
...createComponentRegistry('ChatPane'),
|
||||
|
||||
create(opts) {
|
||||
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
||||
const instance = componentMixin({
|
||||
id,
|
||||
messagesEl: opts.messagesEl,
|
||||
inputEl: opts.inputEl,
|
||||
sendBtnEl: opts.sendBtnEl || null,
|
||||
modelSelEl: opts.modelSelEl || null,
|
||||
toolbarEl: opts.toolbarEl || null,
|
||||
channelId: opts.channelId || null,
|
||||
standalone: opts.standalone || false,
|
||||
_cmView: null,
|
||||
_typing: null,
|
||||
|
||||
renderMessages(messages) {
|
||||
if (!this.messagesEl) return;
|
||||
if (typeof UI !== 'undefined' && UI._renderMessages) {
|
||||
UI._renderMessages(messages, this);
|
||||
} else {
|
||||
this.messagesEl.innerHTML = '';
|
||||
(messages || []).forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
div.innerHTML = typeof formatMessage === 'function'
|
||||
? formatMessage(m) : esc(m.content || '');
|
||||
this.messagesEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
appendChunk(html) {
|
||||
if (!this.messagesEl) return;
|
||||
const last = this.messagesEl.querySelector('.chat-msg--streaming');
|
||||
if (last) {
|
||||
const content = last.querySelector('.chat-msg__content');
|
||||
if (content) content.innerHTML = html;
|
||||
} else {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
div.innerHTML = '<div class="chat-msg__content">' + html + '</div>';
|
||||
this.messagesEl.appendChild(div);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
finalizeStream() {
|
||||
if (!this.messagesEl) return;
|
||||
const el = this.messagesEl.querySelector('.chat-msg--streaming');
|
||||
if (el) el.classList.remove('chat-msg--streaming');
|
||||
},
|
||||
|
||||
appendTyping() {
|
||||
if (!this.messagesEl || this._typing) return;
|
||||
this._typing = document.createElement('div');
|
||||
this._typing.className = 'chat-msg chat-msg--typing';
|
||||
this._typing.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
|
||||
this.messagesEl.appendChild(this._typing);
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
removeTyping() {
|
||||
if (this._typing) { this._typing.remove(); this._typing = null; }
|
||||
},
|
||||
|
||||
scrollToBottom(smooth) {
|
||||
if (!this.messagesEl) return;
|
||||
const el = this.messagesEl;
|
||||
if (smooth) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
else el.scrollTop = el.scrollHeight;
|
||||
},
|
||||
|
||||
isScrolledToBottom() {
|
||||
if (!this.messagesEl) return true;
|
||||
const el = this.messagesEl;
|
||||
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 60;
|
||||
},
|
||||
|
||||
setChannel(channelId) { this.channelId = channelId; },
|
||||
getChannel() { return this.channelId; },
|
||||
|
||||
showWelcome(html) {
|
||||
if (!this.messagesEl) return;
|
||||
this.messagesEl.innerHTML = html || '<div class="chat-welcome">' +
|
||||
'<p class="chat-welcome__title">Start a conversation</p>' +
|
||||
'<p class="chat-welcome__hint">Type a message below to get started.</p></div>';
|
||||
},
|
||||
|
||||
clear() {
|
||||
if (this.messagesEl) this.messagesEl.innerHTML = '';
|
||||
this.removeTyping();
|
||||
},
|
||||
|
||||
getInputValue() {
|
||||
if (this._cmView) return this._cmView.state.doc.toString();
|
||||
const ta = this.inputEl && this.inputEl.querySelector('textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
setInputValue(val) {
|
||||
if (this._cmView) {
|
||||
this._cmView.dispatch({
|
||||
changes: { from: 0, to: this._cmView.state.doc.length, insert: val }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ta = this.inputEl && this.inputEl.querySelector('textarea');
|
||||
if (ta) ta.value = val;
|
||||
},
|
||||
|
||||
clearInput() { this.setInputValue(''); },
|
||||
|
||||
focusInput() {
|
||||
if (this._cmView) this._cmView.focus();
|
||||
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
|
||||
},
|
||||
|
||||
_cleanup() {
|
||||
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
|
||||
this.removeTyping();
|
||||
},
|
||||
}, ChatPane);
|
||||
|
||||
ChatPane._register(id, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
forChannel(channelId) {
|
||||
for (const [, inst] of this._instances) {
|
||||
if (inst.channelId === channelId) return inst;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
active() { return this.primary; },
|
||||
|
||||
};
|
||||
|
||||
// v0.37.8: Standalone mode (mount + streaming + model selector + history)
|
||||
// moved to Preact ChatPane kit at src/js/sw/components/chat-pane/.
|
||||
// sw.chat() now prefers the new Preact path; this file retains only
|
||||
// DOM-binding mode for the legacy chat surface (app.js ChatPane.primary).
|
||||
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('ChatPane', ChatPane);
|
||||
1344
src/js/chat.js
1344
src/js/chat.js
File diff suppressed because it is too large
Load Diff
@@ -1,506 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Extension System
|
||||
// ==========================================
|
||||
// Loader, registry, scoped context, and renderer pipeline.
|
||||
// Tier 0 (browser) extensions register here. The ctx object
|
||||
// enforces permissions declared in the manifest.
|
||||
//
|
||||
// Usage:
|
||||
// Extensions.register({
|
||||
// id: 'my-ext',
|
||||
// init(ctx) { ctx.renderers.register(...); },
|
||||
// destroy() { /* cleanup */ }
|
||||
// });
|
||||
//
|
||||
// Load order: events.js → extensions.js → [ext scripts] → api.js → app.js
|
||||
// ==========================================
|
||||
//
|
||||
// Exports: window.Extensions
|
||||
|
||||
|
||||
const Extensions = {
|
||||
|
||||
// ── State ────────────────────────────────
|
||||
_registry: new Map(), // extId → { def, ctx, instance }
|
||||
_renderers: [], // sorted by priority
|
||||
_toolHandlers: new Map(), // toolName → { extId, handler }
|
||||
_loaded: false,
|
||||
_manifests: [], // loaded from server
|
||||
|
||||
// ── Script Loading ────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch enabled browser extensions from the server and inject their scripts.
|
||||
* Called before app init so extensions can register before initAll().
|
||||
*/
|
||||
async loadAll() {
|
||||
try {
|
||||
const resp = await API._get('/api/v1/extensions?tier=browser');
|
||||
const exts = resp.data || [];
|
||||
this._manifests = exts;
|
||||
|
||||
for (const ext of exts) {
|
||||
// Parse manifest to check for _script (inline) or entry (file)
|
||||
const manifest = ext.manifest || {};
|
||||
const extId = ext.id;
|
||||
|
||||
if (manifest._script) {
|
||||
// Inject script tag pointing at the asset endpoint
|
||||
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/main.js`);
|
||||
} else if (manifest.entry) {
|
||||
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/${manifest.entry}`);
|
||||
}
|
||||
}
|
||||
console.log(`[Extensions] Loaded ${exts.length} browser extension(s)`);
|
||||
} catch (e) {
|
||||
console.warn('[Extensions] Failed to load extensions:', e.message || e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Inject a script tag and wait for it to load.
|
||||
*/
|
||||
_injectScript(extId, src) {
|
||||
return new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.dataset.extension = extId;
|
||||
script.onload = () => { resolve(); };
|
||||
script.onerror = () => {
|
||||
console.error(`[Extensions] Failed to load script for ${extId}: ${src}`);
|
||||
resolve(); // Don't block other extensions
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Registration ─────────────────────────
|
||||
|
||||
/**
|
||||
* Register a browser extension. Called by extension scripts.
|
||||
* @param {object} def — { id, init(ctx), destroy() }
|
||||
*/
|
||||
register(def) {
|
||||
if (!def || !def.id) {
|
||||
console.error('[Extensions] register() requires an id');
|
||||
return;
|
||||
}
|
||||
if (this._registry.has(def.id)) {
|
||||
console.warn(`[Extensions] ${def.id} already registered, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { def, ctx: null, instance: null, active: false };
|
||||
this._registry.set(def.id, entry);
|
||||
console.log(`[Extensions] Registered: ${def.id}`);
|
||||
},
|
||||
|
||||
// ── Lifecycle ────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize all registered extensions. Called once after app startup.
|
||||
* Builds scoped ctx for each and calls init().
|
||||
*/
|
||||
async initAll() {
|
||||
// Set up the tool bridge: listen for tool.call.* events from server
|
||||
this._setupToolBridge();
|
||||
|
||||
for (const [id, entry] of this._registry) {
|
||||
if (entry.active) continue;
|
||||
try {
|
||||
entry.ctx = this._buildContext(id, entry.def);
|
||||
if (typeof entry.def.init === 'function') {
|
||||
await entry.def.init.call(entry.def, entry.ctx);
|
||||
}
|
||||
entry.active = true;
|
||||
console.log(`[Extensions] Initialized: ${id}`);
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Failed to init ${id}:`, e);
|
||||
}
|
||||
}
|
||||
this._loaded = true;
|
||||
Events.emit('extension.loaded', { count: this._registry.size }, { localOnly: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Destroy all extensions (logout/cleanup).
|
||||
*/
|
||||
destroyAll() {
|
||||
for (const [id, entry] of this._registry) {
|
||||
if (!entry.active) continue;
|
||||
try {
|
||||
if (typeof entry.def.destroy === 'function') {
|
||||
entry.def.destroy.call(entry.def);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Failed to destroy ${id}:`, e);
|
||||
}
|
||||
entry.active = false;
|
||||
entry.ctx = null;
|
||||
}
|
||||
this._renderers = [];
|
||||
this._loaded = false;
|
||||
},
|
||||
|
||||
// ── Context Builder ──────────────────────
|
||||
|
||||
/**
|
||||
* Build a scoped context object for an extension.
|
||||
* Each extension gets its own ctx with permission-aware proxies.
|
||||
*/
|
||||
_buildContext(extId, def) {
|
||||
const manifest = def.manifest || {};
|
||||
const permissions = new Set(manifest.permissions || []);
|
||||
|
||||
return {
|
||||
// Extension identity
|
||||
id: extId,
|
||||
|
||||
// Event bus (scoped — could filter by permissions later)
|
||||
events: {
|
||||
on: (label, fn) => Events.on(label, fn),
|
||||
once: (label, fn) => Events.once(label, fn),
|
||||
off: (label, fn) => Events.off(label, fn),
|
||||
emit: (label, payload) => Events.emit(label, payload, { localOnly: true }),
|
||||
},
|
||||
|
||||
// Scoped localStorage namespace
|
||||
storage: {
|
||||
get: (key) => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(`ext::${extId}::${key}`));
|
||||
} catch { return null; }
|
||||
},
|
||||
set: (key, value) => {
|
||||
localStorage.setItem(`ext::${extId}::${key}`, JSON.stringify(value));
|
||||
},
|
||||
remove: (key) => {
|
||||
localStorage.removeItem(`ext::${extId}::${key}`);
|
||||
},
|
||||
},
|
||||
|
||||
// Extension settings (read-only, from manifest defaults + user overrides)
|
||||
settings: Object.freeze(Object.assign(
|
||||
{},
|
||||
Extensions._extractDefaults(manifest.settings || {}),
|
||||
Extensions._getUserSettings(extId)
|
||||
)),
|
||||
|
||||
// Renderer registration
|
||||
renderers: {
|
||||
register: (name, opts) => Extensions._registerRenderer(extId, name, opts),
|
||||
},
|
||||
|
||||
// Tool registration (browser tool bridge)
|
||||
tools: {
|
||||
handle: (name, fn) => {
|
||||
Extensions._toolHandlers.set(name, { extId, handler: fn });
|
||||
console.log(`[Extensions] Tool registered: ${extId}:${name}`);
|
||||
},
|
||||
},
|
||||
|
||||
// UI primitives — safe wrappers around primary UI components.
|
||||
// Extensions use these instead of reaching into globals directly.
|
||||
ui: {
|
||||
/** Show a toast notification. type: 'success' | 'error' | 'warning' | 'info' */
|
||||
toast(msg, type = 'success') {
|
||||
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
|
||||
},
|
||||
|
||||
/** Open the side-panel preview with arbitrary HTML content. */
|
||||
openPreview(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';
|
||||
if (typeof PanelRegistry !== 'undefined') {
|
||||
PanelRegistry.open('preview');
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
},
|
||||
|
||||
/** Is the current theme dark? */
|
||||
isDark() {
|
||||
return document.body.classList.contains('dark-theme') ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
},
|
||||
|
||||
/** Is the viewport at mobile width (≤ 768px)? */
|
||||
isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
},
|
||||
|
||||
/** Is the side panel container currently open? */
|
||||
isPanelOpen() {
|
||||
if (typeof PanelRegistry !== 'undefined') return PanelRegistry.isContainerOpen();
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Show a confirm dialog. Returns Promise<boolean>. */
|
||||
confirm(msg, opts) {
|
||||
if (typeof showConfirm === 'function') return showConfirm(msg, opts);
|
||||
return Promise.resolve(window.confirm(msg));
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace a surface region's content with a new element.
|
||||
* Current children are preserved in memory (not destroyed)
|
||||
* and can be restored later. Critical for CM6 state preservation.
|
||||
*/
|
||||
replace(regionId, element) {
|
||||
console.warn(`[Extensions] ui.replace() requires surface system (removed in v0.22.6)`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore a surface region's previously saved content.
|
||||
*/
|
||||
restore(regionId) {
|
||||
console.warn(`[Extensions] ui.restore() requires surface system (removed in v0.22.6)`);
|
||||
},
|
||||
|
||||
/** Inject an element into a named UI region (stub for future use). */
|
||||
inject(region, el) {
|
||||
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
|
||||
},
|
||||
|
||||
/** Create a popup menu anchored to an element. */
|
||||
createMenu(anchor, opts) {
|
||||
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
|
||||
// Surface registration (removed v0.22.6 — server-rendered surfaces)
|
||||
surfaces: {
|
||||
register: (id, opts) => {
|
||||
console.warn(`[Extensions] surfaces.register() removed in v0.22.6`);
|
||||
},
|
||||
unregister: (id) => {
|
||||
console.warn(`[Extensions] surfaces.unregister() removed in v0.22.6`);
|
||||
},
|
||||
activate: (id) => {
|
||||
console.warn(`[Extensions] surfaces.activate() removed in v0.22.6`);
|
||||
},
|
||||
getCurrent: () => {
|
||||
return window.__SURFACE__ || 'chat';
|
||||
},
|
||||
},
|
||||
|
||||
// Model info (resolved at call time)
|
||||
get model() {
|
||||
return {
|
||||
id: typeof currentModelId !== 'undefined' ? currentModelId : null,
|
||||
};
|
||||
},
|
||||
|
||||
// User info
|
||||
get user() {
|
||||
return {
|
||||
id: typeof API !== 'undefined' ? API.user?.id : null,
|
||||
username: typeof API !== 'undefined' ? API.user?.username : null,
|
||||
role: typeof API !== 'undefined' ? API.user?.role : null,
|
||||
};
|
||||
},
|
||||
|
||||
// Proxied API fetch
|
||||
api: {
|
||||
fetch: (path, opts) => {
|
||||
if (typeof API !== 'undefined' && typeof API._fetch === 'function') {
|
||||
return API._fetch(path, opts);
|
||||
}
|
||||
return fetch(path, opts);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// ── Renderer Pipeline ────────────────────
|
||||
|
||||
/**
|
||||
* Register a custom renderer.
|
||||
* @param {string} extId — owning extension
|
||||
* @param {string} name — renderer name (unique within extension)
|
||||
* @param {object} opts — { pattern, render, priority, type }
|
||||
*
|
||||
* Types:
|
||||
* 'block' — operates on code blocks (receives lang, code, container)
|
||||
* 'inline' — operates on the full message HTML (receives html string, returns html string)
|
||||
* 'post' — operates on the rendered DOM (receives container element)
|
||||
*/
|
||||
_registerRenderer(extId, name, opts) {
|
||||
if (!opts || !opts.render) {
|
||||
console.error(`[Extensions] Renderer ${extId}:${name} requires a render function`);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
extId,
|
||||
name,
|
||||
type: opts.type || 'block',
|
||||
priority: opts.priority || 50,
|
||||
pattern: opts.pattern || null,
|
||||
render: opts.render,
|
||||
match: opts.match || null, // function(lang, code) → bool
|
||||
};
|
||||
|
||||
this._renderers.push(renderer);
|
||||
this._renderers.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// v0.28.5: Shim 'post' renderers into the SDK render pipe.
|
||||
// This makes old ctx.renderers.register() calls visible in
|
||||
// sw.pipe.list() and ordered alongside new sw.pipe.render() filters.
|
||||
// Block renderers stay in the old path (they run during formatMessage,
|
||||
// not after DOM insertion).
|
||||
if (renderer.type === 'post' && typeof sw !== 'undefined' && sw.pipe) {
|
||||
sw.pipe.render(renderer.priority, (renderCtx) => {
|
||||
try {
|
||||
renderer.render(renderCtx.element);
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Shimmed post-renderer ${extId}:${name} error:`, e);
|
||||
}
|
||||
return renderCtx;
|
||||
}, { source: `${extId}:${name}` });
|
||||
}
|
||||
|
||||
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Run block renderers on a code block element.
|
||||
* Called by ui-format.js after creating the code block DOM.
|
||||
* Returns true if a renderer handled the block (caller should skip default).
|
||||
*
|
||||
* @param {string} lang — language tag
|
||||
* @param {string} code — raw code content
|
||||
* @param {HTMLElement} container — the code block wrapper element
|
||||
*/
|
||||
runBlockRenderers(lang, code, container) {
|
||||
for (const r of this._renderers) {
|
||||
if (r.type !== 'block') continue;
|
||||
|
||||
let matched = false;
|
||||
if (r.match && typeof r.match === 'function') {
|
||||
matched = r.match(lang, code);
|
||||
} else if (r.pattern instanceof RegExp) {
|
||||
matched = r.pattern.test(lang);
|
||||
} else if (typeof r.pattern === 'string') {
|
||||
matched = lang === r.pattern;
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
try {
|
||||
r.render(lang, code, container);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Renderer ${r.extId}:${r.name} error:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Run post-render DOM processors on a message container.
|
||||
* Called after the full message HTML is inserted into the DOM.
|
||||
*
|
||||
* @param {HTMLElement} container — the message content element
|
||||
*/
|
||||
runPostRenderers(container) {
|
||||
for (const r of this._renderers) {
|
||||
if (r.type !== 'post') continue;
|
||||
try {
|
||||
r.render(container);
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Post-renderer ${r.extId}:${r.name} error:`, e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Helpers ──────────────────────────────
|
||||
|
||||
_extractDefaults(settingsSchema) {
|
||||
const defaults = {};
|
||||
for (const [key, def] of Object.entries(settingsSchema)) {
|
||||
if (def && 'default' in def) defaults[key] = def.default;
|
||||
}
|
||||
return defaults;
|
||||
},
|
||||
|
||||
_getUserSettings(extId) {
|
||||
// Find user settings from the manifest data loaded from API
|
||||
const manifest = this._manifests.find(m => m.id === extId);
|
||||
if (manifest?.user_settings) {
|
||||
try {
|
||||
return typeof manifest.user_settings === 'string'
|
||||
? JSON.parse(manifest.user_settings)
|
||||
: manifest.user_settings;
|
||||
} catch { return {}; }
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up the WebSocket bridge for browser tool execution.
|
||||
* Listens for tool.call.* events from the server, routes to
|
||||
* the registered handler, and sends tool.result.* back.
|
||||
*/
|
||||
_setupToolBridge() {
|
||||
Events.on('tool.call.*', async (payload, meta) => {
|
||||
const { call_id, tool, arguments: args } = payload || {};
|
||||
if (!call_id || !tool) return;
|
||||
|
||||
const registration = this._toolHandlers.get(tool);
|
||||
if (!registration) {
|
||||
// No handler registered — send error back
|
||||
Events.emit(`tool.result.${call_id}`, {
|
||||
call_id,
|
||||
error: JSON.stringify({ error: `no handler for tool: ${tool}` }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
|
||||
const result = await registration.handler(parsedArgs);
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
Events.emit(`tool.result.${call_id}`, {
|
||||
call_id,
|
||||
result: resultStr,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Tool ${tool} error:`, e);
|
||||
Events.emit(`tool.result.${call_id}`, {
|
||||
call_id,
|
||||
error: JSON.stringify({ error: e.message || 'tool execution failed' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if any renderers are registered for a given type.
|
||||
*/
|
||||
hasRenderers(type) {
|
||||
return this._renderers.some(r => r.type === type);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get info about all registered extensions (for debug/admin).
|
||||
*/
|
||||
debug() {
|
||||
const exts = {};
|
||||
for (const [id, entry] of this._registry) {
|
||||
exts[id] = {
|
||||
active: entry.active,
|
||||
renderers: this._renderers.filter(r => r.extId === id).map(r => r.name),
|
||||
};
|
||||
}
|
||||
return { extensions: exts, rendererCount: this._renderers.length };
|
||||
},
|
||||
};
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('Extensions', Extensions);
|
||||
857
src/js/files.js
857
src/js/files.js
@@ -1,857 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Files
|
||||
// ==========================================
|
||||
// File upload, smart paste, drag-and-drop, extraction
|
||||
// polling, staged lifecycle, message rendering, lightbox,
|
||||
// and admin storage panel.
|
||||
//
|
||||
// Depends on: API, App, UI (toast, getModelValue, renderChatList)
|
||||
// Consumed by: chat.js (send flow), ui-core.js (message rendering),
|
||||
// ui-admin.js (storage tab)
|
||||
|
||||
|
||||
// ── Constants ───────────────────────────────
|
||||
|
||||
const FILE_POLL_INTERVAL = 2000; // ms between extraction status checks
|
||||
const FILE_MAX_PER_MSG = 5; // mirrors backend defaultMaxFilesPerMsg
|
||||
|
||||
// MIME categories for capability-aware filtering
|
||||
const IMAGE_TYPES = new Set([
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
]);
|
||||
|
||||
// ── State ───────────────────────────────────
|
||||
|
||||
// Staged files waiting to be sent with the next message.
|
||||
// Each entry:
|
||||
// {
|
||||
// localId: string — crypto.randomUUID(), stable key for DOM
|
||||
// serverId: string? — file UUID from backend (null while uploading)
|
||||
// filename: string
|
||||
// contentType: string
|
||||
// sizeBytes: number
|
||||
// previewUrl: string? — object URL for image preview (revoked on remove)
|
||||
// status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error'
|
||||
// uploadProgress: number — 0–100 (only meaningful during 'uploading')
|
||||
// extractionStatus: string? — mirrors backend metadata.extraction_status
|
||||
// queuePosition: number? — extraction queue position (0 = processing)
|
||||
// error: string? — error message if status is 'error' or 'failed'
|
||||
// }
|
||||
|
||||
// App.stagedFiles is initialized in app.js.
|
||||
// Polling timers are tracked here for cleanup.
|
||||
const _pollTimers = new Map(); // localId → intervalId
|
||||
|
||||
|
||||
// ── Upload ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stage a file for upload. Creates the staged entry, uploads to backend,
|
||||
* and starts extraction polling.
|
||||
*
|
||||
* If no current chat exists, creates one first (same pattern as sendMessage).
|
||||
*
|
||||
* @param {File} file — File object from input, paste, or drop
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function stageFile(file) {
|
||||
if (!App.storageConfigured) return;
|
||||
|
||||
// Enforce per-message limit
|
||||
if (App.stagedFiles.length >= FILE_MAX_PER_MSG) {
|
||||
UI.toast(`Maximum ${FILE_MAX_PER_MSG} files per message`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build staged entry
|
||||
const staged = {
|
||||
localId: crypto.randomUUID(),
|
||||
serverId: null,
|
||||
filename: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
||||
status: 'uploading',
|
||||
uploadProgress: 0,
|
||||
extractionStatus: null,
|
||||
queuePosition: null,
|
||||
error: null,
|
||||
};
|
||||
App.stagedFiles.push(staged);
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
|
||||
// Ensure a channel exists (files are channel-scoped)
|
||||
if (!App.activeId) {
|
||||
try {
|
||||
const title = file.name.slice(0, 50);
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||
const chat = {
|
||||
id: resp.id, title: resp.title, type: 'direct',
|
||||
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
|
||||
};
|
||||
App.chats.unshift(chat);
|
||||
App.setActive(chat.id, 'direct');
|
||||
UI.renderChatList();
|
||||
} catch (e) {
|
||||
staged.status = 'error';
|
||||
staged.error = 'Failed to create chat: ' + e.message;
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload
|
||||
try {
|
||||
const result = await API.uploadFile(App.activeId, file);
|
||||
staged.serverId = result.id;
|
||||
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
|
||||
staged.extractionStatus = result.metadata?.extraction_status || null;
|
||||
staged.queuePosition = result.metadata?.queue_position ?? null;
|
||||
|
||||
// Start polling if extraction is pending/processing
|
||||
if (staged.status === 'queued' || staged.status === 'processing') {
|
||||
_startPolling(staged);
|
||||
}
|
||||
} catch (e) {
|
||||
staged.status = 'error';
|
||||
staged.error = e.message;
|
||||
console.error('File upload failed:', e);
|
||||
}
|
||||
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a staged file by localId.
|
||||
* Revokes object URL, stops polling, and optionally deletes from backend.
|
||||
*/
|
||||
async function unstageFile(localId) {
|
||||
const idx = App.stagedFiles.findIndex(a => a.localId === localId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const staged = App.stagedFiles[idx];
|
||||
|
||||
// Clean up preview blob
|
||||
if (staged.previewUrl) {
|
||||
URL.revokeObjectURL(staged.previewUrl);
|
||||
}
|
||||
|
||||
// Stop polling
|
||||
_stopPolling(localId);
|
||||
|
||||
// Delete from backend if it was uploaded
|
||||
if (staged.serverId) {
|
||||
try {
|
||||
await API.deleteFile(staged.serverId);
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete staged file:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
App.stagedFiles.splice(idx, 1);
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all staged files (chat switch, cancel, etc.)
|
||||
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
|
||||
*/
|
||||
function clearStaged() {
|
||||
for (const staged of App.stagedFiles) {
|
||||
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
|
||||
_stopPolling(staged.localId);
|
||||
}
|
||||
App.stagedFiles = [];
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
|
||||
// ── Send Integration ────────────────────────
|
||||
|
||||
/**
|
||||
* Returns file IDs (server-side UUIDs) for the current staged set.
|
||||
* Only includes successfully uploaded files.
|
||||
*/
|
||||
function getStagedFileIds() {
|
||||
return App.stagedFiles
|
||||
.filter(a => a.serverId)
|
||||
.map(a => a.serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the send button should be blocked due to in-flight uploads.
|
||||
* Per design: blocked ONLY while any file is actively uploading (bytes in flight).
|
||||
* Queued/processing/failed extraction does NOT block send.
|
||||
*/
|
||||
function isSendBlocked() {
|
||||
return App.stagedFiles.some(a => a.status === 'uploading');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there are any staged files (for UI visibility).
|
||||
*/
|
||||
function hasStagedFiles() {
|
||||
return App.stagedFiles.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-send cleanup: detach staged list without deleting from backend
|
||||
* (files are now linked to the sent message).
|
||||
*/
|
||||
function consumeStaged() {
|
||||
for (const staged of App.stagedFiles) {
|
||||
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
|
||||
_stopPolling(staged.localId);
|
||||
}
|
||||
App.stagedFiles = [];
|
||||
_renderStrip();
|
||||
_updateSendBlock();
|
||||
}
|
||||
|
||||
|
||||
// ── Extraction Polling ──────────────────────
|
||||
|
||||
function _startPolling(staged) {
|
||||
if (_pollTimers.has(staged.localId)) return;
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
if (!staged.serverId) return;
|
||||
|
||||
try {
|
||||
const data = await API.getFile(staged.serverId);
|
||||
const meta = data.metadata || {};
|
||||
staged.extractionStatus = meta.extraction_status || null;
|
||||
staged.queuePosition = meta.queue_position ?? null;
|
||||
staged.status = _mapExtractionStatus(meta.extraction_status);
|
||||
|
||||
if (meta.extraction_error) {
|
||||
staged.error = meta.extraction_error;
|
||||
}
|
||||
|
||||
_renderStrip();
|
||||
|
||||
// Stop polling on terminal state
|
||||
if (staged.status === 'complete' || staged.status === 'failed') {
|
||||
_stopPolling(staged.localId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Extraction poll failed for', staged.localId, e.message);
|
||||
// Don't stop polling on transient errors — backend might be restarting
|
||||
}
|
||||
}, FILE_POLL_INTERVAL);
|
||||
|
||||
_pollTimers.set(staged.localId, timer);
|
||||
}
|
||||
|
||||
function _stopPolling(localId) {
|
||||
const timer = _pollTimers.get(localId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
_pollTimers.delete(localId);
|
||||
}
|
||||
}
|
||||
|
||||
function _stopAllPolling() {
|
||||
for (const [id, timer] of _pollTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
_pollTimers.clear();
|
||||
}
|
||||
|
||||
|
||||
// ── Status Helpers ──────────────────────────
|
||||
|
||||
/**
|
||||
* Map backend extraction_status to frontend staged status.
|
||||
* Images have no extraction — they go straight to 'complete'.
|
||||
*/
|
||||
function _mapExtractionStatus(backendStatus) {
|
||||
switch (backendStatus) {
|
||||
case 'pending': return 'queued';
|
||||
case 'processing': return 'processing';
|
||||
case 'complete': return 'complete';
|
||||
case 'failed': return 'failed';
|
||||
case 'unavailable': return 'complete'; // no extraction needed (e.g., images)
|
||||
default: return 'queued';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable status label for a staged file.
|
||||
*/
|
||||
function fileStatusLabel(staged) {
|
||||
switch (staged.status) {
|
||||
case 'uploading':
|
||||
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
|
||||
case 'queued':
|
||||
if (staged.queuePosition != null && staged.queuePosition > 0) {
|
||||
return `Queued (${staged.queuePosition} ahead)`;
|
||||
}
|
||||
return 'Queued';
|
||||
case 'processing': return 'Extracting…';
|
||||
case 'complete': return '✓ Ready';
|
||||
case 'failed': return '⚠ Extraction failed';
|
||||
case 'error': return '✕ ' + (staged.error || 'Upload failed');
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given content type is an image (for vision gating).
|
||||
*/
|
||||
function isImageFile(contentType) {
|
||||
return IMAGE_TYPES.has(contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display.
|
||||
*/
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
|
||||
// ── Strip Rendering ─────────────────────────
|
||||
|
||||
function _renderStrip() {
|
||||
const strip = document.getElementById('fileStrip');
|
||||
if (!strip) return;
|
||||
|
||||
if (App.stagedFiles.length === 0) {
|
||||
strip.style.display = 'none';
|
||||
strip.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
strip.style.display = 'flex';
|
||||
strip.innerHTML = App.stagedFiles.map(a => {
|
||||
const icon = isImageFile(a.contentType) ? '🖼' : '📄';
|
||||
const name = _truncFilename(a.filename, 20);
|
||||
const size = formatFileSize(a.sizeBytes);
|
||||
const label = fileStatusLabel(a);
|
||||
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
|
||||
a.status === 'complete' ? 'att-ready' : 'att-pending';
|
||||
|
||||
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
|
||||
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
|
||||
<span class="att-info">
|
||||
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
|
||||
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
|
||||
</span>
|
||||
<button class="att-remove" data-action="unstageFile" data-args='${JSON.stringify([a.localId])}'" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _truncFilename(name, max) {
|
||||
if (name.length <= max) return name;
|
||||
const ext = name.lastIndexOf('.');
|
||||
if (ext > 0 && name.length - ext <= 6) {
|
||||
// Preserve extension: "very-long-name...pdf"
|
||||
const suffix = name.slice(ext);
|
||||
return name.slice(0, max - suffix.length - 1) + '…' + suffix;
|
||||
}
|
||||
return name.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Channel File Loading ──────────────
|
||||
|
||||
/**
|
||||
* Fetch all files for a channel, build messageId → [att] map.
|
||||
* Called after loading messages in selectChat / reloadActivePath.
|
||||
* Non-blocking: failures just leave the map empty (files are optional UX).
|
||||
*/
|
||||
async function loadChannelFiles(channelId) {
|
||||
App.channelFiles = {};
|
||||
if (!channelId || !App.storageConfigured) return;
|
||||
|
||||
try {
|
||||
const data = await API.listChannelFiles(channelId);
|
||||
const list = data.files || data || [];
|
||||
for (const att of list) {
|
||||
if (!att.message_id) continue;
|
||||
if (!App.channelFiles[att.message_id]) {
|
||||
App.channelFiles[att.message_id] = [];
|
||||
}
|
||||
App.channelFiles[att.message_id].push(att);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('File load skipped:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Message File Rendering ────────────
|
||||
|
||||
/**
|
||||
* Returns HTML string for files associated with a message.
|
||||
* Called from ui-core.js _messageHTML(). Returns '' if none.
|
||||
*
|
||||
* Images use data-att-id; actual src is loaded post-render with auth
|
||||
* via loadAuthImages(). Doc downloads use onclick handlers.
|
||||
*/
|
||||
function renderMessageFiles(msgId) {
|
||||
if (!msgId) return '';
|
||||
const atts = App.channelFiles[msgId];
|
||||
if (!atts || atts.length === 0) return '';
|
||||
|
||||
// Check current model vision capability for hint
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||
|
||||
const items = atts.map(att => {
|
||||
const isImage = isImageFile(att.content_type);
|
||||
|
||||
if (isImage) {
|
||||
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
|
||||
const w = Math.min(dims?.width || 280, 320);
|
||||
const visionHint = !hasVision
|
||||
? '<span class="att-vision-hint" title="Current model does not support images">👁️🗨️</span>'
|
||||
: '';
|
||||
return `<div class="msg-att msg-att-image" data-action="openLightbox" data-args='${JSON.stringify([esc(att.id)])}'">
|
||||
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
|
||||
style="max-width:${w}px" loading="lazy">
|
||||
${visionHint}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Document chip
|
||||
const icon = _docIcon(att.content_type);
|
||||
const size = formatFileSize(att.size_bytes);
|
||||
return `<a class="msg-att msg-att-doc" href="#" data-action="downloadFile" data-args='${JSON.stringify([esc(att.id), esc(att.filename)])}' data-prevent-default">
|
||||
<span class="att-doc-icon">${icon}</span>
|
||||
<span class="att-doc-info">
|
||||
<span class="att-doc-name">${esc(att.filename)}</span>
|
||||
<span class="att-doc-size">${size}</span>
|
||||
</span>
|
||||
<span class="att-doc-dl">⬇</span>
|
||||
</a>`;
|
||||
});
|
||||
|
||||
return `<div class="msg-files">${items.join('')}</div>`;
|
||||
}
|
||||
|
||||
function _docIcon(contentType) {
|
||||
if (contentType === 'application/pdf') return '📕';
|
||||
if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊';
|
||||
if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙';
|
||||
if (contentType?.includes('word') || contentType?.includes('document')) return '📄';
|
||||
if (contentType?.startsWith('text/')) return '📝';
|
||||
return '📎';
|
||||
}
|
||||
|
||||
|
||||
// ── Image Lightbox ──────────────────────────
|
||||
|
||||
function openLightbox(fileId) {
|
||||
const overlay = document.getElementById('lightbox');
|
||||
const img = document.getElementById('lightboxImg');
|
||||
if (!overlay || !img) return;
|
||||
|
||||
img.src = '';
|
||||
img.alt = 'Loading…';
|
||||
overlay.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Fetch full-size image with auth
|
||||
API.downloadFileBlob(fileId)
|
||||
.then(blob => { img.src = URL.createObjectURL(blob); })
|
||||
.catch(() => { img.alt = 'Failed to load image'; });
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
const overlay = document.getElementById('lightbox');
|
||||
const img = document.getElementById('lightboxImg');
|
||||
if (!overlay) return;
|
||||
|
||||
overlay.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
// Revoke previous blob URL to free memory
|
||||
if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
||||
if (img) { img.src = ''; img.alt = ''; }
|
||||
}
|
||||
|
||||
|
||||
// ── Auth Image Loading ──────────────────────
|
||||
|
||||
/**
|
||||
* Post-render pass: finds all <img data-att-id="..."> in the given
|
||||
* container and fetches them with auth headers, setting blob URLs.
|
||||
* Called after renderMessages and reloadActivePath.
|
||||
*/
|
||||
function loadAuthImages(container) {
|
||||
if (!container) container = document.getElementById('chatMessages');
|
||||
if (!container) return;
|
||||
|
||||
const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])');
|
||||
for (const img of imgs) {
|
||||
const attId = img.dataset.attId;
|
||||
if (!attId) continue;
|
||||
img.dataset.loaded = '1';
|
||||
|
||||
API.downloadFileBlob(attId)
|
||||
.then(blob => { img.src = URL.createObjectURL(blob); })
|
||||
.catch(() => {
|
||||
img.alt = '⚠ Load failed';
|
||||
img.classList.add('att-load-error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Document Download ───────────────────────
|
||||
|
||||
/**
|
||||
* Download a file via auth-aware fetch.
|
||||
* Creates a temporary <a> element to trigger the browser download dialog.
|
||||
*/
|
||||
async function downloadFile(fileId, filename) {
|
||||
try {
|
||||
const blob = await API.downloadFileBlob(fileId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
UI.toast('Download failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Admin Storage Panel ─────────────────────
|
||||
|
||||
async function loadAdminStorage() {
|
||||
const el = document.getElementById('adminStorageContent');
|
||||
if (!el) return;
|
||||
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const [status, orphans] = await Promise.all([
|
||||
API.adminGetStorageStatus().catch(() => null),
|
||||
API.adminGetOrphanCount().catch(() => null),
|
||||
]);
|
||||
|
||||
let html = '';
|
||||
|
||||
// Status card
|
||||
if (status) {
|
||||
const healthClass = status.healthy ? 'badge-success' : 'badge-danger';
|
||||
const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy';
|
||||
html += `<div class="admin-storage-card">
|
||||
<h4>Storage Backend</h4>
|
||||
<div class="admin-storage-grid">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Backend</span>
|
||||
<span class="admin-storage-value">${esc(status.backend || 'none')}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Status</span>
|
||||
<span class="badge ${healthClass}">${healthLabel}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Total Files</span>
|
||||
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Total Size</span>
|
||||
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
|
||||
</div>
|
||||
${status.path ? `<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Path</span>
|
||||
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.path)}</span>
|
||||
</div>` : ''}
|
||||
${status.endpoint ? `<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Endpoint</span>
|
||||
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.endpoint)}</span>
|
||||
</div>` : ''}
|
||||
${status.bucket ? `<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Bucket</span>
|
||||
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.bucket)}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="admin-storage-card">
|
||||
<h4>Storage Backend</h4>
|
||||
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Orphan cleanup card
|
||||
if (orphans) {
|
||||
const count = orphans.count ?? 0;
|
||||
const bytes = orphans.total_bytes ?? 0;
|
||||
html += `<div class="admin-storage-card">
|
||||
<h4>Orphan Files</h4>
|
||||
<p class="admin-storage-desc">Files uploaded but never linked to a message for >24 hours.</p>
|
||||
<div class="admin-storage-grid">
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Orphaned</span>
|
||||
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="admin-storage-item">
|
||||
<span class="admin-storage-label">Reclaimable</span>
|
||||
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Wire cleanup button
|
||||
document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => {
|
||||
const btn = e.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Cleaning…';
|
||||
try {
|
||||
const result = await API.adminRunStorageCleanup();
|
||||
UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success');
|
||||
await loadAdminStorage(); // refresh counts
|
||||
} catch (err) {
|
||||
UI.toast('Cleanup failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Run Cleanup Now';
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Constants (Paste) ───────────────────────
|
||||
// Threshold for auto-attaching pasted text as a file.
|
||||
// Synced from backend via PublicSettings (storage.paste_to_file_chars).
|
||||
// Falls back to 2000 if not yet loaded.
|
||||
function _pasteToFileChars() {
|
||||
return App.pasteToFileChars || 2000;
|
||||
}
|
||||
|
||||
// Accept string for file picker — mirrors backend allowedMIMETypes.
|
||||
// Backend is authoritative (rejects disallowed types server-side),
|
||||
// this just gives the OS file dialog a better filter.
|
||||
const FILE_ACCEPT = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
'application/pdf',
|
||||
'text/plain', 'text/markdown', 'text/csv',
|
||||
'.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf',
|
||||
].join(',');
|
||||
|
||||
// ── MIME → Extension ────────────────────────
|
||||
|
||||
const _mimeToExt = {
|
||||
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
|
||||
'image/webp': 'webp', 'image/svg+xml': 'svg',
|
||||
'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md',
|
||||
'text/csv': 'csv', 'text/html': 'html',
|
||||
};
|
||||
|
||||
function _extFromMime(mime) {
|
||||
return _mimeToExt[mime] || mime.split('/').pop() || 'bin';
|
||||
}
|
||||
|
||||
|
||||
// ── Send Button Blocking ────────────────────
|
||||
|
||||
function _updateSendBlock() {
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
if (!sendBtn) return;
|
||||
|
||||
// Merge with existing generation check — don't override if generating
|
||||
if (App.isGenerating) return;
|
||||
|
||||
if (isSendBlocked()) {
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.title = 'Upload in progress…';
|
||||
} else {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.title = 'Send';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Init + Listeners ────────────────────────
|
||||
|
||||
/**
|
||||
* Called from app.js startApp(). Reads storage_configured from boot
|
||||
* payload and sets up file UI visibility.
|
||||
*/
|
||||
function initFiles() {
|
||||
const attachBtn = document.getElementById('attachBtn');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
if (attachBtn) {
|
||||
attachBtn.style.display = App.storageConfigured ? '' : 'none';
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.accept = FILE_ACCEPT;
|
||||
}
|
||||
console.log(`📎 Files: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from app.js initListeners(). Wires up all file input
|
||||
* handlers: button, file picker, paste, and drag-and-drop.
|
||||
*/
|
||||
function _initFileListeners() {
|
||||
const attachBtn = document.getElementById('attachBtn');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const chatArea = document.getElementById('chatMessages');
|
||||
const lightbox = document.getElementById('lightbox');
|
||||
|
||||
// ── 📎 Button → File Picker ─────────────
|
||||
if (attachBtn && fileInput) {
|
||||
attachBtn.addEventListener('click', () => {
|
||||
if (!App.storageConfigured) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = fileInput.files;
|
||||
if (!files?.length) return;
|
||||
for (const file of files) stageFile(file);
|
||||
fileInput.value = ''; // reset so same file can be re-selected
|
||||
});
|
||||
}
|
||||
|
||||
// ── Smart Paste ─────────────────────────
|
||||
// Attach to the active input element (CM6 contentDOM or textarea)
|
||||
const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
|
||||
? ChatInput.getDom()
|
||||
: document.getElementById('messageInput');
|
||||
if (pasteTarget) {
|
||||
pasteTarget.addEventListener('paste', (e) => {
|
||||
if (!App.storageConfigured) return; // fall through to normal paste
|
||||
|
||||
const items = [...(e.clipboardData?.items || [])];
|
||||
|
||||
// Binary detection: images/files from clipboard
|
||||
const binaryItems = items.filter(i => i.kind === 'file');
|
||||
if (binaryItems.length > 0) {
|
||||
e.preventDefault();
|
||||
for (const item of binaryItems) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
// Generate UUID filename — original clipboard items have no useful name
|
||||
const ext = _extFromMime(file.type);
|
||||
const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type });
|
||||
stageFile(renamed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Large text detection — auto-attach as .txt file
|
||||
const _ptfChars = _pasteToFileChars();
|
||||
if (_ptfChars > 0) {
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text && text.length > _ptfChars) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
|
||||
stageFile(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Below threshold or no text: normal paste (no preventDefault)
|
||||
});
|
||||
}
|
||||
|
||||
// ── Drag-and-Drop ───────────────────────
|
||||
if (chatArea) {
|
||||
// Track drag enter/leave depth to avoid flicker from child elements
|
||||
let dragDepth = 0;
|
||||
|
||||
chatArea.addEventListener('dragenter', (e) => {
|
||||
if (!App.storageConfigured) return;
|
||||
if (!_hasDragFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth++;
|
||||
if (dragDepth === 1) chatArea.classList.add('drag-over');
|
||||
});
|
||||
|
||||
chatArea.addEventListener('dragover', (e) => {
|
||||
if (!App.storageConfigured) return;
|
||||
if (!_hasDragFiles(e)) return;
|
||||
e.preventDefault(); // required to allow drop
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
chatArea.addEventListener('dragleave', (e) => {
|
||||
if (!App.storageConfigured) return;
|
||||
dragDepth--;
|
||||
if (dragDepth <= 0) {
|
||||
dragDepth = 0;
|
||||
chatArea.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
chatArea.addEventListener('drop', (e) => {
|
||||
if (!App.storageConfigured) return;
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
chatArea.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files?.length) return;
|
||||
for (const file of files) stageFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lightbox ────────────────────────────
|
||||
if (lightbox) {
|
||||
// Click overlay (not the image) to close
|
||||
lightbox.addEventListener('click', (e) => {
|
||||
if (e.target === lightbox || e.target.classList.contains('lightbox-close')) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a drag event contains files (not just text selection drag).
|
||||
*/
|
||||
function _hasDragFiles(e) {
|
||||
if (!e.dataTransfer?.types) return false;
|
||||
return e.dataTransfer.types.includes('Files');
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.register('_initFileListeners', _initFileListeners);
|
||||
sb.register('_pollTimers', _pollTimers);
|
||||
sb.register('_startPolling', _startPolling);
|
||||
sb.register('_stopPolling', _stopPolling);
|
||||
sb.register('clearStaged', clearStaged);
|
||||
sb.register('closeLightbox', closeLightbox);
|
||||
sb.register('consumeStaged', consumeStaged);
|
||||
sb.register('getStagedFileIds', getStagedFileIds);
|
||||
sb.register('hasStagedFiles', hasStagedFiles);
|
||||
sb.register('initFiles', initFiles);
|
||||
sb.register('isSendBlocked', isSendBlocked);
|
||||
sb.register('loadAdminStorage', loadAdminStorage);
|
||||
sb.register('loadAuthImages', loadAuthImages);
|
||||
sb.register('loadChannelFiles', loadChannelFiles);
|
||||
sb.register('renderMessageFiles', renderMessageFiles);
|
||||
sb.register('unstageFile', unstageFile);
|
||||
sb.register('openLightbox', openLightbox);
|
||||
sb.register('downloadFile', downloadFile);
|
||||
@@ -1,556 +0,0 @@
|
||||
// ── knowledge-ui.js ─────────────────────────
|
||||
// Knowledge base management UI.
|
||||
//
|
||||
// Three contexts:
|
||||
// 1. User Settings → personal KBs (scope: personal)
|
||||
// 2. Admin Panel → global KBs (scope: global)
|
||||
// 3. Team Admin → team-scoped KBs (scope: team)
|
||||
//
|
||||
// Plus the channel KB toggle popup in the chat input bar.
|
||||
//
|
||||
// Depends on: API (api.js), App (app.js)
|
||||
|
||||
|
||||
const KnowledgeUI = (() => {
|
||||
console.debug('[KnowledgeUI] module loaded');
|
||||
let _allKBs = []; // [{id, name, scope, document_count, ...}]
|
||||
let _channelKBs = []; // [{kb_id, kb_name, enabled, document_count}]
|
||||
let _pollTimers = {}; // docId → timer
|
||||
let _currentKBId = null; // for document panel
|
||||
let _panelCtx = null; // { container, scope, teamId } — current panel context
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Channel KB Toggle Popup (chat input bar)
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
async function openChannelPopup() {
|
||||
const popup = document.getElementById('kbPopup');
|
||||
if (!popup) return;
|
||||
|
||||
if (!App.activeId) {
|
||||
popup.innerHTML = '<div class="empty-hint">Start a conversation first</div>';
|
||||
popup.classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
popup.innerHTML = '<div class="loading">Loading…</div>';
|
||||
popup.classList.add('open');
|
||||
|
||||
try {
|
||||
const [allResp, chResp] = await Promise.all([
|
||||
API.listKnowledgeBases(),
|
||||
API.getChannelKBs(App.activeId),
|
||||
]);
|
||||
_allKBs = allResp.data || [];
|
||||
_channelKBs = chResp.data || [];
|
||||
_renderChannelPopup(popup);
|
||||
} catch (e) {
|
||||
popup.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
|
||||
console.warn('KB popup load failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderChannelPopup(popup) {
|
||||
if (_allKBs.length === 0) {
|
||||
popup.innerHTML = `
|
||||
<div class="empty-hint">
|
||||
No knowledge bases yet.
|
||||
<br><small>Create one in Settings → Knowledge Bases</small>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
|
||||
|
||||
let html = '<div class="kb-popup-header">Knowledge Bases</div>';
|
||||
for (const kb of _allKBs) {
|
||||
const linked = linkedSet.has(kb.id);
|
||||
const scope = kb.scope === 'personal' ? '' : ` · ${kb.scope}`;
|
||||
const docs = kb.document_count === 1 ? '1 doc' : `${kb.document_count} docs`;
|
||||
const status = kb.chunk_count === 0 ? ' · no content' : '';
|
||||
html += `
|
||||
<div class="kb-popup-item ${linked ? 'enabled' : ''}" data-kb-id="${kb.id}">
|
||||
<span class="kb-popup-check"></span>
|
||||
<span class="kb-popup-label">
|
||||
<span class="kb-popup-name">${esc(kb.name)}</span>
|
||||
<span class="kb-popup-meta">${docs}${scope}${status}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
popup.innerHTML = html;
|
||||
}
|
||||
|
||||
async function _toggleChannelKB(kbId) {
|
||||
if (!App.activeId) return;
|
||||
|
||||
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
|
||||
if (linkedSet.has(kbId)) {
|
||||
linkedSet.delete(kbId);
|
||||
} else {
|
||||
linkedSet.add(kbId);
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await API.setChannelKBs(App.activeId, [...linkedSet]);
|
||||
_channelKBs = resp.data || [];
|
||||
const popup = document.getElementById('kbPopup');
|
||||
if (popup) _renderChannelPopup(popup);
|
||||
_updateKBBtnState();
|
||||
} catch (e) {
|
||||
console.warn('Failed to update channel KBs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _updateKBBtnState() {
|
||||
const btn = document.getElementById('kbToggleBtn');
|
||||
if (!btn) return;
|
||||
const count = _channelKBs.filter(kb => kb.enabled !== false).length;
|
||||
btn.classList.toggle('has-active', count > 0);
|
||||
btn.title = count > 0 ? `Knowledge Bases (${count} active)` : 'Knowledge Bases';
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Shared KB Management Panel
|
||||
// ═════════════════════════════════════════
|
||||
// Rendered into any container with { scope, teamId } context.
|
||||
|
||||
/**
|
||||
* Open KB management panel.
|
||||
* @param {string} containerId — DOM id of the container to render into
|
||||
* @param {string} scope — 'personal', 'global', or 'team'
|
||||
* @param {string} [teamId] — required when scope='team'
|
||||
*/
|
||||
async function openPanel(containerId, scope, teamId) {
|
||||
console.debug(`[KB] openPanel: container=${containerId}, scope=${scope}, teamId=${teamId}`);
|
||||
const panel = document.getElementById(containerId);
|
||||
if (!panel) {
|
||||
console.warn(`[KB] openPanel: container '${containerId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
_panelCtx = { container: containerId, scope, teamId };
|
||||
panel.innerHTML = '<div class="loading">Loading…</div>';
|
||||
panel.style.display = '';
|
||||
|
||||
try {
|
||||
const resp = await API.listKnowledgeBases();
|
||||
// Filter by scope
|
||||
const all = resp.data || [];
|
||||
_allKBs = all.filter(kb => {
|
||||
if (scope === 'personal') return kb.scope === 'personal';
|
||||
if (scope === 'global') return kb.scope === 'global';
|
||||
if (scope === 'team') return kb.scope === 'team' && kb.team_id === teamId;
|
||||
return true;
|
||||
});
|
||||
_renderManagePanel(panel);
|
||||
} catch (e) {
|
||||
console.warn('[KB] openPanel error:', e);
|
||||
panel.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience wrappers
|
||||
function openManagePanel() { openPanel('kbManagePanel', 'personal'); }
|
||||
function openAdminPanel() { openPanel('adminKBContent', 'global'); }
|
||||
function openTeamPanel(teamId) { openPanel('teamKBContent', 'team', teamId); }
|
||||
|
||||
function _renderManagePanel(panel) {
|
||||
const ctx = _panelCtx || { scope: 'personal' };
|
||||
const scopeLabel = ctx.scope === 'global' ? 'Global' : ctx.scope === 'team' ? 'Team' : 'Personal';
|
||||
|
||||
let html = `
|
||||
<div class="kb-manage-header">
|
||||
<h3>${scopeLabel} Knowledge Bases</h3>
|
||||
<button class="btn-small btn-primary" id="kbCreateBtn">+ New</button>
|
||||
</div>`;
|
||||
|
||||
if (_allKBs.length === 0) {
|
||||
html += '<div class="empty-hint">No knowledge bases yet. Create one to get started.</div>';
|
||||
} else {
|
||||
// Summary stats
|
||||
const totalDocs = _allKBs.reduce((s, kb) => s + (kb.document_count || 0), 0);
|
||||
const totalChunks = _allKBs.reduce((s, kb) => s + (kb.chunk_count || 0), 0);
|
||||
const totalBytes = _allKBs.reduce((s, kb) => s + (kb.total_bytes || 0), 0);
|
||||
html += `<div class="kb-manage-summary">${_allKBs.length} KB${_allKBs.length !== 1 ? 's' : ''} · ${totalDocs} documents · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
|
||||
|
||||
html += '<div class="kb-manage-list">';
|
||||
for (const kb of _allKBs) {
|
||||
const scope = kb.scope === 'personal' ? '🔒' : kb.scope === 'team' ? '👥' : '🌐';
|
||||
const status = kb.status === 'processing' ? ' · ⏳ processing' : '';
|
||||
const docs = kb.document_count === 1 ? '1 document' : `${kb.document_count} documents`;
|
||||
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
|
||||
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
|
||||
html += `
|
||||
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
|
||||
<div class="kb-manage-info">
|
||||
<span class="kb-manage-name">${scope} ${esc(kb.name)}</span>
|
||||
<span class="kb-manage-desc">${esc(kb.description || '')}</span>
|
||||
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
|
||||
</div>
|
||||
<div class="kb-manage-actions">
|
||||
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${esc(kb.name)}" title="Access">🔒</button>` : ''}
|
||||
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
|
||||
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
panel.innerHTML = html;
|
||||
|
||||
// Wire create button
|
||||
document.getElementById('kbCreateBtn')?.addEventListener('click', () => {
|
||||
_showCreateDialog(ctx.scope, ctx.teamId);
|
||||
});
|
||||
|
||||
// Wire item actions
|
||||
panel.querySelectorAll('[data-kb-docs]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _openDocumentsPanel(btn.dataset.kbDocs));
|
||||
});
|
||||
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel));
|
||||
});
|
||||
// Wire grant access buttons (admin only, non-personal KBs)
|
||||
panel.querySelectorAll('[data-kb-grant]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (typeof UI !== 'undefined' && UI.openGrantPicker) {
|
||||
UI.openGrantPicker('knowledge_base', btn.dataset.kbGrant, btn.dataset.kbName, `[data-grant-kb="${btn.dataset.kbGrant}"]`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Create KB Dialog ─────────────────────
|
||||
|
||||
function _showCreateDialog(scope, teamId) {
|
||||
const existing = document.getElementById('kbCreateDialog');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const scopeLabel = scope === 'global' ? 'Global' : scope === 'team' ? 'Team' : 'Personal';
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.id = 'kbCreateDialog';
|
||||
dialog.className = 'modal-overlay active';
|
||||
dialog.innerHTML = `
|
||||
<div class="modal" style="max-width: 420px">
|
||||
<div class="modal-header">
|
||||
<h2>New ${scopeLabel} Knowledge Base</h2>
|
||||
<button class="modal-close" id="kbCreateClose">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Name</label>
|
||||
<input type="text" id="kbCreateName" placeholder="e.g. Project Docs" maxlength="200"
|
||||
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Description (optional)</label>
|
||||
<textarea id="kbCreateDesc" rows="2" maxlength="2000" placeholder="What documents will this contain?"
|
||||
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px;resize:vertical"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-small" id="kbCreateCancel">Cancel</button>
|
||||
<button class="btn-small btn-primary" id="kbCreateSave">Create</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const close = () => dialog.remove();
|
||||
document.getElementById('kbCreateClose').addEventListener('click', close);
|
||||
document.getElementById('kbCreateCancel').addEventListener('click', close);
|
||||
document.getElementById('kbCreateSave').addEventListener('click', async () => {
|
||||
const name = document.getElementById('kbCreateName').value.trim();
|
||||
if (!name) {
|
||||
document.getElementById('kbCreateName').focus();
|
||||
return;
|
||||
}
|
||||
const desc = document.getElementById('kbCreateDesc').value.trim();
|
||||
try {
|
||||
await API.createKnowledgeBase(name, desc, scope, teamId);
|
||||
close();
|
||||
_refreshCurrentPanel();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to create: ' + (e.message || e), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key submits
|
||||
document.getElementById('kbCreateName').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') document.getElementById('kbCreateSave').click();
|
||||
});
|
||||
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) close();
|
||||
});
|
||||
|
||||
// Focus name field after render
|
||||
requestAnimationFrame(() => document.getElementById('kbCreateName')?.focus());
|
||||
}
|
||||
|
||||
async function _deleteKB(kbId) {
|
||||
const kb = _allKBs.find(k => k.id === kbId);
|
||||
if (!await showConfirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
|
||||
try {
|
||||
await API.deleteKnowledgeBase(kbId);
|
||||
_refreshCurrentPanel();
|
||||
} catch (e) {
|
||||
UI.toast('Delete failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh whatever panel is currently active. */
|
||||
function _refreshCurrentPanel() {
|
||||
if (!_panelCtx) return;
|
||||
openPanel(_panelCtx.container, _panelCtx.scope, _panelCtx.teamId);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Documents Panel
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
async function _openDocumentsPanel(kbId) {
|
||||
// Use the same alias for backward compat
|
||||
return openDocumentsPanel(kbId);
|
||||
}
|
||||
|
||||
async function openDocumentsPanel(kbId) {
|
||||
_currentKBId = kbId;
|
||||
const containerId = _panelCtx?.container || 'kbManagePanel';
|
||||
const panel = document.getElementById(containerId);
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = '<div class="loading">Loading documents…</div>';
|
||||
|
||||
try {
|
||||
const [kbResp, docsResp] = await Promise.all([
|
||||
API.getKnowledgeBase(kbId),
|
||||
API.listKBDocuments(kbId),
|
||||
]);
|
||||
const kb = kbResp;
|
||||
const docs = docsResp.data || docsResp || [];
|
||||
_renderDocumentsPanel(panel, kb, Array.isArray(docs) ? docs : []);
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="empty-hint">Failed to load documents: ${esc(e.message || '')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderDocumentsPanel(panel, kb, docs) {
|
||||
let html = `
|
||||
<div class="kb-manage-header">
|
||||
<button class="btn-small" id="kbDocsBack">← Back</button>
|
||||
<h3>${esc(kb.name)}</h3>
|
||||
<label class="btn-small btn-primary" id="kbUploadLabel" title="Upload document">
|
||||
+ Upload
|
||||
<input type="file" id="kbFileInput" accept=".txt,.md,.csv,.html,.htm" style="display:none" multiple>
|
||||
</label>
|
||||
</div>`;
|
||||
|
||||
if (kb.description) {
|
||||
html += `<div class="kb-manage-desc-block">${esc(kb.description)}</div>`;
|
||||
}
|
||||
|
||||
// Stats line
|
||||
const totalBytes = docs.reduce((s, d) => s + (d.size_bytes || 0), 0);
|
||||
const totalChunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
|
||||
if (docs.length > 0) {
|
||||
html += `<div class="kb-manage-summary">${docs.length} file${docs.length !== 1 ? 's' : ''} · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
html += '<div class="empty-hint">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
|
||||
} else {
|
||||
html += '<div class="kb-manage-list">';
|
||||
for (const doc of docs) {
|
||||
const statusIcon = _docStatusIcon(doc.status);
|
||||
const size = _formatSize(doc.size_bytes);
|
||||
const chunks = doc.chunk_count > 0 ? ` · ${doc.chunk_count} chunks` : '';
|
||||
const errText = doc.error ? ` · ⚠ ${esc(doc.error)}` : '';
|
||||
html += `
|
||||
<div class="kb-manage-item kb-doc-item" data-doc-id="${doc.id}" data-doc-status="${doc.status}">
|
||||
<div class="kb-manage-info">
|
||||
<span class="kb-manage-name">${statusIcon} ${esc(doc.filename)}</span>
|
||||
<span class="kb-manage-stats">${size}${chunks}${errText}</span>
|
||||
</div>
|
||||
<div class="kb-manage-actions">
|
||||
<button class="btn-small btn-danger" data-doc-del="${doc.id}" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
panel.innerHTML = html;
|
||||
|
||||
// Wire back button
|
||||
document.getElementById('kbDocsBack')?.addEventListener('click', () => _refreshCurrentPanel());
|
||||
|
||||
// Wire upload
|
||||
document.getElementById('kbFileInput')?.addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files.length) return;
|
||||
for (const file of files) {
|
||||
await _uploadDocument(kb.id, file);
|
||||
}
|
||||
e.target.value = '';
|
||||
openDocumentsPanel(kb.id);
|
||||
});
|
||||
|
||||
// Wire delete buttons
|
||||
panel.querySelectorAll('[data-doc-del]').forEach(btn => {
|
||||
btn.addEventListener('click', () => _deleteDocument(kb.id, btn.dataset.docDel));
|
||||
});
|
||||
|
||||
// Start polling for in-progress documents
|
||||
for (const doc of docs) {
|
||||
if (['pending', 'extracting', 'chunking', 'embedding'].includes(doc.status)) {
|
||||
_startPolling(kb.id, doc.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _uploadDocument(kbId, file) {
|
||||
try {
|
||||
const resp = await API.uploadKBDocument(kbId, file);
|
||||
console.log(`📄 Uploaded ${file.name}, status: ${resp.status}`);
|
||||
if (resp.id) _startPolling(kbId, resp.id);
|
||||
} catch (e) {
|
||||
UI.toast(`Upload failed for ${file.name}: ${e.message || e}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteDocument(kbId, docId) {
|
||||
if (!await showConfirm('Delete this document and all its chunks?')) return;
|
||||
try {
|
||||
_stopPolling(docId);
|
||||
await API.deleteKBDocument(kbId, docId);
|
||||
openDocumentsPanel(kbId);
|
||||
} catch (e) {
|
||||
UI.toast('Delete failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Status Polling
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
function _startPolling(kbId, docId) {
|
||||
if (_pollTimers[docId]) return;
|
||||
_pollTimers[docId] = setInterval(async () => {
|
||||
try {
|
||||
const status = await API.getKBDocumentStatus(kbId, docId);
|
||||
_updateDocStatus(docId, status);
|
||||
if (status.status === 'ready' || status.status === 'error') {
|
||||
_stopPolling(docId);
|
||||
if (_currentKBId === kbId) openDocumentsPanel(kbId);
|
||||
}
|
||||
} catch {
|
||||
_stopPolling(docId);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function _stopPolling(docId) {
|
||||
if (_pollTimers[docId]) {
|
||||
clearInterval(_pollTimers[docId]);
|
||||
delete _pollTimers[docId];
|
||||
}
|
||||
}
|
||||
|
||||
function _updateDocStatus(docId, status) {
|
||||
const item = document.querySelector(`[data-doc-id="${docId}"]`);
|
||||
if (!item) return;
|
||||
item.dataset.docStatus = status.status;
|
||||
const nameEl = item.querySelector('.kb-manage-name');
|
||||
if (nameEl) {
|
||||
const icon = _docStatusIcon(status.status);
|
||||
const filename = nameEl.textContent.replace(/^[^\s]+ /, '');
|
||||
nameEl.innerHTML = `${icon} ${esc(status.filename || filename)}`;
|
||||
}
|
||||
const statsEl = item.querySelector('.kb-manage-stats');
|
||||
if (statsEl && status.chunk_count > 0) {
|
||||
statsEl.textContent = `${status.chunk_count} chunks`;
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Event Wiring
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
function init() {
|
||||
// KB toggle button in chat input bar
|
||||
const btn = document.getElementById('kbToggleBtn');
|
||||
const popup = document.getElementById('kbPopup');
|
||||
if (btn && popup) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (popup.classList.contains('open')) {
|
||||
popup.classList.remove('open');
|
||||
} else {
|
||||
openChannelPopup();
|
||||
}
|
||||
});
|
||||
|
||||
popup.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const item = e.target.closest('.kb-popup-item');
|
||||
if (item) _toggleChannelKB(item.dataset.kbId);
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => popup.classList.remove('open'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh channel KB state when switching chats. */
|
||||
async function onChatChanged() {
|
||||
_channelKBs = [];
|
||||
if (App.activeId) {
|
||||
try {
|
||||
const resp = await API.getChannelKBs(App.activeId);
|
||||
_channelKBs = resp.data || [];
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
_updateKBBtnState();
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
function _docStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'ready': return '✅';
|
||||
case 'error': return '❌';
|
||||
case 'pending': return '⏳';
|
||||
case 'extracting': return '📖';
|
||||
case 'chunking': return '✂️';
|
||||
case 'embedding': return '🧮';
|
||||
default: return '📄';
|
||||
}
|
||||
}
|
||||
|
||||
function _formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
|
||||
// ═════════════════════════════════════════
|
||||
// Public API
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
return {
|
||||
init,
|
||||
onChatChanged,
|
||||
openManagePanel, // User Settings → personal
|
||||
openAdminPanel, // Admin Panel → global
|
||||
openTeamPanel, // Team Admin → team-scoped
|
||||
openDocumentsPanel,
|
||||
openChannelPopup,
|
||||
};
|
||||
})();
|
||||
|
||||
sb.ns('KnowledgeUI', KnowledgeUI);
|
||||
@@ -1,431 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Notifications (v0.20.0)
|
||||
// ==========================================
|
||||
// In-app notification bell with dropdown and
|
||||
// side panel. Real-time push via WebSocket.
|
||||
//
|
||||
// Dependencies: API, Events, PanelRegistry, UI
|
||||
// ==========================================
|
||||
//
|
||||
// Exports: window.Notifications
|
||||
|
||||
|
||||
const Notifications = {
|
||||
|
||||
_items: [], // cached notifications (latest first)
|
||||
_unreadCount: 0,
|
||||
_loaded: false, // full list fetched at least once
|
||||
_dropdownOpen: false,
|
||||
|
||||
// ── Init ─────────────────────────────────
|
||||
|
||||
async init() {
|
||||
this._bindEvents();
|
||||
this._registerPanel();
|
||||
await this._fetchUnreadCount();
|
||||
},
|
||||
|
||||
// ── Data ─────────────────────────────────
|
||||
|
||||
async _fetchUnreadCount() {
|
||||
try {
|
||||
const data = await API._get('/api/v1/notifications/unread-count');
|
||||
this._unreadCount = data.count || 0;
|
||||
this._renderBadge();
|
||||
} catch (e) {
|
||||
// Silently fail — badge just stays at 0
|
||||
}
|
||||
},
|
||||
|
||||
async _fetchList(opts = {}) {
|
||||
const limit = opts.limit || 20;
|
||||
const offset = opts.offset || 0;
|
||||
const unreadOnly = opts.unreadOnly || false;
|
||||
try {
|
||||
const data = await API._get(
|
||||
`/api/v1/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
return { data: [], total: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
async _loadLatest() {
|
||||
const result = await this._fetchList({ limit: 10 });
|
||||
this._items = result.data || [];
|
||||
this._loaded = true;
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── Badge ────────────────────────────────
|
||||
|
||||
_renderBadge() {
|
||||
const badge = document.getElementById('notifBadge');
|
||||
if (!badge) return;
|
||||
if (this._unreadCount > 0) {
|
||||
badge.textContent = this._unreadCount > 9 ? '9+' : String(this._unreadCount);
|
||||
badge.style.display = '';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
// ── Dropdown ─────────────────────────────
|
||||
|
||||
async toggleDropdown() {
|
||||
if (this._dropdownOpen) {
|
||||
this._closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy-load on first open
|
||||
if (!this._loaded) {
|
||||
await this._loadLatest();
|
||||
}
|
||||
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (!dd) return;
|
||||
|
||||
dd.innerHTML = this._renderDropdownContent();
|
||||
dd.style.display = 'block';
|
||||
this._dropdownOpen = true;
|
||||
|
||||
// Click-outside handler
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this._outsideClickHandler);
|
||||
}, 0);
|
||||
},
|
||||
|
||||
_closeDropdown() {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (dd) dd.style.display = 'none';
|
||||
this._dropdownOpen = false;
|
||||
document.removeEventListener('click', this._outsideClickHandler);
|
||||
},
|
||||
|
||||
_outsideClickHandler(e) {
|
||||
const wrap = document.getElementById('notifWrap');
|
||||
if (wrap && !wrap.contains(e.target)) {
|
||||
Notifications._closeDropdown();
|
||||
}
|
||||
},
|
||||
|
||||
_renderDropdownContent() {
|
||||
const items = this._items;
|
||||
if (!items.length) {
|
||||
return `<div class="notif-empty">No notifications</div>`;
|
||||
}
|
||||
|
||||
let html = `<div class="notif-dd-header">
|
||||
<span>Notifications</span>
|
||||
<button class="notif-mark-all" data-action="Notifications.markAllRead">Mark all ✓</button>
|
||||
</div><div class="notif-dd-list">`;
|
||||
|
||||
for (const n of items.slice(0, 10)) {
|
||||
const unread = !n.is_read;
|
||||
const dot = unread ? '●' : '○';
|
||||
const cls = unread ? 'notif-item notif-unread' : 'notif-item';
|
||||
const ago = _timeAgo(n.created_at);
|
||||
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onItemClick" data-pass-el>
|
||||
<span class="notif-dot">${dot}</span>
|
||||
<div class="notif-body">
|
||||
<div class="notif-title">${esc(n.title)}</div>
|
||||
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
|
||||
<div class="notif-time">${ago}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
html += `<div class="notif-dd-footer">
|
||||
<button class="notif-view-all" data-action="Notifications.openPanel">View all →</button>
|
||||
</div>`;
|
||||
|
||||
return html;
|
||||
},
|
||||
|
||||
// ── Item Actions ─────────────────────────
|
||||
|
||||
async _onItemClick(el) {
|
||||
const id = el.dataset.id;
|
||||
const resType = el.dataset.resourceType;
|
||||
const resId = el.dataset.resourceId;
|
||||
|
||||
// Mark read
|
||||
if (el.classList.contains('notif-unread')) {
|
||||
await this._markRead(id);
|
||||
el.classList.remove('notif-unread');
|
||||
el.querySelector('.notif-dot').textContent = '○';
|
||||
}
|
||||
|
||||
// Navigate
|
||||
this._navigate(resType, resId);
|
||||
this._closeDropdown();
|
||||
},
|
||||
|
||||
_navigate(resourceType, resourceId) {
|
||||
if (!resourceType || !resourceId) return;
|
||||
switch (resourceType) {
|
||||
case 'channel':
|
||||
if (typeof selectChat === 'function') selectChat(resourceId);
|
||||
break;
|
||||
case 'knowledge_base':
|
||||
// Open admin panel → KB section (if admin)
|
||||
if (API.isAdmin && typeof showAdmin === 'function') {
|
||||
showAdmin('ai', 'knowledge-bases');
|
||||
}
|
||||
break;
|
||||
case 'project':
|
||||
// Open project detail panel
|
||||
if (typeof openProjectPanel === 'function') openProjectPanel(resourceId);
|
||||
break;
|
||||
case 'group':
|
||||
if (API.isAdmin && typeof showAdmin === 'function') {
|
||||
showAdmin('people', 'groups');
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async _markRead(id) {
|
||||
try {
|
||||
await API._authed(`/api/v1/notifications/${id}/read`, 'PATCH');
|
||||
this._unreadCount = Math.max(0, this._unreadCount - 1);
|
||||
const item = this._items.find(n => n.id === id);
|
||||
if (item) item.is_read = true;
|
||||
this._renderBadge();
|
||||
} catch (e) { /* best effort */ }
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await API._post('/api/v1/notifications/mark-all-read');
|
||||
this._unreadCount = 0;
|
||||
this._items.forEach(n => n.is_read = true);
|
||||
this._renderBadge();
|
||||
// Re-render dropdown if open
|
||||
if (this._dropdownOpen) {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (dd) dd.innerHTML = this._renderDropdownContent();
|
||||
}
|
||||
// Re-render panel if open
|
||||
this._renderPanelContent();
|
||||
} catch (e) { /* best effort */ }
|
||||
},
|
||||
|
||||
// ── Side Panel ───────────────────────────
|
||||
|
||||
_registerPanel() {
|
||||
const body = document.getElementById('sidePanelBody');
|
||||
if (!body || typeof PanelRegistry === 'undefined') return;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'side-panel-page';
|
||||
el.id = 'sidePanelNotifications';
|
||||
el.style.display = 'none';
|
||||
el.innerHTML = `<div class="notif-panel" id="notifPanelInner">
|
||||
<div class="notif-panel-toolbar">
|
||||
<button class="btn-small" data-action="Notifications.markAllRead">Mark all read</button>
|
||||
<button class="btn-small btn-danger" data-action="Notifications.clearAll">Clear all</button>
|
||||
</div>
|
||||
<div class="notif-panel-list" id="notifPanelList"></div>
|
||||
<div class="notif-panel-more" id="notifPanelMore" style="display:none">
|
||||
<button class="btn-small" data-action="Notifications.loadMore">Load more</button>
|
||||
</div>
|
||||
</div>`;
|
||||
body.appendChild(el);
|
||||
|
||||
PanelRegistry.register('notifications', {
|
||||
element: el,
|
||||
label: 'Notifications',
|
||||
onOpen: () => this._onPanelOpen(),
|
||||
saveState: () => ({ scrollTop: el.querySelector('.notif-panel-list')?.scrollTop || 0 }),
|
||||
restoreState: (s) => {
|
||||
const list = el.querySelector('.notif-panel-list');
|
||||
if (list && s.scrollTop) list.scrollTop = s.scrollTop;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
_panelOffset: 0,
|
||||
_panelTotal: 0,
|
||||
|
||||
async _onPanelOpen() {
|
||||
this._panelOffset = 0;
|
||||
const result = await this._fetchList({ limit: 30 });
|
||||
this._panelTotal = result.total || 0;
|
||||
this._panelOffset = (result.data || []).length;
|
||||
this._panelItems = result.data || [];
|
||||
this._renderPanelContent();
|
||||
},
|
||||
|
||||
_panelItems: [],
|
||||
|
||||
_renderPanelContent() {
|
||||
const list = document.getElementById('notifPanelList');
|
||||
if (!list) return;
|
||||
|
||||
if (!this._panelItems.length) {
|
||||
list.innerHTML = '<div class="notif-empty">No notifications yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const n of this._panelItems) {
|
||||
const unread = !n.is_read;
|
||||
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
|
||||
const dot = unread ? '●' : '○';
|
||||
const ago = _timeAgo(n.created_at);
|
||||
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onPanelItemClick" data-pass-el>
|
||||
<div class="notif-panel-row">
|
||||
<span class="notif-dot">${dot}</span>
|
||||
<div class="notif-body">
|
||||
<div class="notif-title">${esc(n.title)}</div>
|
||||
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
|
||||
<div class="notif-time">${ago}</div>
|
||||
</div>
|
||||
<button class="notif-delete" data-action="Notifications._deleteItem" data-args='${JSON.stringify([n.id])}'" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
list.innerHTML = html;
|
||||
|
||||
// Show/hide load more
|
||||
const moreBtn = document.getElementById('notifPanelMore');
|
||||
if (moreBtn) {
|
||||
moreBtn.style.display = this._panelOffset < this._panelTotal ? '' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
async _onPanelItemClick(el) {
|
||||
const id = el.dataset.id;
|
||||
const resType = el.dataset.resourceType;
|
||||
const resId = el.dataset.resourceId;
|
||||
|
||||
if (el.classList.contains('notif-unread')) {
|
||||
await this._markRead(id);
|
||||
el.classList.remove('notif-unread');
|
||||
el.querySelector('.notif-dot').textContent = '○';
|
||||
const pItem = this._panelItems.find(n => n.id === id);
|
||||
if (pItem) pItem.is_read = true;
|
||||
}
|
||||
|
||||
this._navigate(resType, resId);
|
||||
},
|
||||
|
||||
async _deleteItem(id) {
|
||||
try {
|
||||
await API._del(`/api/v1/notifications/${id}`);
|
||||
// Check if it was unread before removal
|
||||
const item = this._panelItems.find(n => n.id === id);
|
||||
if (item && !item.is_read) {
|
||||
this._unreadCount = Math.max(0, this._unreadCount - 1);
|
||||
this._renderBadge();
|
||||
}
|
||||
this._panelItems = this._panelItems.filter(n => n.id !== id);
|
||||
this._items = this._items.filter(n => n.id !== id);
|
||||
this._panelTotal = Math.max(0, this._panelTotal - 1);
|
||||
this._renderPanelContent();
|
||||
} catch (e) { /* best effort */ }
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
const result = await this._fetchList({ limit: 30, offset: this._panelOffset });
|
||||
const newItems = result.data || [];
|
||||
this._panelItems = this._panelItems.concat(newItems);
|
||||
this._panelOffset += newItems.length;
|
||||
this._renderPanelContent();
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
if (typeof showConfirm === 'function') {
|
||||
const ok = await showConfirm('Delete all notifications?', 'This cannot be undone.');
|
||||
if (!ok) return;
|
||||
}
|
||||
// Delete visible items (API doesn't have bulk delete, so mark all read and let retention handle it)
|
||||
await this.markAllRead();
|
||||
},
|
||||
|
||||
openPanel() {
|
||||
this._closeDropdown();
|
||||
if (typeof PanelRegistry !== 'undefined') {
|
||||
PanelRegistry.open('notifications');
|
||||
}
|
||||
},
|
||||
|
||||
// ── WebSocket Events ─────────────────────
|
||||
|
||||
_bindEvents() {
|
||||
if (typeof Events === 'undefined') return;
|
||||
|
||||
// New notification from server
|
||||
Events.on('notification.new', (payload) => {
|
||||
// Prepend to cached list
|
||||
this._items.unshift(payload);
|
||||
if (this._items.length > 50) this._items.length = 50;
|
||||
this._unreadCount++;
|
||||
this._renderBadge();
|
||||
|
||||
// Update dropdown if open
|
||||
if (this._dropdownOpen) {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (dd) dd.innerHTML = this._renderDropdownContent();
|
||||
}
|
||||
|
||||
// Update panel if it has items
|
||||
if (this._panelItems.length) {
|
||||
this._panelItems.unshift(payload);
|
||||
this._panelTotal++;
|
||||
this._panelOffset++;
|
||||
this._renderPanelContent();
|
||||
}
|
||||
|
||||
// Toast for high-priority types
|
||||
const highPriority = ['kb.error', 'role.fallback'];
|
||||
if (highPriority.includes(payload.type)) {
|
||||
if (typeof UI !== 'undefined' && UI.toast) {
|
||||
UI.toast(payload.title, 'warning');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Badge sync across tabs (from markRead / markAllRead in another tab)
|
||||
Events.on('notification.read', (payload) => {
|
||||
if (payload.action === 'mark_all_read') {
|
||||
this._unreadCount = 0;
|
||||
this._items.forEach(n => n.is_read = true);
|
||||
this._panelItems.forEach(n => n.is_read = true);
|
||||
} else if (payload.id) {
|
||||
this._unreadCount = Math.max(0, this._unreadCount - 1);
|
||||
const item = this._items.find(n => n.id === payload.id);
|
||||
if (item) item.is_read = true;
|
||||
const pItem = this._panelItems.find(n => n.id === payload.id);
|
||||
if (pItem) pItem.is_read = true;
|
||||
}
|
||||
this._renderBadge();
|
||||
if (this._dropdownOpen) {
|
||||
const dd = document.getElementById('notifDropdown');
|
||||
if (dd) dd.innerHTML = this._renderDropdownContent();
|
||||
}
|
||||
this._renderPanelContent();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
|
||||
function _timeAgo(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr);
|
||||
const now = Date.now();
|
||||
const sec = Math.floor((now - d.getTime()) / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('Notifications', Notifications);
|
||||
349
src/js/pages.js
349
src/js/pages.js
@@ -1,349 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Pages (v0.22.5)
|
||||
// ==========================================
|
||||
// Client-side handlers for Go template-rendered pages.
|
||||
// Works with server-rendered HTML — no DOM construction,
|
||||
// just event handling and API calls.
|
||||
//
|
||||
// The server pre-populates all dropdowns. This JS handles:
|
||||
// - Cascading model select (provider change → filter models)
|
||||
// - Admin save operations (roles, routing, providers, teams, users, settings)
|
||||
// - Table filtering
|
||||
//
|
||||
// Exports: window.Pages, window._val (used by pages-splash.js)
|
||||
// ==========================================
|
||||
|
||||
|
||||
const Pages = {
|
||||
|
||||
// ── Model Select Cascading ───────────────
|
||||
|
||||
roleProviderChanged(selectEl) {
|
||||
const providerID = selectEl.value;
|
||||
const filterType = selectEl.dataset.filterType || '';
|
||||
const row = selectEl.closest('.form-row') || selectEl.parentElement;
|
||||
const modelSelect = row.querySelector('.model-model-select');
|
||||
if (!modelSelect) return;
|
||||
|
||||
const models = _pageData('Models') || [];
|
||||
const filtered = models.filter(m =>
|
||||
m.provider_config_id === providerID &&
|
||||
(!filterType || m.model_type === filterType)
|
||||
);
|
||||
modelSelect.innerHTML = '<option value="">— select model —</option>' +
|
||||
filtered.map(m =>
|
||||
`<option value="${m.model_id}">${esc(m.display_name || m.model_id)}</option>`
|
||||
).join('');
|
||||
},
|
||||
|
||||
// ── Role Save ────────────────────────────
|
||||
|
||||
async saveRole(roleName) {
|
||||
const container = document.querySelector(`.role-config[data-role="${roleName}"]`);
|
||||
if (!container) return;
|
||||
const getValue = (slot, cls) => {
|
||||
const sel = container.querySelector(`[data-slot="${slot}"].${cls}`);
|
||||
return sel ? sel.value : '';
|
||||
};
|
||||
const body = {
|
||||
role: roleName,
|
||||
primary: getValue('primary','model-provider-select') && getValue('primary','model-model-select')
|
||||
? { provider_config_id: getValue('primary','model-provider-select'), model_id: getValue('primary','model-model-select') }
|
||||
: null,
|
||||
fallback: getValue('fallback','model-provider-select') && getValue('fallback','model-model-select')
|
||||
? { provider_config_id: getValue('fallback','model-provider-select'), model_id: getValue('fallback','model-model-select') }
|
||||
: null,
|
||||
};
|
||||
const resp = await _api('PUT', '/api/v1/admin/roles/' + roleName, body);
|
||||
resp ? _toast('Role saved', 'success') : null;
|
||||
},
|
||||
|
||||
// ── Routing Policy ───────────────────────
|
||||
|
||||
showRoutingForm() { _show('routingPolicyForm'); },
|
||||
hideRoutingForm() { _hide('routingPolicyForm'); },
|
||||
routingScopeChanged(sel) {
|
||||
const row = document.getElementById('routingTeamRow');
|
||||
if (row) row.style.display = sel.value === 'team' ? '' : 'none';
|
||||
},
|
||||
|
||||
async saveRoutingPolicy() {
|
||||
const id = _val('routingPolicyId');
|
||||
const body = {
|
||||
name: _val('routingPolicyName'),
|
||||
priority: parseInt(_val('routingPolicyPriority') || '100'),
|
||||
type: _val('routingPolicyType'),
|
||||
scope: _val('routingPolicyScope'),
|
||||
team_id: _val('routingPolicyScope') === 'team' ? _val('routingPolicyTeamId') : '',
|
||||
config: _parseJSON(_val('routingPolicyConfig')),
|
||||
active: document.getElementById('routingPolicyActive')?.checked ?? true,
|
||||
};
|
||||
if (body.config === null) return; // parse error
|
||||
const ok = await _api(id ? 'PUT' : 'POST',
|
||||
id ? '/api/v1/admin/routing/policies/' + id : '/api/v1/admin/routing/policies', body);
|
||||
if (ok) { this.hideRoutingForm(); window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Providers ────────────────────────────
|
||||
|
||||
showProviderForm() { _show('providerFormWrap'); _val('providerFormId', ''); _val('providerFormTitle', 'Add Provider'); },
|
||||
hideProviderForm() { _hide('providerFormWrap'); },
|
||||
|
||||
editProvider(id) {
|
||||
const details = (_pageData('ProviderDetails') || []).find(p => p.id === id);
|
||||
if (!details) return;
|
||||
_val('providerFormId', id);
|
||||
_val('providerFormName', details.name);
|
||||
_val('providerFormEndpoint', details.endpoint);
|
||||
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
|
||||
_show('providerFormWrap');
|
||||
},
|
||||
|
||||
async saveProvider() {
|
||||
const id = _val('providerFormId');
|
||||
const body = {
|
||||
name: _val('providerFormName'),
|
||||
provider: _val('providerFormType'),
|
||||
endpoint: _val('providerFormEndpoint'),
|
||||
scope: _val('providerFormScope'),
|
||||
};
|
||||
const key = _val('providerFormKey');
|
||||
if (key) body.api_key = key;
|
||||
const teamId = _val('providerFormTeamId');
|
||||
if (body.scope === 'team' && teamId) body.team_id = teamId;
|
||||
|
||||
const ok = await _api(id ? 'PUT' : 'POST',
|
||||
id ? '/api/v1/admin/providers/' + id : '/api/v1/admin/providers', body);
|
||||
if (ok) { this.hideProviderForm(); window.location.reload(); }
|
||||
},
|
||||
|
||||
async syncProvider(id) {
|
||||
_toast('Syncing…', 'info');
|
||||
const ok = await _api('POST', '/api/v1/admin/providers/' + id + '/sync', {});
|
||||
if (ok) { _toast('Sync complete', 'success'); window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Model Filtering ──────────────────────
|
||||
|
||||
filterModels(query) {
|
||||
const q = (query || _val('modelSearch') || '').toLowerCase();
|
||||
const typeF = _val('modelTypeFilter') || '';
|
||||
const provF = _val('modelProviderFilter') || '';
|
||||
document.querySelectorAll('#modelTable tbody tr').forEach(tr => {
|
||||
const name = (tr.dataset.name || '').toLowerCase();
|
||||
const type = tr.dataset.type || '';
|
||||
const prov = tr.dataset.provider || '';
|
||||
const show = (!q || name.includes(q)) && (!typeF || type === typeF) && (!provF || prov === provF);
|
||||
tr.style.display = show ? '' : 'none';
|
||||
});
|
||||
},
|
||||
|
||||
// ── Teams ────────────────────────────────
|
||||
|
||||
showTeamForm() { _show('teamFormWrap'); _val('teamFormId', ''); _val('teamFormName', ''); _val('teamFormDescription', ''); },
|
||||
hideTeamForm() { _hide('teamFormWrap'); },
|
||||
|
||||
editTeam(id, name, desc) {
|
||||
_val('teamFormId', id);
|
||||
_val('teamFormName', name);
|
||||
_val('teamFormDescription', desc);
|
||||
document.getElementById('teamFormTitle').textContent = 'Edit Team';
|
||||
_show('teamFormWrap');
|
||||
},
|
||||
|
||||
async saveTeam() {
|
||||
const id = _val('teamFormId');
|
||||
const body = { name: _val('teamFormName'), description: _val('teamFormDescription') };
|
||||
const ok = await _api(id ? 'PUT' : 'POST',
|
||||
id ? '/api/v1/admin/teams/' + id : '/api/v1/admin/teams', body);
|
||||
if (ok) { this.hideTeamForm(); window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Users ────────────────────────────────
|
||||
|
||||
filterUsers(query) {
|
||||
const q = (query || '').toLowerCase();
|
||||
document.querySelectorAll('#userTable tbody tr').forEach(tr => {
|
||||
tr.style.display = (!q || (tr.dataset.name || '').toLowerCase().includes(q)) ? '' : 'none';
|
||||
});
|
||||
},
|
||||
|
||||
async editUserRole(id, username, currentRole) {
|
||||
const role = await showPrompt({ title: `Set role for ${username}`, value: currentRole, ok: 'Save' });
|
||||
if (!role || role === currentRole) return;
|
||||
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
|
||||
if (ok) window.location.reload();
|
||||
},
|
||||
|
||||
async toggleUser(id, active) {
|
||||
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { is_active: active });
|
||||
if (ok) window.location.reload();
|
||||
},
|
||||
|
||||
// ── Settings ─────────────────────────────
|
||||
|
||||
async saveSettings() {
|
||||
// Policies
|
||||
const policies = {
|
||||
allow_registration: document.getElementById('settRegEnabled')?.checked ? 'true' : 'false',
|
||||
default_user_active: _val('settRegDefaultState') === 'active' ? 'true' : 'false',
|
||||
allow_user_byok: document.getElementById('settUserBYOK')?.checked ? 'true' : 'false',
|
||||
allow_user_personas: document.getElementById('settUserPersonas')?.checked ? 'true' : 'false',
|
||||
kb_direct_access: document.getElementById('settKBDirect')?.checked ? 'true' : 'false',
|
||||
};
|
||||
|
||||
// Banner
|
||||
const banner = {
|
||||
enabled: document.getElementById('settBannerEnabled')?.checked || false,
|
||||
text: _val('settBannerText'),
|
||||
bg: _val('settBannerBg'),
|
||||
fg: _val('settBannerFg'),
|
||||
};
|
||||
|
||||
// System prompt
|
||||
const system_prompt = { content: _val('settSystemPrompt') };
|
||||
|
||||
const ok = await _api('PUT', '/api/v1/admin/settings', { policies, settings: { banner, system_prompt } });
|
||||
if (ok) _toast('Settings saved', 'success');
|
||||
},
|
||||
|
||||
// ── Login ─────────────────────────────────
|
||||
|
||||
async doLogin() {
|
||||
const username = _val('loginUsername');
|
||||
const password = _val('loginPassword');
|
||||
const errEl = document.getElementById('loginError');
|
||||
const btn = document.getElementById('loginBtn');
|
||||
if (!username || !password) {
|
||||
if (errEl) { errEl.textContent = 'Enter username and password'; errEl.style.display = ''; }
|
||||
return;
|
||||
}
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
|
||||
|
||||
const base = window.__BASE__ || '';
|
||||
try {
|
||||
const resp = await fetch(base + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: username, password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Login failed (${resp.status})`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
|
||||
// Save tokens in same format as API._setAuth / API.saveTokens
|
||||
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
user: data.user,
|
||||
}));
|
||||
// Set cookie for server-rendered page auth
|
||||
document.cookie = `sb_token=${data.access_token}; path=/; max-age=900; SameSite=Strict`;
|
||||
|
||||
// Redirect to chat surface
|
||||
window.location.href = base + '/';
|
||||
} catch (e) {
|
||||
if (errEl) { errEl.textContent = e.message; errEl.style.display = ''; }
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Log In'; }
|
||||
}
|
||||
},
|
||||
|
||||
// ── User Settings (settings surface) ─────
|
||||
|
||||
async saveProfile() {
|
||||
const name = _val('profileDisplayName');
|
||||
if (!name) { _toast('Display name is required', 'error'); return; }
|
||||
const ok = await _api('PUT', '/api/v1/profile', { display_name: name });
|
||||
if (ok) _toast('Profile saved', 'success');
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
const current = _val('settingsCurrentPw');
|
||||
const newPw = _val('settingsNewPw');
|
||||
const confirm = _val('settingsConfirmPw');
|
||||
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
|
||||
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
|
||||
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
|
||||
const ok = await _api('POST', '/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||
if (ok) {
|
||||
_toast('Password changed', 'success');
|
||||
_val('settingsCurrentPw', '');
|
||||
_val('settingsNewPw', '');
|
||||
_val('settingsConfirmPw', '');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Init: banner toggle ──────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cb = document.getElementById('settBannerEnabled');
|
||||
const fields = document.getElementById('bannerFields');
|
||||
if (cb && fields) {
|
||||
cb.addEventListener('change', () => { fields.style.display = cb.checked ? '' : 'none'; });
|
||||
fields.style.display = cb.checked ? '' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
function _pageData(key) {
|
||||
const d = window.__PAGE_DATA__;
|
||||
return d ? (d[key] || d[key.toLowerCase()]) : null;
|
||||
}
|
||||
|
||||
function _val(id, setVal) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return '';
|
||||
if (setVal !== undefined) { el.value = setVal; return; }
|
||||
return el.value;
|
||||
}
|
||||
|
||||
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
|
||||
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
|
||||
|
||||
|
||||
function _toast(msg, type) {
|
||||
if (typeof UI !== 'undefined' && UI.toast) {
|
||||
UI.toast(msg, type);
|
||||
} else {
|
||||
console[type === 'error' ? 'error' : 'log']('[Pages]', msg);
|
||||
}
|
||||
}
|
||||
|
||||
function _parseJSON(text) {
|
||||
if (!text || !text.trim()) return {};
|
||||
try { return JSON.parse(text); }
|
||||
catch (e) { _toast('Invalid JSON', 'error'); return null; }
|
||||
}
|
||||
|
||||
async function _api(method, path, body) {
|
||||
const base = window.__BASE__ || '';
|
||||
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
|
||||
let token = '';
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
|
||||
token = saved.accessToken || '';
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
try {
|
||||
const resp = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
if (resp.ok) return true;
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
_toast(err.error || `Error ${resp.status}`, 'error');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_toast('Connection error', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('Pages', Pages);
|
||||
sb.register('_val', _val); // used by pages-splash.js
|
||||
@@ -1,589 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — PaneContainer
|
||||
// ==========================================
|
||||
// v0.25.0: Composable multi-pane layout system.
|
||||
// Replaces the "one surface fills viewport" model with resizable
|
||||
// side-by-side panes. Supports leaf panes (single component) and
|
||||
// tabbed panes (N components, tab bar, one active at a time).
|
||||
//
|
||||
// Layout presets define the pane structure. The surface manifest
|
||||
// declares which preset to use via __MANIFEST__.layout.
|
||||
//
|
||||
// Usage:
|
||||
// const layout = PaneContainer.mount(
|
||||
// document.getElementById('editorBody'),
|
||||
// 'editor',
|
||||
// { workspaceId: 'ws-123' }
|
||||
// );
|
||||
// const fileTree = layout.getPane('files');
|
||||
// layout.resize('files', 250);
|
||||
// layout.destroy();
|
||||
//
|
||||
// Exports: window.PaneContainer
|
||||
|
||||
|
||||
const PaneContainer = {
|
||||
_presets: {},
|
||||
_active: null,
|
||||
|
||||
// ── Layout Preset Registration ──────────
|
||||
|
||||
/**
|
||||
* Register a named layout preset.
|
||||
* @param {string} name - Preset name (e.g. 'single', 'editor', 'split')
|
||||
* @param {object} definition - Layout tree definition
|
||||
*
|
||||
* Definition format:
|
||||
* { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} }
|
||||
* { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 }
|
||||
* { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] }
|
||||
*
|
||||
* sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining).
|
||||
*/
|
||||
registerPreset(name, definition) {
|
||||
this._presets[name] = definition;
|
||||
},
|
||||
|
||||
// ── Mount ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Mount a layout preset into a root element.
|
||||
* @param {HTMLElement} rootEl - Container element
|
||||
* @param {string} presetName - Registered preset name
|
||||
* @param {object} opts - Passed to component factories (e.g. { workspaceId })
|
||||
* @returns {object} Layout instance
|
||||
*/
|
||||
mount(rootEl, presetName, opts) {
|
||||
if (!rootEl) { console.error('[PaneContainer] No root element'); return null; }
|
||||
|
||||
const preset = this._presets[presetName];
|
||||
if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; }
|
||||
|
||||
opts = opts || {};
|
||||
const surfaceId = window.__SURFACE__ || 'unknown';
|
||||
|
||||
// Build the DOM tree from the preset definition
|
||||
const panes = new Map(); // id → { el, component, type }
|
||||
const handles = [];
|
||||
|
||||
const buildNode = (def, parentEl) => {
|
||||
if (def.type === 'leaf') {
|
||||
return _buildLeafPane(def, parentEl, panes, opts);
|
||||
}
|
||||
if (def.type === 'tabbed') {
|
||||
return _buildTabbedPane(def, parentEl, panes, opts, surfaceId);
|
||||
}
|
||||
if (def.type === 'split') {
|
||||
return _buildSplit(def, parentEl, panes, handles, opts, surfaceId);
|
||||
}
|
||||
console.error('[PaneContainer] Unknown node type:', def.type);
|
||||
return null;
|
||||
};
|
||||
|
||||
rootEl.classList.add('pane-container');
|
||||
const rootNode = buildNode(preset, rootEl);
|
||||
|
||||
// Restore persisted sizes
|
||||
_restoreSizes(surfaceId, presetName, handles);
|
||||
|
||||
const instance = {
|
||||
rootEl,
|
||||
presetName,
|
||||
_panes: panes,
|
||||
_handles: handles,
|
||||
_rootNode: rootNode,
|
||||
|
||||
/** Get the component instance for a pane by ID. */
|
||||
getPane(id) {
|
||||
const p = panes.get(id);
|
||||
return p ? p.component : null;
|
||||
},
|
||||
|
||||
/** Get the pane element by ID. */
|
||||
getPaneEl(id) {
|
||||
const p = panes.get(id);
|
||||
return p ? p.el : null;
|
||||
},
|
||||
|
||||
/** Programmatic resize of a leaf pane. */
|
||||
resize(id, sizePx) {
|
||||
const p = panes.get(id);
|
||||
if (p?.el) {
|
||||
p.el.style.flexBasis = sizePx + 'px';
|
||||
p.el.style.flexGrow = '0';
|
||||
p.el.style.flexShrink = '0';
|
||||
}
|
||||
},
|
||||
|
||||
/** Minimize a pane (collapse to 0). */
|
||||
minimize(id) {
|
||||
const p = panes.get(id);
|
||||
if (!p?.el) return;
|
||||
p._prevBasis = p.el.style.flexBasis;
|
||||
p._prevGrow = p.el.style.flexGrow;
|
||||
p.el.style.flexBasis = '0px';
|
||||
p.el.style.flexGrow = '0';
|
||||
p.el.style.overflow = 'hidden';
|
||||
p.el.classList.add('pane-minimized');
|
||||
},
|
||||
|
||||
/** Restore a minimized pane. */
|
||||
restore(id) {
|
||||
const p = panes.get(id);
|
||||
if (!p?.el) return;
|
||||
p.el.style.flexBasis = p._prevBasis || '';
|
||||
p.el.style.flexGrow = p._prevGrow || '';
|
||||
p.el.style.overflow = '';
|
||||
p.el.classList.remove('pane-minimized');
|
||||
},
|
||||
|
||||
/** Tear down the entire layout. */
|
||||
destroy() {
|
||||
// Destroy all component instances
|
||||
for (const [, p] of panes) {
|
||||
if (p.component?.destroy) p.component.destroy();
|
||||
// Tabbed: destroy all tab components
|
||||
if (p.tabs) {
|
||||
for (const tab of p.tabs) {
|
||||
if (tab.component?.destroy) tab.component.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
panes.clear();
|
||||
handles.length = 0;
|
||||
rootEl.innerHTML = '';
|
||||
rootEl.classList.remove('pane-container');
|
||||
PaneContainer._active = null;
|
||||
},
|
||||
};
|
||||
|
||||
PaneContainer._active = instance;
|
||||
return instance;
|
||||
},
|
||||
|
||||
/** Get the currently active layout instance. */
|
||||
active() { return this._active; },
|
||||
};
|
||||
|
||||
// ── Leaf Pane Builder ───────────────────────
|
||||
|
||||
function _buildLeafPane(def, parentEl, panes, opts) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane pane-leaf';
|
||||
el.dataset.paneId = def.id;
|
||||
|
||||
if (def.size) {
|
||||
el.style.flexBasis = def.size + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
} else {
|
||||
el.style.flex = '1';
|
||||
el.style.minWidth = '0';
|
||||
el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane
|
||||
}
|
||||
if (def.minSize) el.style.minWidth = def.minSize + 'px';
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
// Component instantiation is deferred — the surface boot script
|
||||
// creates components and mounts them into pane elements.
|
||||
// We just register the pane slot here.
|
||||
panes.set(def.id, {
|
||||
el,
|
||||
component: null, // set by surface boot script
|
||||
type: 'leaf',
|
||||
def,
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Tabbed Pane Builder ─────────────────────
|
||||
|
||||
function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane pane-tabbed';
|
||||
el.dataset.paneId = def.id;
|
||||
|
||||
if (def.size) {
|
||||
el.style.flexBasis = def.size + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
} else {
|
||||
el.style.flex = '1';
|
||||
el.style.minWidth = '0';
|
||||
}
|
||||
if (def.minSize) el.style.minWidth = def.minSize + 'px';
|
||||
|
||||
// Tab bar
|
||||
const tabBar = document.createElement('div');
|
||||
tabBar.className = 'pane-tab-bar';
|
||||
el.appendChild(tabBar);
|
||||
|
||||
// Content area
|
||||
const contentArea = document.createElement('div');
|
||||
contentArea.className = 'pane-tab-content';
|
||||
el.appendChild(contentArea);
|
||||
|
||||
// Restore persisted active tab
|
||||
const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id;
|
||||
let savedTab = 0;
|
||||
try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {}
|
||||
if (savedTab >= (def.tabs || []).length) savedTab = 0;
|
||||
|
||||
const tabs = (def.tabs || []).map((tabDef, i) => {
|
||||
// Tab button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : '');
|
||||
btn.textContent = tabDef.label || tabDef.id;
|
||||
btn.dataset.tabIndex = i;
|
||||
tabBar.appendChild(btn);
|
||||
|
||||
// Tab panel (mount point for component)
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'pane-tab-panel';
|
||||
panel.dataset.tabId = tabDef.id;
|
||||
panel.style.display = i === savedTab ? '' : 'none';
|
||||
contentArea.appendChild(panel);
|
||||
|
||||
return {
|
||||
id: tabDef.id,
|
||||
label: tabDef.label,
|
||||
component: tabDef.component,
|
||||
opts: tabDef.opts || {},
|
||||
btn,
|
||||
panel,
|
||||
instance: null, // lazy-created on first activation
|
||||
_activated: i === savedTab,
|
||||
};
|
||||
});
|
||||
|
||||
// Tab click handler
|
||||
const activateTab = (index) => {
|
||||
tabs.forEach((tab, i) => {
|
||||
const isActive = i === index;
|
||||
tab.btn.classList.toggle('pane-tab-btn--active', isActive);
|
||||
tab.panel.style.display = isActive ? '' : 'none';
|
||||
if (isActive) tab._activated = true;
|
||||
});
|
||||
// Persist
|
||||
try { localStorage.setItem(persistKey, String(index)); } catch (_) {}
|
||||
};
|
||||
|
||||
tabs.forEach((tab, i) => {
|
||||
tab.btn.addEventListener('click', () => activateTab(i));
|
||||
});
|
||||
|
||||
parentEl.appendChild(el);
|
||||
|
||||
panes.set(def.id, {
|
||||
el,
|
||||
component: null, // tabbed panes don't have a single component
|
||||
type: 'tabbed',
|
||||
def,
|
||||
tabs,
|
||||
activateTab,
|
||||
getActiveTab() {
|
||||
return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0];
|
||||
},
|
||||
getTabPanel(tabId) {
|
||||
const t = tabs.find(t => t.id === tabId);
|
||||
return t ? t.panel : null;
|
||||
},
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Split Builder ───────────────────────────
|
||||
|
||||
function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'pane-split pane-split--' + (def.direction || 'horizontal');
|
||||
parentEl.appendChild(el);
|
||||
|
||||
const children = def.children || [];
|
||||
const sizes = def.sizes || [];
|
||||
|
||||
children.forEach((childDef, i) => {
|
||||
// Apply initial size from preset
|
||||
if (sizes[i] != null && childDef.size === undefined) {
|
||||
childDef.size = sizes[i];
|
||||
}
|
||||
|
||||
// Build child
|
||||
if (childDef.type === 'leaf') {
|
||||
_buildLeafPane(childDef, el, panes, opts);
|
||||
} else if (childDef.type === 'tabbed') {
|
||||
_buildTabbedPane(childDef, el, panes, opts, surfaceId);
|
||||
} else if (childDef.type === 'split') {
|
||||
_buildSplit(childDef, el, panes, handles, opts, surfaceId);
|
||||
}
|
||||
|
||||
// Add drag handle between children (not after last)
|
||||
if (i < children.length - 1) {
|
||||
const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root');
|
||||
handles.push(handle);
|
||||
}
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Drag Handle ─────────────────────────────
|
||||
|
||||
function _createHandle(direction, splitEl, index, surfaceId, splitId) {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = 'pane-handle pane-handle--' + direction;
|
||||
handle.dataset.handleIndex = index;
|
||||
|
||||
// Insert after the Nth child (child, handle, child, handle, child)
|
||||
// Children are at positions 0, 2, 4, ... and handles at 1, 3, ...
|
||||
// But since we add sequentially, handle goes after the (index)th pane
|
||||
const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split');
|
||||
const afterEl = childEls[index];
|
||||
if (afterEl?.nextSibling) {
|
||||
splitEl.insertBefore(handle, afterEl.nextSibling);
|
||||
} else {
|
||||
splitEl.appendChild(handle);
|
||||
}
|
||||
|
||||
// Track adjacent panes for dblclick reset
|
||||
let lastLeftEl = null, lastRightEl = null;
|
||||
|
||||
initDragResize(handle, {
|
||||
direction,
|
||||
onStart(_clientPos) {
|
||||
// Find the two panes adjacent to this handle
|
||||
const handleIdx = Array.from(splitEl.children).indexOf(handle);
|
||||
const leftEl = splitEl.children[handleIdx - 1];
|
||||
const rightEl = splitEl.children[handleIdx + 1];
|
||||
if (!leftEl || !rightEl) return false;
|
||||
|
||||
lastLeftEl = leftEl;
|
||||
lastRightEl = rightEl;
|
||||
|
||||
const isHoriz = direction === 'horizontal';
|
||||
const dim = isHoriz ? 'width' : 'height';
|
||||
const startLeftBasis = leftEl.getBoundingClientRect()[dim];
|
||||
const startRightBasis = rightEl.getBoundingClientRect()[dim];
|
||||
|
||||
// Lock non-flex panes to current rendered size so the first
|
||||
// move delta doesn't cause a visual snap
|
||||
if (leftEl.dataset.paneFlex !== '1') {
|
||||
leftEl.style.flexBasis = startLeftBasis + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
}
|
||||
if (rightEl.dataset.paneFlex !== '1') {
|
||||
rightEl.style.flexBasis = startRightBasis + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
}
|
||||
|
||||
handle.classList.add('pane-handle--active');
|
||||
|
||||
return { leftEl, rightEl, startLeftBasis, startRightBasis };
|
||||
},
|
||||
onMove(delta, ctx) {
|
||||
const { leftEl, rightEl, startLeftBasis, startRightBasis } = ctx;
|
||||
const leftIsFlex = leftEl.dataset.paneFlex === '1';
|
||||
const rightIsFlex = rightEl.dataset.paneFlex === '1';
|
||||
const isHoriz = direction === 'horizontal';
|
||||
const dim = isHoriz ? 'width' : 'height';
|
||||
const maxSize = splitEl.getBoundingClientRect()[dim] * 0.6;
|
||||
|
||||
if (leftIsFlex) {
|
||||
const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta));
|
||||
rightEl.style.flexBasis = newRight + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
} else if (rightIsFlex) {
|
||||
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
|
||||
leftEl.style.flexBasis = newLeft + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
} else {
|
||||
const newLeft = Math.max(50, startLeftBasis + delta);
|
||||
const newRight = Math.max(50, startRightBasis - delta);
|
||||
leftEl.style.flexBasis = newLeft + 'px';
|
||||
leftEl.style.flexGrow = '0';
|
||||
leftEl.style.flexShrink = '0';
|
||||
rightEl.style.flexBasis = newRight + 'px';
|
||||
rightEl.style.flexGrow = '0';
|
||||
rightEl.style.flexShrink = '0';
|
||||
}
|
||||
},
|
||||
onEnd(_ctx) {
|
||||
handle.classList.remove('pane-handle--active');
|
||||
_persistSizes(surfaceId, splitEl);
|
||||
},
|
||||
});
|
||||
|
||||
// Double-click resets to default
|
||||
handle.addEventListener('dblclick', () => {
|
||||
_resetPaneSize(lastLeftEl);
|
||||
_resetPaneSize(lastRightEl);
|
||||
_persistSizes(surfaceId, splitEl);
|
||||
});
|
||||
|
||||
return { handle, splitEl, index, direction };
|
||||
}
|
||||
|
||||
/** Reset a pane element to its default flex sizing. */
|
||||
function _resetPaneSize(el) {
|
||||
if (!el) return;
|
||||
if (el.dataset.paneFlex === '1') {
|
||||
el.style.flex = '1';
|
||||
}
|
||||
el.style.flexBasis = '';
|
||||
el.style.flexGrow = '';
|
||||
el.style.flexShrink = '';
|
||||
}
|
||||
|
||||
// ── Persistence ─────────────────────────────
|
||||
|
||||
// v0.27.0: Debounced server sync for cross-device pane layout persistence.
|
||||
var _paneSyncTimer = null;
|
||||
function _syncPaneLayoutsToServer() {
|
||||
if (_paneSyncTimer) clearTimeout(_paneSyncTimer);
|
||||
_paneSyncTimer = setTimeout(function() {
|
||||
try {
|
||||
// Collect all sb_layout_* keys
|
||||
var layouts = {};
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var k = localStorage.key(i);
|
||||
if (k && k.indexOf('sb_layout_') === 0) {
|
||||
try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {}
|
||||
}
|
||||
}
|
||||
if (typeof API !== 'undefined' && API._put) {
|
||||
API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 2000); // 2s debounce — avoid hammering during resize drags
|
||||
}
|
||||
|
||||
// v0.27.0: On page load, restore pane layouts from server if available.
|
||||
// Called once from PaneContainer init (or surface boot).
|
||||
function _loadPaneLayoutsFromServer() {
|
||||
if (typeof API === 'undefined' || !API._get) return;
|
||||
API._get('/api/v1/settings').then(function(settings) {
|
||||
if (!settings || !settings.pane_layouts) return;
|
||||
var layouts = settings.pane_layouts;
|
||||
for (var key in layouts) {
|
||||
if (key.indexOf('sb_layout_') === 0 && layouts[key]) {
|
||||
// Only overwrite if localStorage is empty for this key
|
||||
if (!localStorage.getItem(key)) {
|
||||
localStorage.setItem(key, JSON.stringify(layouts[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
// Kick off server layout load on script execution
|
||||
if (typeof API !== 'undefined') _loadPaneLayoutsFromServer();
|
||||
|
||||
function _persistSizes(surfaceId, splitEl) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
// Collect sizes for fixed panes only (skip flex panes)
|
||||
const sizes = {};
|
||||
splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => {
|
||||
const id = child.dataset?.paneId;
|
||||
if (id && child.dataset.paneFlex !== '1') {
|
||||
sizes[id] = child.getBoundingClientRect().width;
|
||||
}
|
||||
});
|
||||
// Merge with existing persisted data
|
||||
let existing = {};
|
||||
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
|
||||
Object.assign(existing, sizes);
|
||||
localStorage.setItem(key, JSON.stringify(existing));
|
||||
|
||||
// v0.27.0: Debounced sync to server for cross-device persistence
|
||||
_syncPaneLayoutsToServer();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _restoreSizes(surfaceId, presetName, handles) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
if (Object.keys(saved).length === 0) return;
|
||||
|
||||
// Find the split container to measure available width
|
||||
const splitEl = document.querySelector('.pane-split--horizontal');
|
||||
const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth;
|
||||
|
||||
// Validate: sum of all fixed pane sizes must leave at least 150px for flex pane
|
||||
const fixedPanes = document.querySelectorAll('.pane[data-pane-id]');
|
||||
let totalFixed = 0;
|
||||
fixedPanes.forEach(el => {
|
||||
const id = el.dataset.paneId;
|
||||
if (saved[id] && el.dataset.paneFlex !== '1') {
|
||||
totalFixed += saved[id];
|
||||
}
|
||||
});
|
||||
|
||||
// If saved sizes overflow, clear corrupt data and use defaults
|
||||
if (totalFixed > containerWidth - 150) {
|
||||
console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting');
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply saved sizes to pane elements (skip flex panes)
|
||||
fixedPanes.forEach(el => {
|
||||
const id = el.dataset.paneId;
|
||||
if (saved[id] && el.dataset.paneFlex !== '1') {
|
||||
el.style.flexBasis = saved[id] + 'px';
|
||||
el.style.flexGrow = '0';
|
||||
el.style.flexShrink = '0';
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Built-in Presets ────────────────────────
|
||||
|
||||
// single: One leaf pane (default chat UX)
|
||||
PaneContainer.registerPreset('single', {
|
||||
type: 'leaf',
|
||||
id: 'main',
|
||||
component: 'chat-pane',
|
||||
});
|
||||
|
||||
// editor: files | code-editor | <chat, notes>
|
||||
PaneContainer.registerPreset('editor', {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
direction: 'horizontal',
|
||||
sizes: [220, null, 380],
|
||||
children: [
|
||||
{ type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 },
|
||||
{ type: 'leaf', id: 'editor', component: 'code-editor' },
|
||||
{
|
||||
type: 'tabbed', id: 'assist', size: 380, minSize: 200,
|
||||
tabs: [
|
||||
{ id: 'chat', label: 'Chat', component: 'chat-pane' },
|
||||
{ id: 'notes', label: 'Notes', component: 'note-editor' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// split: generic two-pane layout
|
||||
PaneContainer.registerPreset('split', {
|
||||
type: 'split',
|
||||
id: 'root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ type: 'leaf', id: 'primary', component: null },
|
||||
{ type: 'leaf', id: 'secondary', component: null },
|
||||
],
|
||||
});
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('PaneContainer', PaneContainer);
|
||||
454
src/js/panels.js
454
src/js/panels.js
@@ -1,454 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Panel Registry (v0.22.0)
|
||||
// ==========================================
|
||||
// Workspace-aware panel system. Panels are shown inside the
|
||||
// workspace secondary pane (.workspace-secondary). The workspace
|
||||
// owns the resize handle between primary and secondary.
|
||||
//
|
||||
// Each panel registers with name, DOM element, and lifecycle hooks.
|
||||
// The registry owns all open/close/focus logic — individual panels
|
||||
// never touch the secondary container directly.
|
||||
//
|
||||
// Layout model (v0.22.0):
|
||||
// .workspace
|
||||
// .workspace-primary ← main content (chat / notes / editor)
|
||||
// .workspace-handle ← drag-resize between panes
|
||||
// .workspace-secondary ← panel pages (preview / notes / project)
|
||||
//
|
||||
// Exports: window.PanelRegistry,window.toggleSidePanelFullscreen,window._initWorkspaceResize,window._initPanelSwipe,window._initPanelResponsive,window._initPanelOverlay
|
||||
|
||||
|
||||
// ── Panel Registry ──────────────────────────
|
||||
|
||||
const PanelRegistry = {
|
||||
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
|
||||
_active: null, // currently visible panel name
|
||||
_order: [], // registration order (for tab rendering + cycle)
|
||||
|
||||
/**
|
||||
* Register a panel.
|
||||
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
|
||||
* @param {object} opts
|
||||
* @param {HTMLElement} opts.element — the .side-panel-page div
|
||||
* @param {string} opts.label — display name
|
||||
* @param {Function} [opts.onOpen] — called when panel becomes visible
|
||||
* @param {Function} [opts.onClose] — called when panel is hidden
|
||||
* @param {Function} [opts.saveState] — returns state snapshot before hiding
|
||||
* @param {Function} [opts.restoreState]— receives state snapshot on re-show
|
||||
* @param {Array} [opts.actions] — per-panel action button configs
|
||||
* Each action: { id, icon, title, onClick, visible? }
|
||||
*/
|
||||
register(name, opts) {
|
||||
if (this._panels[name]) {
|
||||
console.warn(`[panels] panel "${name}" already registered, replacing`);
|
||||
}
|
||||
this._panels[name] = {
|
||||
element: opts.element,
|
||||
label: opts.label || name,
|
||||
onOpen: opts.onOpen || null,
|
||||
onClose: opts.onClose || null,
|
||||
saveState: opts.saveState || null,
|
||||
restoreState: opts.restoreState || null,
|
||||
actions: opts.actions || [],
|
||||
state: {},
|
||||
};
|
||||
if (!this._order.includes(name)) {
|
||||
this._order.push(name);
|
||||
}
|
||||
this._renderLabel();
|
||||
},
|
||||
|
||||
/**
|
||||
* Open a panel (and the secondary pane if collapsed).
|
||||
* Hides current active panel, shows the requested one.
|
||||
*/
|
||||
open(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) {
|
||||
console.warn(`[panels] unknown panel: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this._container();
|
||||
if (!container) return;
|
||||
|
||||
// Already active — just refresh label/actions
|
||||
if (this._active === name) {
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide current active (saves state + fires onClose)
|
||||
if (this._active && this._active !== name) {
|
||||
this._hide(this._active);
|
||||
}
|
||||
|
||||
// Hide ALL panel pages in the body, including unregistered ones
|
||||
// (e.g. sidePanelPreview which is a static placeholder)
|
||||
const body = this._body();
|
||||
if (body) {
|
||||
body.querySelectorAll('.side-panel-page').forEach(el => {
|
||||
if (el !== panel.element) el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Show the secondary pane
|
||||
container.classList.add('open');
|
||||
this._showHandle(true);
|
||||
this._show(name);
|
||||
this._active = name;
|
||||
this._showOverlay();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Close a specific panel. If it's active, close the secondary pane.
|
||||
*/
|
||||
close(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) return;
|
||||
|
||||
if (this._active === name) {
|
||||
this._hide(name);
|
||||
this._active = null;
|
||||
this._closeContainer();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle: if this panel is visible, close it. Otherwise, open it.
|
||||
*/
|
||||
toggle(name) {
|
||||
if (this.isOpen(name)) {
|
||||
this.close(name);
|
||||
} else {
|
||||
this.open(name);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close all panels and the secondary pane.
|
||||
*/
|
||||
closeAll() {
|
||||
if (this._active) {
|
||||
this._hide(this._active);
|
||||
this._active = null;
|
||||
}
|
||||
this._closeContainer();
|
||||
},
|
||||
|
||||
/** Is a specific panel currently visible? */
|
||||
isOpen(name) {
|
||||
return this._active === name;
|
||||
},
|
||||
|
||||
/** Is the secondary pane open at all? */
|
||||
isContainerOpen() {
|
||||
return this._container()?.classList.contains('open') || false;
|
||||
},
|
||||
|
||||
/** Get the active panel name (or null). */
|
||||
active() {
|
||||
return this._active;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cycle to the next registered panel. If container is closed, open
|
||||
* the first panel. Wraps around.
|
||||
*/
|
||||
cycle() {
|
||||
if (this._order.length === 0) return;
|
||||
|
||||
if (!this._active) {
|
||||
this.open(this._order[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = this._order.indexOf(this._active);
|
||||
const next = this._order[(idx + 1) % this._order.length];
|
||||
this.open(next);
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────
|
||||
|
||||
/** The secondary pane element. */
|
||||
_container() {
|
||||
return document.getElementById('workspaceSecondary');
|
||||
},
|
||||
|
||||
_body() {
|
||||
return document.getElementById('sidePanelBody');
|
||||
},
|
||||
|
||||
/** Show a panel: set display, restore state, fire onOpen. */
|
||||
_show(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) return;
|
||||
|
||||
panel.element.style.display = '';
|
||||
|
||||
// Restore state if previously saved
|
||||
if (panel.restoreState && Object.keys(panel.state).length > 0) {
|
||||
panel.restoreState(panel.state);
|
||||
}
|
||||
|
||||
if (panel.onOpen) panel.onOpen();
|
||||
},
|
||||
|
||||
/** Hide a panel: save state, call onClose, set display:none. */
|
||||
_hide(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) return;
|
||||
|
||||
if (panel.saveState) {
|
||||
panel.state = panel.saveState() || {};
|
||||
}
|
||||
|
||||
if (panel.onClose) panel.onClose();
|
||||
|
||||
panel.element.style.display = 'none';
|
||||
},
|
||||
|
||||
/** Close the secondary pane. */
|
||||
_closeContainer() {
|
||||
const container = this._container();
|
||||
if (!container) return;
|
||||
container.classList.remove('open', 'fullscreen');
|
||||
container.style.width = '';
|
||||
container.style.minWidth = '';
|
||||
this._showHandle(false);
|
||||
this._hideOverlay();
|
||||
},
|
||||
|
||||
/** Show/hide the workspace resize handle. */
|
||||
_showHandle(show) {
|
||||
const handle = document.getElementById('workspaceHandle');
|
||||
if (handle) handle.classList.toggle('active', show);
|
||||
},
|
||||
|
||||
// ── Mobile overlay ──────────────────────
|
||||
|
||||
_isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
},
|
||||
|
||||
/** Show overlay behind secondary on mobile (tap-to-close). */
|
||||
_showOverlay() {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (ov && this._isMobile()) ov.style.display = 'block';
|
||||
},
|
||||
|
||||
/** Hide the mobile overlay. */
|
||||
_hideOverlay() {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
},
|
||||
|
||||
// ── Label and action rendering ──────────
|
||||
|
||||
/** Update the header label to show the active panel name. */
|
||||
_renderLabel() {
|
||||
const el = document.getElementById('sidePanelLabel');
|
||||
if (!el) return;
|
||||
|
||||
if (this._active) {
|
||||
const p = this._panels[this._active];
|
||||
el.textContent = p ? p.label : '';
|
||||
} else {
|
||||
el.textContent = '';
|
||||
}
|
||||
},
|
||||
|
||||
/** Render per-panel action buttons for the active panel. */
|
||||
_renderActions() {
|
||||
const slot = document.getElementById('sidePanelPanelActions');
|
||||
if (!slot) return;
|
||||
|
||||
if (!this._active) {
|
||||
slot.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = this._panels[this._active];
|
||||
slot.innerHTML = (panel.actions || [])
|
||||
.filter(a => !a.visible || a.visible())
|
||||
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${esc(a.title || '')}">${a.icon || ''}</button>`)
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Refresh action button visibility (call after state changes). */
|
||||
refreshActions() {
|
||||
this._renderActions();
|
||||
},
|
||||
};
|
||||
|
||||
/** Minimal HTML escaping for panel labels/titles. */
|
||||
|
||||
// ── Secondary Pane Fullscreen ──────────────
|
||||
|
||||
function toggleSidePanelFullscreen() {
|
||||
const panel = document.getElementById('workspaceSecondary');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('fullscreen');
|
||||
const btn = document.getElementById('sidePanelFullscreenBtn');
|
||||
if (btn) {
|
||||
const isFS = panel.classList.contains('fullscreen');
|
||||
btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen';
|
||||
btn.innerHTML = isFS
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace Resize Handle ────────────────
|
||||
|
||||
function _initWorkspaceResize() {
|
||||
const secondary = document.getElementById('workspaceSecondary');
|
||||
const handle = document.getElementById('workspaceHandle');
|
||||
if (!handle || !secondary) return;
|
||||
|
||||
initDragResize(handle, {
|
||||
direction: 'horizontal',
|
||||
canDrag() {
|
||||
return handle.classList.contains('active') &&
|
||||
!secondary.classList.contains('fullscreen');
|
||||
},
|
||||
onStart(_clientPos) {
|
||||
// Kill transition first, force reflow, then measure.
|
||||
// Pin inline width immediately to prevent snap-back if
|
||||
// the open-transition is still animating.
|
||||
secondary.style.transition = 'none';
|
||||
void secondary.offsetWidth;
|
||||
// #surfaceInner has transform:scale(z) from applyAppearance.
|
||||
// getBoundingClientRect on descendants returns post-transform
|
||||
// visual px. Divide by scale to recover CSS px for style.width.
|
||||
const inner = document.getElementById('surfaceInner');
|
||||
const m = inner?.style.transform?.match(/scale\(([^)]+)\)/);
|
||||
const scale = m ? parseFloat(m[1]) : 1;
|
||||
const startW = secondary.getBoundingClientRect().width / scale;
|
||||
secondary.style.width = startW + 'px';
|
||||
secondary.style.minWidth = startW + 'px';
|
||||
return { startW, scale };
|
||||
},
|
||||
onMove(delta, ctx) {
|
||||
// Handle is left of secondary — dragging left (negative delta) grows it.
|
||||
// Mouse delta is in viewport px; convert to CSS px in scaled space.
|
||||
const cssDelta = delta / ctx.scale;
|
||||
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, ctx.startW - cssDelta));
|
||||
secondary.style.width = newW + 'px';
|
||||
secondary.style.minWidth = newW + 'px';
|
||||
},
|
||||
onEnd(_ctx) {
|
||||
secondary.style.transition = '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mobile Swipe Navigation ─────────────────
|
||||
|
||||
function _initPanelSwipe() {
|
||||
const body = document.getElementById('sidePanelBody');
|
||||
if (!body) return;
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let tracking = false;
|
||||
|
||||
body.addEventListener('touchstart', (e) => {
|
||||
if (!PanelRegistry.isContainerOpen()) return;
|
||||
if (PanelRegistry._order.length < 2) return;
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
tracking = true;
|
||||
}, { passive: true });
|
||||
|
||||
body.addEventListener('touchend', (e) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
|
||||
// Require horizontal movement > 80px and more horizontal than vertical
|
||||
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
|
||||
if (dx > 0) {
|
||||
_cyclePanelDirection(-1);
|
||||
} else {
|
||||
_cyclePanelDirection(1);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle panels in a specific direction.
|
||||
* direction: +1 = next, -1 = previous.
|
||||
*/
|
||||
function _cyclePanelDirection(direction) {
|
||||
const order = PanelRegistry._order;
|
||||
if (order.length < 2 || !PanelRegistry._active) return;
|
||||
|
||||
const idx = order.indexOf(PanelRegistry._active);
|
||||
const next = order[(idx + direction + order.length) % order.length];
|
||||
PanelRegistry.open(next);
|
||||
}
|
||||
|
||||
// ── Responsive Resize Handler ───────────────
|
||||
|
||||
function _initPanelResponsive() {
|
||||
let wasWide = window.innerWidth > 768;
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const isWide = window.innerWidth > 768;
|
||||
|
||||
// Update overlay visibility: show on mobile if panel open, hide on desktop
|
||||
if (PanelRegistry.isContainerOpen()) {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (ov) {
|
||||
ov.style.display = isWide ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
wasWide = isWide;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Panel Overlay (mobile tap-to-close) ─────
|
||||
|
||||
function _initPanelOverlay() {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (!ov) return;
|
||||
|
||||
ov.addEventListener('click', () => {
|
||||
PanelRegistry.closeAll();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Legacy Compat (thin wrappers) ───────────
|
||||
|
||||
function openSidePanel(tab) {
|
||||
PanelRegistry.open(tab);
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
PanelRegistry.closeAll();
|
||||
}
|
||||
|
||||
function switchSidePanelTab(tab) {
|
||||
PanelRegistry.open(tab);
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('PanelRegistry', PanelRegistry);
|
||||
sb.register('toggleSidePanelFullscreen', toggleSidePanelFullscreen);
|
||||
sb.register('_initWorkspaceResize', _initWorkspaceResize);
|
||||
sb.register('_initPanelSwipe', _initPanelSwipe);
|
||||
sb.register('_initPanelResponsive', _initPanelResponsive);
|
||||
sb.register('_initPanelOverlay', _initPanelOverlay);
|
||||
File diff suppressed because it is too large
Load Diff
122
src/js/sw/components/chat-pane/code-block.js
Normal file
122
src/js/sw/components/chat-pane/code-block.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — CodeBlock Component
|
||||
// ==========================================
|
||||
// Enhanced code block: language badge, copy button.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage (from markdown post-processing):
|
||||
// Enhances <pre><code> blocks after markdown rendering.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* SVG icons for copy/check.
|
||||
*/
|
||||
const COPY_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHECK_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* CodeBlock — wraps a <pre><code> block with language badge + copy button.
|
||||
*
|
||||
* @param {{ code: string, language?: string }} props
|
||||
*/
|
||||
export function CodeBlock({ code, language }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
const langLabel = language && language !== 'text' ? language : null;
|
||||
|
||||
return html`
|
||||
<div class="sw-code-block">
|
||||
<div class="sw-code-block__header">
|
||||
${langLabel && html`<span class="sw-code-block__lang">${langLabel}</span>`}
|
||||
<button class="sw-code-block__copy"
|
||||
title=${copied ? 'Copied!' : 'Copy code'}
|
||||
onClick=${_copy}
|
||||
aria-label="Copy code to clipboard">
|
||||
${copied ? CHECK_SVG : COPY_SVG}
|
||||
<span class="sw-code-block__copy-text">${copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="sw-code-block__pre"><code class=${langLabel ? 'language-' + langLabel : ''}
|
||||
dangerouslySetInnerHTML=${{ __html: _escapeHtml(code) }} /></pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process rendered markdown HTML to enhance code blocks.
|
||||
* Extracts language from class="language-xxx" and returns
|
||||
* an array of { type, code, language } blocks for Preact rendering.
|
||||
*
|
||||
* @param {string} html — rendered markdown HTML
|
||||
* @returns {{ segments: Array<{type: 'html'|'code', html?: string, code?: string, language?: string}> }}
|
||||
*/
|
||||
export function extractCodeBlocks(htmlStr) {
|
||||
// Match <pre><code class="language-xxx">...</code></pre> blocks
|
||||
const pattern = /<pre><code(?:\s+class="language-(\w+)")?>([\s\S]*?)<\/code><\/pre>/g;
|
||||
const segments = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(htmlStr)) !== null) {
|
||||
// Push preceding HTML
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'html', html: htmlStr.slice(lastIndex, match.index) });
|
||||
}
|
||||
// Push code block
|
||||
const language = match[1] || '';
|
||||
const code = _unescapeHtml(match[2]);
|
||||
segments.push({ type: 'code', code, language });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Push trailing HTML
|
||||
if (lastIndex < htmlStr.length) {
|
||||
segments.push({ type: 'html', html: htmlStr.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function _unescapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
205
src/js/sw/components/chat-pane/emoji-picker.js
Normal file
205
src/js/sw/components/chat-pane/emoji-picker.js
Normal file
@@ -0,0 +1,205 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — EmojiPicker Component
|
||||
// ==========================================
|
||||
// Categorized emoji selector popup with search and recents.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${EmojiPicker} open=${true} onSelect=${fn} onClose=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useRef, useEffect, useCallback, useMemo } = window.hooks;
|
||||
|
||||
// ── Emoji data (compact) ─────────────────
|
||||
// Categories with popular emoji — not a full Unicode dump.
|
||||
// Keeps bundle small. Each entry is just the emoji character.
|
||||
const CATEGORIES = [
|
||||
{ key: 'recent', icon: '🕐', label: 'Recent', emoji: [] },
|
||||
{ key: 'smileys', icon: '😀', label: 'Smileys & People', emoji: [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊',
|
||||
'😇','🥰','😍','🤩','😘','😋','😛','😜','🤪','😝',
|
||||
'🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄',
|
||||
'😬','😮💨','🤥','😌','😔','😪','🤤','😴','😷','🤒',
|
||||
'🤕','🤢','🤮','🥵','🥶','🥴','😵','🤯','🤠','🥳',
|
||||
'🥸','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲',
|
||||
'😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱',
|
||||
'😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠',
|
||||
'🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻',
|
||||
'👽','👾','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌',
|
||||
'🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕',
|
||||
'👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌',
|
||||
'👐','🤲','🤝','🙏',
|
||||
]},
|
||||
{ key: 'nature', icon: '🌿', label: 'Nature', emoji: [
|
||||
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯',
|
||||
'🦁','🐮','🐷','🐸','🐵','🐔','🐧','🐦','🐤','🦆',
|
||||
'🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋',
|
||||
'🐌','🐞','🐜','🪲','🪳','🦟','🦗','🕷️','🌸','💐',
|
||||
'🌷','🌹','🥀','🌺','🌻','🌼','🌱','🌲','🌳','🌴',
|
||||
'🌵','🍀','🍁','🍂','🍃','🍄',
|
||||
]},
|
||||
{ key: 'food', icon: '🍔', label: 'Food & Drink', emoji: [
|
||||
'🍇','🍈','🍉','🍊','🍋','🍌','🍍','🥭','🍎','🍐',
|
||||
'🍑','🍒','🍓','🫐','🥝','🍅','🫒','🥥','🥑','🍆',
|
||||
'🥔','🥕','🌽','🌶️','🫑','🥒','🥬','🧄','🧅','🍄',
|
||||
'🥜','🫘','🌰','🍞','🥐','🥖','🫓','🥨','🥯','🥞',
|
||||
'🧇','🧀','🍖','🍗','🥩','🥓','🍔','🍟','🍕','🌭',
|
||||
'🥪','🌮','🌯','🫔','🥙','🧆','🥚','🍳','🥘','🍲',
|
||||
'🫕','🥣','🥗','🍿','🧈','🍱','🍘','🍙','🍚','🍛',
|
||||
'🍜','🍝','🍠','🍢','🍣','🍤','🍥','🥮','🍡','🥟',
|
||||
'🥠','🥡','☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂',
|
||||
'🍷','🥃','🍸','🍹','🧉','🍾','🫗',
|
||||
]},
|
||||
{ key: 'activity', icon: '⚽', label: 'Activities', emoji: [
|
||||
'⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱',
|
||||
'🏓','🏸','🏒','🥅','⛳','🏹','🎣','🤿','🥊','🥋',
|
||||
'🎿','⛷️','🏂','🪂','🏋️','🤸','🤺','⛹️','🤾','🏌️',
|
||||
'🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚴','🚵','🎪',
|
||||
'🎭','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎷','🎺',
|
||||
'🎸','🪕','🎲','🎯','🎳','🎮','🕹️','🧩',
|
||||
]},
|
||||
{ key: 'objects', icon: '💡', label: 'Objects', emoji: [
|
||||
'⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','🖲️','💾','💿',
|
||||
'📷','📹','🎥','📽️','🎞️','📞','📟','📠','📺','📻',
|
||||
'🎙️','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡',
|
||||
'🔦','🕯️','🪔','🧯','🛢️','💸','💵','💴','💶','💷',
|
||||
'🪙','💰','💳','💎','⚖️','🪜','🧰','🪛','🔧','🔨',
|
||||
'⛏️','🪚','🔩','⚙️','🪤','🧱','⛓️','🧲','🔫','💣',
|
||||
'🪓','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🏺','🔮','📿',
|
||||
'🧿','💈','⚗️','🔭','🔬','🕳️','🩹','🩺','💊','💉',
|
||||
'🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🪠','🧺','🧻',
|
||||
]},
|
||||
{ key: 'symbols', icon: '❤️', label: 'Symbols', emoji: [
|
||||
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔',
|
||||
'❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️',
|
||||
'✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐',
|
||||
'⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐',
|
||||
'♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳',
|
||||
'🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️',
|
||||
'㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️',
|
||||
'🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️',
|
||||
'🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❓','‼️',
|
||||
'⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️',
|
||||
'✅','🈯','💹','❎','🌐','💠','Ⓜ️','🌀','💤','🏧',
|
||||
'🎦','🈁','🔣','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙',
|
||||
'🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣',
|
||||
'7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️',
|
||||
'⏹️','⏺️','⏭️','⏮️','⏩','⏪','🔀','🔁','🔂','◀️',
|
||||
'🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️',
|
||||
'↕️','↔️','↩️','↪️','⤴️','⤵️','🔃','🔄','🔙','🔚',
|
||||
'🔛','🔜','🔝',
|
||||
]},
|
||||
];
|
||||
|
||||
const RECENTS_KEY = 'sw-emoji-recent';
|
||||
const MAX_RECENTS = 24;
|
||||
|
||||
function _loadRecents() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function _saveRecent(emoji) {
|
||||
const recents = _loadRecents().filter(e => e !== emoji);
|
||||
recents.unshift(emoji);
|
||||
if (recents.length > MAX_RECENTS) recents.length = MAX_RECENTS;
|
||||
try { localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); } catch (_) {}
|
||||
return recents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ open: boolean, onSelect: (emoji: string) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function EmojiPicker({ open, onSelect, onClose }) {
|
||||
const [activeCategory, setActiveCategory] = useState('smileys');
|
||||
const [search, setSearch] = useState('');
|
||||
const [recents, setRecents] = useState(_loadRecents);
|
||||
const searchRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (open && searchRef.current) {
|
||||
setTimeout(() => searchRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onKey(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
}
|
||||
document.addEventListener('keydown', _onKey, true);
|
||||
return () => document.removeEventListener('keydown', _onKey, true);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
||||
return () => document.removeEventListener('click', _onClick);
|
||||
}, [open, onClose]);
|
||||
|
||||
const _pick = useCallback((emoji) => {
|
||||
setRecents(_saveRecent(emoji));
|
||||
onSelect(emoji);
|
||||
}, [onSelect]);
|
||||
|
||||
// Build active emoji list
|
||||
const activeEmoji = useMemo(() => {
|
||||
if (search) {
|
||||
// Flat search across all categories (emoji chars don't have searchable names,
|
||||
// so this just filters for exact substring — limited but functional)
|
||||
const all = CATEGORIES.flatMap(c => c.emoji);
|
||||
return all.filter(e => e.includes(search));
|
||||
}
|
||||
if (activeCategory === 'recent') return recents;
|
||||
const cat = CATEGORIES.find(c => c.key === activeCategory);
|
||||
return cat ? cat.emoji : [];
|
||||
}, [activeCategory, search, recents]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-emoji-picker" ref=${panelRef} role="dialog" aria-label="Emoji picker">
|
||||
<div class="sw-emoji-picker__tabs">
|
||||
${CATEGORIES.map(c => html`
|
||||
<button key=${c.key}
|
||||
class=${'sw-emoji-picker__tab' + (activeCategory === c.key ? ' sw-emoji-picker__tab--active' : '')}
|
||||
title=${c.label}
|
||||
onClick=${() => { setActiveCategory(c.key); setSearch(''); }}
|
||||
type="button">
|
||||
${c.icon}
|
||||
</button>`)}
|
||||
</div>
|
||||
<input ref=${searchRef}
|
||||
class="sw-emoji-picker__search"
|
||||
type="text"
|
||||
placeholder="Search emoji..."
|
||||
value=${search}
|
||||
onInput=${e => setSearch(e.target.value)}
|
||||
aria-label="Search emoji" />
|
||||
<div class="sw-emoji-picker__grid" role="listbox">
|
||||
${activeEmoji.length === 0 && html`
|
||||
<div class="sw-emoji-picker__empty">
|
||||
${activeCategory === 'recent' ? 'No recent emoji' : 'No results'}
|
||||
</div>`}
|
||||
${activeEmoji.map(e => html`
|
||||
<button key=${e}
|
||||
class="sw-emoji-picker__emoji"
|
||||
onClick=${() => _pick(e)}
|
||||
role="option"
|
||||
type="button"
|
||||
title=${e}>
|
||||
${e}
|
||||
</button>`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
// 1-on-1 chat experience. Surfaces that need a different
|
||||
// layout import individual pieces instead.
|
||||
//
|
||||
// v0.37.10: Error banner, stop button, regenerate, pagination,
|
||||
// markdown toolbar, emoji picker, code block copy.
|
||||
//
|
||||
// Usage:
|
||||
// import { ChatPane } from './components/chat-pane/index.js';
|
||||
// html`<${ChatPane} handleRef=${ref} standalone=${true} getContext=${fn} />`
|
||||
@@ -20,8 +23,12 @@ import { MessageInput } from './message-input.js';
|
||||
import { ModelSelector } from './model-selector.js';
|
||||
import { ChatHistory } from './chat-history.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
@@ -70,6 +77,8 @@ export function ChatPane(props) {
|
||||
}
|
||||
});
|
||||
|
||||
const _dismissError = useCallback(() => chat.clearError(), [chat.clearError]);
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
|
||||
${standalone && html`
|
||||
@@ -84,23 +93,41 @@ export function ChatPane(props) {
|
||||
onChange=${chat.setModel} />
|
||||
</div>
|
||||
</div>`}
|
||||
${chat.error && html`
|
||||
<div class="sw-chat-pane__error" role="alert">
|
||||
<span class="sw-chat-pane__error-text">${chat.error}</span>
|
||||
<button class="sw-chat-pane__error-dismiss"
|
||||
onClick=${_dismissError}
|
||||
aria-label="Dismiss error">\u00d7</button>
|
||||
</div>`}
|
||||
<${MessageList}
|
||||
messages=${chat.messages}
|
||||
streamContent=${chat.streamContent}
|
||||
streaming=${chat.streaming} />
|
||||
streaming=${chat.streaming}
|
||||
hasMore=${chat.hasMore}
|
||||
loadingMore=${chat.loadingMore}
|
||||
onLoadMore=${chat.loadMore}
|
||||
onRegenerate=${chat.regenerate}
|
||||
onDelete=${chat.deleteMessage} />
|
||||
<${MessageInput}
|
||||
handleRef=${inputHandle}
|
||||
onSend=${chat.sendMessage}
|
||||
disabled=${chat.sending} />
|
||||
onStop=${chat.stop}
|
||||
disabled=${chat.sending}
|
||||
streaming=${chat.streaming} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Kit re-exports ─────────────────────────
|
||||
export { useChat } from './use-chat.js';
|
||||
export { useStream } from './use-stream.js';
|
||||
export { MessageList } from './message-list.js';
|
||||
export { MessageBubble } from './message-bubble.js';
|
||||
export { MessageInput } from './message-input.js';
|
||||
export { ModelSelector } from './model-selector.js';
|
||||
export { ChatHistory } from './chat-history.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { useChat } from './use-chat.js';
|
||||
export { useStream } from './use-stream.js';
|
||||
export { MessageList } from './message-list.js';
|
||||
export { MessageBubble } from './message-bubble.js';
|
||||
export { MessageInput } from './message-input.js';
|
||||
export { ModelSelector } from './model-selector.js';
|
||||
export { ChatHistory } from './chat-history.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
export { MessageActions } from './message-actions.js';
|
||||
export { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
export { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
110
src/js/sw/components/chat-pane/markdown-toolbar.js
Normal file
110
src/js/sw/components/chat-pane/markdown-toolbar.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MarkdownToolbar Component
|
||||
// ==========================================
|
||||
// Formatting toolbar for the message input.
|
||||
// Wraps selection with markdown syntax. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MarkdownToolbar} textareaRef=${ref} onInput=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* Insert markdown wrapper around the textarea's selection.
|
||||
*
|
||||
* @param {HTMLTextAreaElement} ta
|
||||
* @param {string} before — prefix (e.g. '**')
|
||||
* @param {string} after — suffix (e.g. '**')
|
||||
* @param {string} placeholder — text if nothing selected
|
||||
* @param {Function} onInput — trigger resize after edit
|
||||
*/
|
||||
function _wrap(ta, before, after, placeholder, onInput) {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end) || placeholder;
|
||||
const replacement = before + selected + after;
|
||||
|
||||
// Use execCommand for undo support, fallback to direct edit
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, replacement);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
|
||||
}
|
||||
|
||||
// Select the inserted text (without wrappers) for quick overtype
|
||||
const selStart = start + before.length;
|
||||
const selEnd = selStart + selected.length;
|
||||
ta.setSelectionRange(selStart, selEnd);
|
||||
if (onInput) onInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at cursor (no wrapping).
|
||||
*/
|
||||
function _insert(ta, text, onInput) {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, text);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
|
||||
}
|
||||
const pos = start + text.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
if (onInput) onInput();
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', fn: (ta, cb) => _wrap(ta, '**', '**', 'bold text', cb) },
|
||||
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', fn: (ta, cb) => _wrap(ta, '_', '_', 'italic text', cb), style: 'font-style:italic' },
|
||||
{ key: 'code', label: '<>', title: 'Inline code', fn: (ta, cb) => _wrap(ta, '`', '`', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:11px' },
|
||||
{ key: 'codeblk', label: '```',title: 'Code block', fn: (ta, cb) => _wrap(ta, '```\n', '\n```', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:10px' },
|
||||
{ key: 'link', label: '🔗', title: 'Link', fn: (ta, cb) => _wrap(ta, '[', '](url)', 'link text', cb) },
|
||||
{ key: 'list', label: '•', title: 'Bullet list', fn: (ta, cb) => _insert(ta, '\n- ', cb) },
|
||||
{ key: 'olist', label: '1.', title: 'Numbered list', fn: (ta, cb) => _insert(ta, '\n1. ', cb) },
|
||||
{ key: 'heading', label: 'H', title: 'Heading', fn: (ta, cb) => _insert(ta, '\n## ', cb), style: 'font-weight:700' },
|
||||
{ key: 'quote', label: '>', title: 'Blockquote', fn: (ta, cb) => _insert(ta, '\n> ', cb) },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{ textareaRef: { current: HTMLTextAreaElement }, onInput?: () => void }} props
|
||||
*/
|
||||
export function MarkdownToolbar({ textareaRef, onInput }) {
|
||||
return html`
|
||||
<div class="sw-md-toolbar" role="toolbar" aria-label="Formatting">
|
||||
${ACTIONS.map(a => html`
|
||||
<button key=${a.key}
|
||||
class="sw-md-toolbar__btn"
|
||||
title=${a.title}
|
||||
style=${a.style || ''}
|
||||
onClick=${() => a.fn(textareaRef.current, onInput)}
|
||||
tabindex="-1"
|
||||
type="button">
|
||||
${a.label}
|
||||
</button>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard shortcuts for formatting.
|
||||
* Call from textarea onKeyDown handler.
|
||||
*
|
||||
* @param {KeyboardEvent} e
|
||||
* @param {HTMLTextAreaElement} ta
|
||||
* @param {Function} onInput
|
||||
* @returns {boolean} true if handled
|
||||
*/
|
||||
export function handleFormatShortcut(e, ta, onInput) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return false;
|
||||
switch (e.key) {
|
||||
case 'b': e.preventDefault(); _wrap(ta, '**', '**', 'bold', onInput); return true;
|
||||
case 'i': e.preventDefault(); _wrap(ta, '_', '_', 'italic', onInput); return true;
|
||||
case 'e': e.preventDefault(); _wrap(ta, '`', '`', 'code', onInput); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
// Wraps window.marked + window.DOMPurify with fallback.
|
||||
// Independently importable utility.
|
||||
//
|
||||
// v0.37.10: Task list rendering, code block language extraction.
|
||||
//
|
||||
// Usage:
|
||||
// import { renderMarkdown } from './markdown.js';
|
||||
// const html = renderMarkdown('**bold** text');
|
||||
@@ -17,6 +19,37 @@ function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure marked with task list support if available.
|
||||
*/
|
||||
let _configured = false;
|
||||
function _ensureConfigured() {
|
||||
if (_configured) return;
|
||||
_configured = true;
|
||||
if (typeof marked === 'undefined') return;
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Custom renderer for task list items
|
||||
const renderer = new marked.Renderer();
|
||||
const origListItem = renderer.listitem?.bind(renderer);
|
||||
renderer.listitem = function(text, task, checked) {
|
||||
if (task) {
|
||||
const checkbox = checked
|
||||
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
|
||||
: '<input type="checkbox" disabled class="sw-task-checkbox" />';
|
||||
return '<li class="sw-task-item">' + checkbox + ' ' + text + '</li>\n';
|
||||
}
|
||||
if (origListItem) return origListItem(text, task, checked);
|
||||
return '<li>' + text + '</li>\n';
|
||||
};
|
||||
|
||||
marked.use({ renderer });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown text to sanitized HTML.
|
||||
* Falls back to escaped plaintext when marked/DOMPurify unavailable.
|
||||
@@ -30,6 +63,7 @@ export function renderMarkdown(text) {
|
||||
|
||||
let result;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
_ensureConfigured();
|
||||
result = DOMPurify.sanitize(marked.parse(text));
|
||||
} else {
|
||||
result = _escapeHtml(text);
|
||||
|
||||
91
src/js/sw/components/chat-pane/message-actions.js
Normal file
91
src/js/sw/components/chat-pane/message-actions.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageActions Component
|
||||
// ==========================================
|
||||
// Per-message action toolbar: regenerate, copy, delete.
|
||||
// Appears on hover/focus. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageActions} role="assistant" content=${text}
|
||||
// onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
const REGEN_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>`;
|
||||
|
||||
const COPY_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHECK_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>`;
|
||||
|
||||
const DELETE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* onRegenerate?: () => void,
|
||||
* onDelete?: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageActions({ role, content, onRegenerate, onDelete }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content || '');
|
||||
} catch (_) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = content || '';
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [content]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-msg__actions" role="toolbar" aria-label="Message actions">
|
||||
${role === 'assistant' && onRegenerate && html`
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title="Regenerate"
|
||||
onClick=${onRegenerate}
|
||||
aria-label="Regenerate response">
|
||||
${REGEN_SVG}
|
||||
</button>`}
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title=${copied ? 'Copied!' : 'Copy'}
|
||||
onClick=${_copy}
|
||||
aria-label="Copy message text">
|
||||
${copied ? CHECK_SVG : COPY_SVG}
|
||||
</button>
|
||||
${onDelete && html`
|
||||
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
|
||||
title="Delete"
|
||||
onClick=${onDelete}
|
||||
aria-label="Delete message">
|
||||
${DELETE_SVG}
|
||||
</button>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,35 +1,79 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageBubble Component
|
||||
// ==========================================
|
||||
// Single message rendering with role styling and markdown.
|
||||
// Single message rendering with role styling, markdown,
|
||||
// action toolbar, and enhanced code blocks.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added message actions, timestamps, code block copy.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageBubble} role="assistant" content=${text} />`
|
||||
// html`<${MessageBubble} role="assistant" content=${text}
|
||||
// onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useMemo } = window.hooks;
|
||||
|
||||
/**
|
||||
* @param {{ role: string, content: string, streaming?: boolean }} props
|
||||
* Format a relative time string from an ISO date.
|
||||
*/
|
||||
export function MessageBubble({ role, content, streaming }) {
|
||||
function _relTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
try {
|
||||
const d = new Date(isoDate);
|
||||
const now = Date.now();
|
||||
const sec = Math.floor((now - d.getTime()) / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* id?: string,
|
||||
* created_at?: string,
|
||||
* streaming?: boolean,
|
||||
* onRegenerate?: () => void,
|
||||
* onDelete?: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageBubble({ role, content, id, created_at, streaming, onRegenerate, onDelete }) {
|
||||
const cls = 'sw-chat-msg sw-chat-msg--' + role
|
||||
+ (streaming ? ' sw-chat-msg--streaming' : '');
|
||||
|
||||
if (role === 'user') {
|
||||
// User messages use escaped text (no markdown)
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<div class="sw-chat-msg__content"
|
||||
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
|
||||
</div>`;
|
||||
}
|
||||
// Parse markdown and extract code blocks for enhanced rendering
|
||||
const segments = useMemo(() => {
|
||||
const rendered = renderMarkdown(content || '');
|
||||
return extractCodeBlocks(rendered);
|
||||
}, [content]);
|
||||
|
||||
const timeStr = _relTime(created_at);
|
||||
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<div class="sw-chat-msg__content"
|
||||
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
|
||||
<div class="sw-chat-msg__content">
|
||||
${segments.map((seg, i) =>
|
||||
seg.type === 'code'
|
||||
? html`<${CodeBlock} key=${i} code=${seg.code} language=${seg.language} />`
|
||||
: html`<span key=${i} dangerouslySetInnerHTML=${{ __html: seg.html }} />`
|
||||
)}
|
||||
</div>
|
||||
${!streaming && html`
|
||||
<div class="sw-chat-msg__footer">
|
||||
${timeStr && html`<span class="sw-chat-msg__time">${timeStr}</span>`}
|
||||
<${MessageActions}
|
||||
role=${role}
|
||||
content=${content}
|
||||
onRegenerate=${onRegenerate}
|
||||
onDelete=${onDelete} />
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageInput Component
|
||||
// ==========================================
|
||||
// Auto-resize textarea with Enter-to-send.
|
||||
// Auto-resize textarea with Enter-to-send, markdown toolbar,
|
||||
// emoji picker, and stop button.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
|
||||
//
|
||||
// Usage:
|
||||
// const inputHandle = useRef(null);
|
||||
// html`<${MessageInput} onSend=${fn} disabled=${false} handleRef=${inputHandle} />`
|
||||
// inputHandle.current.focus();
|
||||
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
|
||||
// streaming=${false} handleRef=${inputHandle} />`
|
||||
|
||||
const { useRef, useCallback, useEffect } = window.hooks;
|
||||
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
const { useRef, useState, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SEND_SVG = html`
|
||||
@@ -19,11 +25,33 @@ const SEND_SVG = html`
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>`;
|
||||
|
||||
const STOP_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>`;
|
||||
|
||||
const EMOJI_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{ onSend: (text: string) => void, disabled?: boolean, handleRef?: { current: any } }} props
|
||||
* @param {{
|
||||
* onSend: (text: string) => void,
|
||||
* onStop?: () => void,
|
||||
* disabled?: boolean,
|
||||
* streaming?: boolean,
|
||||
* handleRef?: { current: any },
|
||||
* }} props
|
||||
*/
|
||||
export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef }) {
|
||||
const taRef = useRef(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
|
||||
function _autoResize() {
|
||||
const ta = taRef.current;
|
||||
@@ -40,6 +68,7 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
|
||||
focus() { taRef.current?.focus(); },
|
||||
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
|
||||
getTextarea() { return taRef.current; },
|
||||
};
|
||||
}
|
||||
return () => { if (handleRef) handleRef.current = null; };
|
||||
@@ -48,6 +77,9 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
const _onInput = useCallback(() => _autoResize(), []);
|
||||
|
||||
const _onKeyDown = useCallback((e) => {
|
||||
// Formatting shortcuts (Ctrl+B, Ctrl+I, Ctrl+E)
|
||||
if (handleFormatShortcut(e, taRef.current, _autoResize)) return;
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = taRef.current?.value?.trim();
|
||||
@@ -66,8 +98,28 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
const _insertEmoji = useCallback((emoji) => {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, emoji);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + emoji + ta.value.slice(ta.selectionEnd);
|
||||
}
|
||||
const pos = start + emoji.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
_autoResize();
|
||||
}, []);
|
||||
|
||||
const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []);
|
||||
const _closeEmoji = useCallback(() => setEmojiOpen(false), []);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__input-bar">
|
||||
<${MarkdownToolbar} textareaRef=${taRef} onInput=${_autoResize} />
|
||||
<div class="sw-msg-input__wrap">
|
||||
<textarea
|
||||
ref=${taRef}
|
||||
@@ -77,13 +129,38 @@ export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
disabled=${disabled}
|
||||
onInput=${_onInput}
|
||||
onKeyDown=${_onKeyDown}
|
||||
aria-label="Message input"
|
||||
/>
|
||||
<button
|
||||
class="sw-msg-input__send"
|
||||
title="Send"
|
||||
disabled=${disabled}
|
||||
onClick=${_onClickSend}
|
||||
>${SEND_SVG}</button>
|
||||
<div class="sw-msg-input__buttons">
|
||||
<div class="sw-msg-input__emoji-wrap">
|
||||
<button
|
||||
class="sw-msg-input__emoji-btn"
|
||||
title="Emoji"
|
||||
onClick=${_toggleEmoji}
|
||||
type="button"
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded=${emojiOpen}
|
||||
>${EMOJI_SVG}</button>
|
||||
<${EmojiPicker}
|
||||
open=${emojiOpen}
|
||||
onSelect=${_insertEmoji}
|
||||
onClose=${_closeEmoji} />
|
||||
</div>
|
||||
${streaming && onStop
|
||||
? html`<button
|
||||
class="sw-msg-input__stop"
|
||||
title="Stop generating"
|
||||
onClick=${onStop}
|
||||
aria-label="Stop generating"
|
||||
>${STOP_SVG}</button>`
|
||||
: html`<button
|
||||
class="sw-msg-input__send"
|
||||
title="Send"
|
||||
disabled=${disabled}
|
||||
onClick=${_onClickSend}
|
||||
aria-label="Send message"
|
||||
>${SEND_SVG}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageList Component
|
||||
// ==========================================
|
||||
// Scrollable message list with auto-scroll-to-bottom.
|
||||
// Scrollable message list with auto-scroll-to-bottom
|
||||
// and "load older" pagination.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added pagination (load more), regenerate/delete wiring.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageList} messages=${msgs} streamContent=${text} streaming=${true} />`
|
||||
// html`<${MessageList} messages=${msgs} streamContent=${text}
|
||||
// streaming=${true} hasMore=${true} loadingMore=${false}
|
||||
// onLoadMore=${fn} onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
import { MessageBubble } from './message-bubble.js';
|
||||
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SCROLL_THRESHOLD = 60;
|
||||
|
||||
/**
|
||||
* @param {{ messages: Array<{role: string, content: string}>, streamContent?: string, streaming?: boolean, className?: string }} props
|
||||
* @param {{
|
||||
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
|
||||
* streamContent?: string,
|
||||
* streaming?: boolean,
|
||||
* hasMore?: boolean,
|
||||
* loadingMore?: boolean,
|
||||
* onLoadMore?: () => void,
|
||||
* onRegenerate?: (msgId: string) => void,
|
||||
* onDelete?: (msgId: string) => void,
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageList({ messages, streamContent, streaming, className }) {
|
||||
export function MessageList({
|
||||
messages, streamContent, streaming, hasMore, loadingMore,
|
||||
onLoadMore, onRegenerate, onDelete, className,
|
||||
}) {
|
||||
const scrollRef = useRef(null);
|
||||
const wasAtBottom = useRef(true);
|
||||
const prevScrollHeightRef = useRef(0);
|
||||
|
||||
// Track scroll position
|
||||
function _onScroll() {
|
||||
@@ -36,19 +55,50 @@ export function MessageList({ messages, streamContent, streaming, className }) {
|
||||
}
|
||||
}, [messages, streamContent]);
|
||||
|
||||
// Preserve scroll position when prepending older messages
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const prevH = prevScrollHeightRef.current;
|
||||
if (prevH && el.scrollHeight > prevH && !wasAtBottom.current) {
|
||||
// Older messages were prepended — maintain relative position
|
||||
el.scrollTop += (el.scrollHeight - prevH);
|
||||
}
|
||||
prevScrollHeightRef.current = el.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
const hasContent = messages.length > 0 || streaming;
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
|
||||
ref=${scrollRef}
|
||||
onScroll=${_onScroll}>
|
||||
onScroll=${_onScroll}
|
||||
role="log"
|
||||
aria-label="Chat messages"
|
||||
aria-live="polite">
|
||||
${hasMore && html`
|
||||
<div class="sw-chat-pane__load-more">
|
||||
<button class="sw-chat-pane__load-more-btn"
|
||||
onClick=${onLoadMore}
|
||||
disabled=${loadingMore}
|
||||
aria-label="Load older messages">
|
||||
${loadingMore ? 'Loading\u2026' : 'Load older messages'}
|
||||
</button>
|
||||
</div>`}
|
||||
${!hasContent && html`
|
||||
<div class="sw-chat-welcome">
|
||||
<p class="sw-chat-welcome__title">Start a conversation</p>
|
||||
<p class="sw-chat-welcome__hint">Type a message below to get started.</p>
|
||||
</div>`}
|
||||
${messages.map((m, i) => html`
|
||||
<${MessageBubble} key=${m.id || i} role=${m.role} content=${m.content} />`)}
|
||||
<${MessageBubble}
|
||||
key=${m.id || i}
|
||||
role=${m.role}
|
||||
content=${m.content}
|
||||
id=${m.id}
|
||||
created_at=${m.created_at}
|
||||
onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined}
|
||||
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`)}
|
||||
${streaming && html`
|
||||
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
|
||||
</div>`;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Core chat state machine: messages, channel CRUD, send/receive,
|
||||
// model selection. Independently importable.
|
||||
//
|
||||
// v0.37.10: Added regenerate(), deleteMessage(), pagination.
|
||||
//
|
||||
// Usage:
|
||||
// const chat = useChat({ onChannelChange });
|
||||
// chat.sendMessage('Hello');
|
||||
@@ -12,6 +14,8 @@ import { useStream } from './use-stream.js';
|
||||
|
||||
const { useState, useRef, useCallback } = window.hooks;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
|
||||
*/
|
||||
@@ -23,21 +27,22 @@ export function useChat(opts = {}) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [model, setModel] = useState('');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const channelRef = useRef(channelId);
|
||||
const pageRef = useRef(1);
|
||||
const { streamContent, streaming, startStream, stopStream } = useStream();
|
||||
|
||||
// Keep ref in sync
|
||||
channelRef.current = channelId;
|
||||
|
||||
// ── Init model from first enabled ──────
|
||||
// Deferred — set on first render if not already set
|
||||
// ── Init model from session ─────────────
|
||||
if (!model && window.sw?.auth?.session?.settings?.model) {
|
||||
// Will be picked up on next render cycle via setModel
|
||||
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
|
||||
}
|
||||
|
||||
// ── Send Message ───────────────────────
|
||||
// ── Send Message ────────────────────────
|
||||
const sendMessage = useCallback(async (text) => {
|
||||
if (!text.trim() || sending) return;
|
||||
setSending(true);
|
||||
@@ -78,9 +83,6 @@ export function useChat(opts = {}) {
|
||||
const data = { channel_id: cid, content, model };
|
||||
const resp = await window.sw.api.channels.complete(data);
|
||||
await startStream(resp);
|
||||
|
||||
// After stream completes, append assistant message
|
||||
// (streamContent will have the final text via useStream)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Error: ' + e.message);
|
||||
@@ -94,15 +96,54 @@ export function useChat(opts = {}) {
|
||||
const prevStreamingRef = useRef(false);
|
||||
if (prevStreamingRef.current && !streaming && streamContent) {
|
||||
prevStreamingRef.current = false;
|
||||
// Use functional update to avoid stale closure
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: streamContent }]);
|
||||
}
|
||||
prevStreamingRef.current = streaming;
|
||||
|
||||
// ── Switch Channel ─────────────────────
|
||||
// ── Regenerate ──────────────────────────
|
||||
const regenerate = useCallback(async (msgId) => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || !msgId || sending) return;
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model });
|
||||
// Remove the old assistant message being regenerated
|
||||
setMessages(prev => {
|
||||
const idx = prev.findIndex(m => m.id === msgId);
|
||||
return idx >= 0 ? prev.slice(0, idx) : prev;
|
||||
});
|
||||
await startStream(resp);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Regenerate failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, model, startStream]);
|
||||
|
||||
// ── Delete Message ──────────────────────
|
||||
const deleteMessage = useCallback(async (msgId) => {
|
||||
// Optimistic remove from UI
|
||||
setMessages(prev => prev.filter(m => m.id !== msgId));
|
||||
|
||||
// If API supports delete, fire it (no-op if not available)
|
||||
if (window.sw?.api?.channels?.deleteMessage) {
|
||||
try {
|
||||
await window.sw.api.channels.deleteMessage(channelRef.current, msgId);
|
||||
} catch (_) { /* swallow — already removed from UI */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Switch Channel ──────────────────────
|
||||
const switchChannel = useCallback(async (id) => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setHasMore(false);
|
||||
pageRef.current = 1;
|
||||
channelRef.current = id;
|
||||
setChannelId(id);
|
||||
if (onChannelChange) onChannelChange(id);
|
||||
@@ -110,30 +151,60 @@ export function useChat(opts = {}) {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: 100 });
|
||||
setMessages(resp?.data || resp || []);
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
|
||||
const data = resp?.data || resp || [];
|
||||
setMessages(data);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} catch (e) {
|
||||
setError('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}, [onChannelChange]);
|
||||
|
||||
// ── New Chat ───────────────────────────
|
||||
// ── Load More (pagination) ──────────────
|
||||
const loadMore = useCallback(async () => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const nextPage = pageRef.current + 1;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE });
|
||||
const data = resp?.data || resp || [];
|
||||
if (data.length > 0) {
|
||||
pageRef.current = nextPage;
|
||||
// Prepend older messages
|
||||
setMessages(prev => [...data, ...prev]);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load older messages: ' + e.message);
|
||||
}
|
||||
|
||||
setLoadingMore(false);
|
||||
}, [loadingMore, hasMore]);
|
||||
|
||||
// ── New Chat ────────────────────────────
|
||||
const newChat = useCallback(() => {
|
||||
stopStream();
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setHasMore(false);
|
||||
pageRef.current = 1;
|
||||
channelRef.current = null;
|
||||
setChannelId(null);
|
||||
if (onChannelChange) onChannelChange(null);
|
||||
}, [stopStream, onChannelChange]);
|
||||
|
||||
// ── Stop ───────────────────────────────
|
||||
// ── Stop ────────────────────────────────
|
||||
const stop = useCallback(() => {
|
||||
stopStream();
|
||||
setSending(false);
|
||||
}, [stopStream]);
|
||||
|
||||
// ── Clear Error ────────────────────────
|
||||
// ── Clear Error ─────────────────────────
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return {
|
||||
@@ -151,5 +222,11 @@ export function useChat(opts = {}) {
|
||||
// Streaming state (passthrough from useStream)
|
||||
streamContent,
|
||||
streaming,
|
||||
// v0.37.10: new methods
|
||||
regenerate,
|
||||
deleteMessage,
|
||||
loadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
* RBAC-gated items based on sw.can() / sw.isAdmin / sw.isTeamAdmin().
|
||||
* Surfaces mount this wherever they want; the shell does not own placement.
|
||||
*
|
||||
* Surface links (Notes, extensions) are auto-included and the CURRENT
|
||||
* surface is filtered out so every surface gets the same menu minus itself.
|
||||
*
|
||||
* Props:
|
||||
* placement — Menu direction: 'down-right' (default) | 'down-left'
|
||||
* onAction — Override handler; if omitted, uses default routing
|
||||
* placement — Menu direction: 'down-right' (default) | 'up-right' etc.
|
||||
* onAction — Override handler; if omitted, navigates via location.href
|
||||
* extraItems — Additional menu items inserted after surface links
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useMemo } = hooks;
|
||||
@@ -14,7 +18,14 @@ const { useState, useRef, useMemo } = hooks;
|
||||
import { Avatar } from '../primitives/avatar.js';
|
||||
import { Menu } from '../primitives/menu.js';
|
||||
|
||||
export function UserMenu({ placement = 'down-right', onAction }) {
|
||||
/**
|
||||
* Detect current surface from body[data-surface].
|
||||
*/
|
||||
function _currentSurface() {
|
||||
return document.body?.dataset?.surface || '';
|
||||
}
|
||||
|
||||
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
@@ -23,18 +34,51 @@ export function UserMenu({ placement = 'down-right', onAction }) {
|
||||
const authenticated = sw?.auth?.isAuthenticated;
|
||||
|
||||
const items = useMemo(() => {
|
||||
const list = [
|
||||
{ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' },
|
||||
const current = _currentSurface();
|
||||
const list = [];
|
||||
|
||||
// ── Surface links (auto-filtered) ──────
|
||||
// Each surface that exists gets a menu item, except the current one.
|
||||
const surfaces = [
|
||||
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' },
|
||||
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
|
||||
];
|
||||
|
||||
// Add extension surfaces from page data if available
|
||||
const extSurfaces = window.__EXTENSION_SURFACES__ || [];
|
||||
for (const ext of extSurfaces) {
|
||||
surfaces.push({ key: ext.id || ext.route, label: ext.title, icon: '\ud83e\udde9' });
|
||||
}
|
||||
|
||||
const surfaceItems = surfaces
|
||||
.filter(s => s.key !== current)
|
||||
.map(s => ({ label: s.label, action: s.key, icon: s.icon }));
|
||||
|
||||
if (surfaceItems.length) {
|
||||
list.push(...surfaceItems);
|
||||
list.push({ divider: true });
|
||||
}
|
||||
|
||||
// ── Extra items from caller ────────────
|
||||
if (extraItems && extraItems.length) {
|
||||
list.push(...extraItems);
|
||||
list.push({ divider: true });
|
||||
}
|
||||
|
||||
// ── Standard items ─────────────────────
|
||||
// Settings — skip if current surface IS settings
|
||||
if (current !== 'settings') {
|
||||
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
||||
}
|
||||
|
||||
// Admin — requires admin role
|
||||
if (!authenticated || sw?.isAdmin) {
|
||||
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
|
||||
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
|
||||
}
|
||||
|
||||
// Team Admin — requires admin role on at least one team
|
||||
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
||||
if (!authenticated || hasTeamAdmin) {
|
||||
if ((!authenticated || hasTeamAdmin) && current !== 'team-admin') {
|
||||
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
||||
}
|
||||
|
||||
@@ -47,15 +91,27 @@ export function UserMenu({ placement = 'down-right', onAction }) {
|
||||
list.push({ label: 'Sign Out', action: 'sign-out' });
|
||||
|
||||
return list;
|
||||
}, [user, authenticated]);
|
||||
}, [user, authenticated, extraItems]);
|
||||
|
||||
function handleSelect(action) {
|
||||
setOpen(false);
|
||||
if (onAction) return onAction(action);
|
||||
if (action === 'sign-out') {
|
||||
sw?.auth?.logout();
|
||||
} else {
|
||||
sw?.emit('shell.navigate', { target: action });
|
||||
|
||||
// Default routing
|
||||
const BASE = window.__BASE__ || '';
|
||||
switch (action) {
|
||||
case 'sign-out':
|
||||
window.sw?.auth?.logout();
|
||||
break;
|
||||
case 'debug':
|
||||
const dbg = document.getElementById('debugModal');
|
||||
if (dbg) dbg.classList.toggle('active');
|
||||
break;
|
||||
case 'chat':
|
||||
location.href = BASE + '/';
|
||||
break;
|
||||
default:
|
||||
location.href = BASE + '/' + action;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
src/js/sw/surfaces/chat/chat-workspace.js
Normal file
66
src/js/sw/surfaces/chat/chat-workspace.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// ==========================================
|
||||
// Chat Surface — ChatWorkspace Component
|
||||
// ==========================================
|
||||
// Header bar (model selector, tools, sidebar toggle) + ChatPane.
|
||||
// ChatPane runs standalone=false — the surface manages navigation.
|
||||
|
||||
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
|
||||
const PANEL_SVG = html`
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* activeId: string|null,
|
||||
* activeType: 'chat'|'channel'|null,
|
||||
* onChannelChange: (id: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* sidebarOpen: boolean,
|
||||
* onToggleSidebar: () => void,
|
||||
* toolsButton?: any,
|
||||
* }} props
|
||||
*/
|
||||
export function ChatWorkspace({
|
||||
activeId, activeType, onChannelChange, onNewChat,
|
||||
sidebarOpen, onToggleSidebar, toolsButton,
|
||||
}) {
|
||||
const chatRef = useRef(null);
|
||||
|
||||
// When activeId changes, tell ChatPane to switch channel
|
||||
useEffect(() => {
|
||||
if (chatRef.current) {
|
||||
chatRef.current.setChannel(activeId);
|
||||
}
|
||||
}, [activeId]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__workspace">
|
||||
<div class="sw-chat-surface__workspace-header">
|
||||
<div class="sw-chat-surface__workspace-header-left">
|
||||
${!sidebarOpen && html`
|
||||
<button class="sw-chat-surface__sidebar-toggle"
|
||||
onClick=${onToggleSidebar}
|
||||
title="Show sidebar"
|
||||
aria-label="Show sidebar">
|
||||
${PANEL_SVG}
|
||||
</button>`}
|
||||
</div>
|
||||
<div class="sw-chat-surface__workspace-header-right">
|
||||
${toolsButton}
|
||||
</div>
|
||||
</div>
|
||||
<${ChatPane}
|
||||
channelId=${activeId}
|
||||
standalone=${false}
|
||||
handleRef=${chatRef}
|
||||
onChannelChange=${onChannelChange}
|
||||
className="sw-chat-surface__chat-pane" />
|
||||
</div>`;
|
||||
}
|
||||
115
src/js/sw/surfaces/chat/index.js
Normal file
115
src/js/sw/surfaces/chat/index.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Root Component
|
||||
// ==========================================
|
||||
// Main chat surface: sidebar + workspace.
|
||||
// Follows the settings/admin mount pattern.
|
||||
//
|
||||
// v0.37.10: New Preact surface replacing the legacy SPA.
|
||||
|
||||
import { ToastContainer } from '../../primitives/toast.js';
|
||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
import { useWorkspace } from './use-workspace.js';
|
||||
import { useSidebar } from './use-sidebar.js';
|
||||
import { Sidebar } from './sidebar.js';
|
||||
import { ChatWorkspace } from './chat-workspace.js';
|
||||
import { useTools, ToolsPopup } from './tools-popup.js';
|
||||
|
||||
const { render } = window.preact;
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
const WRENCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0
|
||||
0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1
|
||||
7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>`;
|
||||
|
||||
function ChatSurface() {
|
||||
const workspace = useWorkspace();
|
||||
const sidebar = useSidebar({ activeId: workspace.activeId });
|
||||
const tools = useTools();
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
|
||||
const _onNewChat = useCallback(() => {
|
||||
workspace.clearSelection();
|
||||
}, [workspace.clearSelection]);
|
||||
|
||||
const _onChannelChange = useCallback((id) => {
|
||||
if (id) workspace.selectChat(id);
|
||||
}, [workspace.selectChat]);
|
||||
|
||||
const _toggleSidebar = useCallback(() => {
|
||||
workspace.setSidebarOpen(!workspace.sidebarOpen);
|
||||
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
|
||||
|
||||
const _toggleTools = useCallback(() => {
|
||||
setToolsOpen(v => !v);
|
||||
}, []);
|
||||
|
||||
const _closeTools = useCallback(() => {
|
||||
setToolsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Tools button — shared between mobile bar and workspace header
|
||||
const toolsButton = html`
|
||||
<button class="sw-chat-surface__tools-btn"
|
||||
onClick=${_toggleTools}
|
||||
title="Tools"
|
||||
aria-label="Toggle tools"
|
||||
aria-expanded=${toolsOpen}>
|
||||
${WRENCH_SVG}
|
||||
${tools.disabled.size > 0 && html`
|
||||
<span class="sw-chat-surface__tools-badge">${tools.disabled.size}</span>`}
|
||||
</button>`;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface">
|
||||
${/* Mobile sidebar overlay */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<div class="sw-chat-surface__sidebar-overlay"
|
||||
onClick=${_toggleSidebar} />`}
|
||||
|
||||
${/* Sidebar */''}
|
||||
${workspace.sidebarOpen && html`
|
||||
<${Sidebar}
|
||||
sidebar=${sidebar}
|
||||
activeId=${workspace.activeId}
|
||||
onSelectChat=${workspace.selectChat}
|
||||
onSelectChannel=${workspace.selectChannel}
|
||||
onNewChat=${_onNewChat}
|
||||
onCollapse=${_toggleSidebar} />`}
|
||||
|
||||
${/* Main workspace */''}
|
||||
<div class="sw-chat-surface__main">
|
||||
<${ChatWorkspace}
|
||||
activeId=${workspace.activeId}
|
||||
activeType=${workspace.activeType}
|
||||
onChannelChange=${_onChannelChange}
|
||||
onNewChat=${_onNewChat}
|
||||
sidebarOpen=${workspace.sidebarOpen}
|
||||
onToggleSidebar=${_toggleSidebar}
|
||||
toolsButton=${toolsButton} />
|
||||
<${ToolsPopup}
|
||||
open=${toolsOpen}
|
||||
onClose=${_closeTools}
|
||||
categories=${tools.categories}
|
||||
disabled=${tools.disabled}
|
||||
onToggle=${tools.toggle}
|
||||
onToggleCategory=${tools.toggleCategory} />
|
||||
</div>
|
||||
</div>
|
||||
<${ToastContainer} />
|
||||
<${DialogStack} />
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Mount ────────────────────────────────────
|
||||
const mount = document.getElementById('chat-mount');
|
||||
if (mount) {
|
||||
render(html`<${ChatSurface} />`, mount);
|
||||
console.log('[chat] Surface mounted (v0.37.10)');
|
||||
}
|
||||
|
||||
export { ChatSurface };
|
||||
65
src/js/sw/surfaces/chat/sidebar-channels.js
Normal file
65
src/js/sw/surfaces/chat/sidebar-channels.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarChannels Component
|
||||
// ==========================================
|
||||
// Collapsible channels section: DMs and named channels.
|
||||
|
||||
const html = window.html;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
const HASH_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="9" x2="20" y2="9"/>
|
||||
<line x1="4" y1="15" x2="20" y2="15"/>
|
||||
<line x1="10" y1="3" x2="8" y2="21"/>
|
||||
<line x1="16" y1="3" x2="14" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* channels: Array<{id: string, title: string, unread_count?: number}>,
|
||||
* activeId: string|null,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarChannels({ channels, activeId, collapsed, onToggle, onSelect }) {
|
||||
if (!channels.length && collapsed) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__section">
|
||||
<button class="sw-chat-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-chat-surface__section-label">Channels</span>
|
||||
${channels.length > 0 && html`
|
||||
<span class="sw-chat-surface__section-count">${channels.length}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-chat-surface__section-body" role="listbox">
|
||||
${channels.length === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No channels</div>`}
|
||||
${channels.map(ch => html`
|
||||
<button key=${ch.id}
|
||||
class=${'sw-chat-surface__item' + (ch.id === activeId ? ' sw-chat-surface__item--active' : '')}
|
||||
onClick=${() => onSelect(ch.id)}
|
||||
role="option"
|
||||
aria-selected=${ch.id === activeId}
|
||||
title=${ch.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${HASH_SVG}</span>
|
||||
<span class="sw-chat-surface__item-title">${ch.title || 'Untitled'}</span>
|
||||
${(ch.unread_count || 0) > 0 && html`
|
||||
<span class="sw-chat-surface__unread">${ch.unread_count}</span>`}
|
||||
</button>`)}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
199
src/js/sw/surfaces/chat/sidebar-chats.js
Normal file
199
src/js/sw/surfaces/chat/sidebar-chats.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// ==========================================
|
||||
// Chat Surface — SidebarChats Component
|
||||
// ==========================================
|
||||
// Personal AI chats with folder grouping and context menus.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback, useRef, useEffect } = window.hooks;
|
||||
|
||||
const CHEVRON_SVG = html`
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>`;
|
||||
|
||||
const CHAT_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const FOLDER_SVG = html`
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>`;
|
||||
|
||||
const DOTS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* chatsByFolder: Record<string, Array>,
|
||||
* folders: Array<{id: string, name: string}>,
|
||||
* activeId: string|null,
|
||||
* collapsed: boolean,
|
||||
* onToggle: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* onRename: (id: string, title: string) => void,
|
||||
* onDelete: (id: string) => void,
|
||||
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
|
||||
* onCreateFolder: (name: string) => void,
|
||||
* onRenameFolder: (id: string, name: string) => void,
|
||||
* onDeleteFolder: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function SidebarChats({
|
||||
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
|
||||
onRename, onDelete, onMoveToFolder, onCreateFolder,
|
||||
onRenameFolder, onDeleteFolder,
|
||||
}) {
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
// Close context menu on outside click or Escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
function _close(e) {
|
||||
if (e.type === 'keydown' && e.key !== 'Escape') return;
|
||||
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
|
||||
setContextMenu(null);
|
||||
}
|
||||
document.addEventListener('click', _close);
|
||||
document.addEventListener('keydown', _close);
|
||||
return () => {
|
||||
document.removeEventListener('click', _close);
|
||||
document.removeEventListener('keydown', _close);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const _openMenu = useCallback((e, type, item) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
|
||||
}, []);
|
||||
|
||||
const _action = useCallback(async (action, item) => {
|
||||
setContextMenu(null);
|
||||
if (action === 'rename') {
|
||||
const newTitle = prompt('Rename:', item.title || item.name || '');
|
||||
if (newTitle && newTitle.trim()) {
|
||||
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
|
||||
else await onRename(item.id, newTitle.trim());
|
||||
}
|
||||
} else if (action === 'delete') {
|
||||
const label = item.name !== undefined ? 'folder' : 'chat';
|
||||
if (confirm('Delete this ' + label + '?')) {
|
||||
if (item.name !== undefined) await onDeleteFolder(item.id);
|
||||
else await onDelete(item.id);
|
||||
}
|
||||
} else if (action === 'move-out') {
|
||||
await onMoveToFolder(item.id, null);
|
||||
} else if (action === 'new-folder') {
|
||||
const name = prompt('Folder name:');
|
||||
if (name && name.trim()) await onCreateFolder(name.trim());
|
||||
} else if (action.startsWith('move-to:')) {
|
||||
const fid = action.slice(8);
|
||||
await onMoveToFolder(item.id, fid);
|
||||
}
|
||||
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
|
||||
|
||||
// Count total chats
|
||||
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
|
||||
const unfolderedChats = chatsByFolder[''] || [];
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__section">
|
||||
<button class="sw-chat-surface__section-header"
|
||||
onClick=${onToggle}
|
||||
aria-expanded=${!collapsed}>
|
||||
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
||||
${CHEVRON_SVG}
|
||||
</span>
|
||||
<span class="sw-chat-surface__section-label">Chats</span>
|
||||
${totalChats > 0 && html`
|
||||
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
|
||||
</button>
|
||||
${!collapsed && html`
|
||||
<div class="sw-chat-surface__section-body" role="listbox">
|
||||
${/* Folders first */''}
|
||||
${folders.map(folder => {
|
||||
const folderChats = chatsByFolder[folder.id] || [];
|
||||
return html`
|
||||
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
|
||||
<div class="sw-chat-surface__folder-header">
|
||||
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
|
||||
<span class="sw-chat-surface__folder-name">${folder.name}</span>
|
||||
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => _openMenu(e, 'folder', folder)}
|
||||
title="Folder actions"
|
||||
aria-label="Folder actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</div>
|
||||
${folderChats.map(ch => html`
|
||||
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu}
|
||||
indent=${true} />`)}
|
||||
</div>`;
|
||||
})}
|
||||
${/* Unfoldered chats */''}
|
||||
${unfolderedChats.map(ch => html`
|
||||
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
||||
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
|
||||
${totalChats === 0 && html`
|
||||
<div class="sw-chat-surface__empty">No chats yet</div>`}
|
||||
</div>`}
|
||||
|
||||
${/* Context Menu */''}
|
||||
${contextMenu && html`
|
||||
<div class="sw-chat-surface__context-menu" ref=${menuRef}
|
||||
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
|
||||
${contextMenu.type === 'chat' && html`
|
||||
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
||||
${folders.length > 0 && folders.map(f => html`
|
||||
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
|
||||
Move to ${f.name}
|
||||
</button>`)}
|
||||
${contextMenu.item.folder_id && html`
|
||||
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
|
||||
<button class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
${contextMenu.type === 'folder' && html`
|
||||
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
||||
<button class="sw-chat-surface__ctx-danger"
|
||||
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
||||
`}
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
|
||||
return html`
|
||||
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
|
||||
onClick=${() => onSelect(chat.id)}
|
||||
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
|
||||
role="option"
|
||||
aria-selected=${chat.id === activeId}
|
||||
title=${chat.title || 'Untitled'}>
|
||||
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
|
||||
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
|
||||
<button class="sw-chat-surface__item-menu"
|
||||
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
|
||||
title="Chat actions"
|
||||
aria-label="Chat actions">
|
||||
${DOTS_SVG}
|
||||
</button>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function _stopAndOpen(e, onOpenMenu, type, item) {
|
||||
e.stopPropagation();
|
||||
onOpenMenu(e, type, item);
|
||||
}
|
||||
109
src/js/sw/surfaces/chat/sidebar.js
Normal file
109
src/js/sw/surfaces/chat/sidebar.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// ==========================================
|
||||
// Chat Surface — Sidebar Component
|
||||
// ==========================================
|
||||
// Search bar, new chat, channels section, chats section with folders.
|
||||
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
|
||||
|
||||
import { SidebarChannels } from './sidebar-channels.js';
|
||||
import { SidebarChats } from './sidebar-chats.js';
|
||||
import { UserMenu } from '../../shell/user-menu.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useCallback } = window.hooks;
|
||||
|
||||
const PLUS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>`;
|
||||
|
||||
const SEARCH_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>`;
|
||||
|
||||
const COLLAPSE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="9" y1="3" x2="9" y2="21"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
|
||||
* activeId: string|null,
|
||||
* onSelectChat: (id: string) => void,
|
||||
* onSelectChannel: (id: string) => void,
|
||||
* onNewChat: () => void,
|
||||
* onCollapse: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
|
||||
const _onSearch = useCallback((e) => {
|
||||
sidebar.setSearch(e.target.value);
|
||||
}, [sidebar.setSearch]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-surface__sidebar" role="navigation" aria-label="Chat sidebar">
|
||||
${/* Header: new chat + collapse */''}
|
||||
<div class="sw-chat-surface__sidebar-header">
|
||||
<button class="sw-chat-surface__new-chat-btn"
|
||||
onClick=${onNewChat}
|
||||
title="New chat"
|
||||
aria-label="Start new chat">
|
||||
${PLUS_SVG}
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
<button class="sw-chat-surface__collapse-btn"
|
||||
onClick=${onCollapse}
|
||||
title="Collapse sidebar"
|
||||
aria-label="Collapse sidebar">
|
||||
${COLLAPSE_SVG}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${/* Search */''}
|
||||
<div class="sw-chat-surface__search-wrap">
|
||||
<span class="sw-chat-surface__search-icon">${SEARCH_SVG}</span>
|
||||
<input class="sw-chat-surface__search"
|
||||
type="text"
|
||||
placeholder="Search\u2026"
|
||||
value=${sidebar.search}
|
||||
onInput=${_onSearch}
|
||||
aria-label="Search chats and channels" />
|
||||
</div>
|
||||
|
||||
${/* Scrollable content */''}
|
||||
<div class="sw-chat-surface__sidebar-body">
|
||||
<${SidebarChannels}
|
||||
channels=${sidebar.channels}
|
||||
activeId=${activeId}
|
||||
collapsed=${sidebar.collapsed.channels}
|
||||
onToggle=${() => sidebar.toggleCollapsed('channels')}
|
||||
onSelect=${onSelectChannel} />
|
||||
|
||||
<${SidebarChats}
|
||||
chatsByFolder=${sidebar.chatsByFolder}
|
||||
folders=${sidebar.folders}
|
||||
activeId=${activeId}
|
||||
collapsed=${sidebar.collapsed.chats}
|
||||
onToggle=${() => sidebar.toggleCollapsed('chats')}
|
||||
onSelect=${onSelectChat}
|
||||
onRename=${sidebar.renameChat}
|
||||
onDelete=${sidebar.deleteChat}
|
||||
onMoveToFolder=${sidebar.moveToFolder}
|
||||
onCreateFolder=${sidebar.createFolder}
|
||||
onRenameFolder=${sidebar.renameFolder}
|
||||
onDeleteFolder=${sidebar.deleteFolder} />
|
||||
</div>
|
||||
|
||||
${/* Footer: UserMenu only */''}
|
||||
<div class="sw-chat-surface__sidebar-footer">
|
||||
<${UserMenu} placement="up-right" />
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
171
src/js/sw/surfaces/chat/tools-popup.js
Normal file
171
src/js/sw/surfaces/chat/tools-popup.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// ==========================================
|
||||
// Chat Surface — ToolsPopup Component
|
||||
// ==========================================
|
||||
// Scrollable popup anchored to a toolbar icon.
|
||||
// Category-based tool toggles with persistence to localStorage.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-disabled-tools';
|
||||
|
||||
/**
|
||||
* Load tools list from API and organize by category.
|
||||
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
|
||||
*/
|
||||
export function useTools() {
|
||||
const [tools, setTools] = useState([]);
|
||||
const [disabled, setDisabled] = useState(_loadDisabled);
|
||||
|
||||
useEffect(() => {
|
||||
// Load available tools from API
|
||||
if (window.sw?.api?.admin?.tools?.list) {
|
||||
window.sw.api.admin.tools.list().then(resp => {
|
||||
setTools(resp?.data || resp || []);
|
||||
}).catch(() => {});
|
||||
} else if (window.sw?.api?.channels?.tools) {
|
||||
// Fallback — try the channels tools endpoint
|
||||
window.sw.api.channels.tools?.().then(resp => {
|
||||
setTools(resp?.data || resp || []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = {};
|
||||
for (const t of tools) {
|
||||
const cat = t.category || 'Other';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(t);
|
||||
}
|
||||
return Object.entries(map).map(([name, items]) => ({ name, items }));
|
||||
}, [tools]);
|
||||
|
||||
const toggle = useCallback((toolName) => {
|
||||
setDisabled(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolName)) next.delete(toolName);
|
||||
else next.add(toolName);
|
||||
_saveDisabled(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCategory = useCallback((catName) => {
|
||||
setDisabled(prev => {
|
||||
const cat = categories.find(c => c.name === catName);
|
||||
if (!cat) return prev;
|
||||
const next = new Set(prev);
|
||||
const allDisabled = cat.items.every(t => next.has(t.name));
|
||||
for (const t of cat.items) {
|
||||
if (allDisabled) next.delete(t.name);
|
||||
else next.add(t.name);
|
||||
}
|
||||
_saveDisabled(next);
|
||||
return next;
|
||||
});
|
||||
}, [categories]);
|
||||
|
||||
const getDisabled = useCallback(() => [...disabled], [disabled]);
|
||||
|
||||
return { categories, disabled, toggle, toggleCategory, getDisabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* open: boolean,
|
||||
* onClose: () => void,
|
||||
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
|
||||
* disabled: Set<string>,
|
||||
* onToggle: (name: string) => void,
|
||||
* onToggleCategory: (catName: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
|
||||
const panelRef = useRef(null);
|
||||
const [expandedCats, setExpandedCats] = useState({});
|
||||
|
||||
// Close on Escape or outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onKey(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
}
|
||||
function _onClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
|
||||
}
|
||||
document.addEventListener('keydown', _onKey, true);
|
||||
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', _onKey, true);
|
||||
document.removeEventListener('click', _onClick);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const _toggleCat = useCallback((name) => {
|
||||
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const disabledCount = disabled.size;
|
||||
|
||||
return html`
|
||||
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
|
||||
<div class="sw-tools-popup__header">
|
||||
<span class="sw-tools-popup__title">Tools</span>
|
||||
${disabledCount > 0 && html`
|
||||
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
|
||||
</div>
|
||||
<div class="sw-tools-popup__body">
|
||||
${categories.length === 0 && html`
|
||||
<div class="sw-tools-popup__empty">No tools available</div>`}
|
||||
${categories.map(cat => {
|
||||
const allDisabled = cat.items.every(t => disabled.has(t.name));
|
||||
const someDisabled = cat.items.some(t => disabled.has(t.name));
|
||||
const isExpanded = expandedCats[cat.name] !== false; // default open
|
||||
|
||||
return html`
|
||||
<div key=${cat.name} class="sw-tools-popup__category">
|
||||
<div class="sw-tools-popup__cat-header">
|
||||
<button class="sw-tools-popup__cat-toggle"
|
||||
onClick=${() => _toggleCat(cat.name)}
|
||||
aria-expanded=${isExpanded}>
|
||||
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
|
||||
\u25B6
|
||||
</span>
|
||||
<span>${cat.name}</span>
|
||||
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
|
||||
</button>
|
||||
<label class="sw-tools-popup__cat-switch">
|
||||
<input type="checkbox"
|
||||
checked=${!allDisabled}
|
||||
indeterminate=${someDisabled && !allDisabled}
|
||||
onChange=${() => onToggleCategory(cat.name)} />
|
||||
</label>
|
||||
</div>
|
||||
${isExpanded && cat.items.map(tool => html`
|
||||
<label key=${tool.name} class="sw-tools-popup__tool">
|
||||
<input type="checkbox"
|
||||
checked=${!disabled.has(tool.name)}
|
||||
onChange=${() => onToggle(tool.name)} />
|
||||
<span class="sw-tools-popup__tool-name">${tool.name}</span>
|
||||
${tool.description && html`
|
||||
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
|
||||
</label>`)}
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _loadDisabled() {
|
||||
try {
|
||||
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||||
return new Set(arr || []);
|
||||
} catch (_) { return new Set(); }
|
||||
}
|
||||
|
||||
function _saveDisabled(set) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
|
||||
}
|
||||
175
src/js/sw/surfaces/chat/use-sidebar.js
Normal file
175
src/js/sw/surfaces/chat/use-sidebar.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useSidebar Hook
|
||||
// ==========================================
|
||||
// Loads and manages sidebar data: channels (DMs + named),
|
||||
// personal chats, and folders. Subscribes to WS events
|
||||
// for live updates.
|
||||
|
||||
const { useState, useEffect, useCallback, useMemo } = window.hooks;
|
||||
|
||||
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
|
||||
|
||||
/**
|
||||
* @param {{ activeId: string|null }} opts
|
||||
*/
|
||||
export function useSidebar(opts = {}) {
|
||||
const { activeId } = opts;
|
||||
|
||||
const [channels, setChannels] = useState([]); // named channels + DMs
|
||||
const [chats, setChats] = useState([]); // personal AI chats (direct, no participants)
|
||||
const [folders, setFolders] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [collapsed, setCollapsed] = useState(_loadCollapsed);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── Load data ───────────────────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!window.sw?.api?.channels?.list) return;
|
||||
|
||||
try {
|
||||
const foldersPromise = window.sw.api.folders?.list
|
||||
? window.sw.api.folders.list().catch(() => [])
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [chResp, dmResp, fResp] = await Promise.all([
|
||||
// Named channels (group chats, DMs with participants)
|
||||
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'channel' }).catch(() => []),
|
||||
// Personal AI chats
|
||||
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'direct' }).catch(() => []),
|
||||
// Folders
|
||||
foldersPromise,
|
||||
]);
|
||||
|
||||
setChannels(_extract(chResp));
|
||||
setChats(_extract(dmResp));
|
||||
setFolders(_extract(fResp));
|
||||
} catch (_) {}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// ── WebSocket live updates ──────────────
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
|
||||
const handlers = {
|
||||
'channel.created': reload,
|
||||
'channel.updated': reload,
|
||||
'channel.deleted': reload,
|
||||
'folder.created': reload,
|
||||
'folder.updated': reload,
|
||||
'folder.deleted': reload,
|
||||
};
|
||||
|
||||
Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
// ── Search filter ───────────────────────
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!search) return channels;
|
||||
const q = search.toLowerCase();
|
||||
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [channels, search]);
|
||||
|
||||
const filteredChats = useMemo(() => {
|
||||
if (!search) return chats;
|
||||
const q = search.toLowerCase();
|
||||
return chats.filter(c => (c.title || '').toLowerCase().includes(q));
|
||||
}, [chats, search]);
|
||||
|
||||
// Group chats by folder
|
||||
const chatsByFolder = useMemo(() => {
|
||||
const map = { '': [] };
|
||||
for (const f of (folders || [])) map[f.id] = [];
|
||||
for (const c of filteredChats) {
|
||||
const fid = c.folder_id || '';
|
||||
if (!map[fid]) map[fid] = [];
|
||||
map[fid].push(c);
|
||||
}
|
||||
return map;
|
||||
}, [filteredChats, folders]);
|
||||
|
||||
// ── Collapse sections ───────────────────
|
||||
const toggleCollapsed = useCallback((key) => {
|
||||
setCollapsed(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── CRUD helpers ────────────────────────
|
||||
const createFolder = useCallback(async (name) => {
|
||||
if (!window.sw?.api?.folders?.create) return;
|
||||
await window.sw.api.folders.create(name);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const renameChat = useCallback(async (id, title) => {
|
||||
await window.sw.api.channels.update(id, { title });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const deleteChat = useCallback(async (id) => {
|
||||
await window.sw.api.channels.del(id);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const moveToFolder = useCallback(async (chatId, folderId) => {
|
||||
await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const renameFolder = useCallback(async (id, name) => {
|
||||
await window.sw.api.folders.update(id, { name });
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const deleteFolder = useCallback(async (id) => {
|
||||
await window.sw.api.folders.del(id);
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return {
|
||||
channels: filteredChannels,
|
||||
chats: filteredChats,
|
||||
chatsByFolder,
|
||||
folders,
|
||||
search,
|
||||
setSearch,
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
loading,
|
||||
reload,
|
||||
// CRUD
|
||||
createFolder,
|
||||
renameChat,
|
||||
deleteChat,
|
||||
moveToFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
};
|
||||
}
|
||||
|
||||
function _extract(resp) {
|
||||
if (!resp) return [];
|
||||
if (Array.isArray(resp)) return resp;
|
||||
if (Array.isArray(resp.data)) return resp.data;
|
||||
// Handle wrapped responses like { folders: [], channels: [] }
|
||||
const vals = Object.values(resp);
|
||||
for (const v of vals) {
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function _loadCollapsed() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
78
src/js/sw/surfaces/chat/use-workspace.js
Normal file
78
src/js/sw/surfaces/chat/use-workspace.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// ==========================================
|
||||
// Chat Surface — useWorkspace Hook
|
||||
// ==========================================
|
||||
// Coordinator hook: manages active conversation, sidebar state.
|
||||
// Persists active selection to sessionStorage for reload restore.
|
||||
|
||||
const { useState, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const STORAGE_KEY = 'sw-chat-active';
|
||||
|
||||
/**
|
||||
* @returns {{
|
||||
* activeId: string|null,
|
||||
* activeType: 'chat'|'channel'|null,
|
||||
* sidebarOpen: boolean,
|
||||
* setSidebarOpen: (v: boolean) => void,
|
||||
* selectChat: (id: string) => void,
|
||||
* selectChannel: (id: string) => void,
|
||||
* clearSelection: () => void,
|
||||
* }}
|
||||
*/
|
||||
export function useWorkspace() {
|
||||
// Restore from sessionStorage
|
||||
const stored = _loadStored();
|
||||
const [activeId, setActiveId] = useState(stored.id);
|
||||
const [activeType, setActiveType] = useState(stored.type);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
if (activeId) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType }));
|
||||
} catch (_) {}
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, [activeId, activeType]);
|
||||
|
||||
const selectChat = useCallback((id) => {
|
||||
setActiveId(id);
|
||||
setActiveType('chat');
|
||||
// Auto-close sidebar on mobile
|
||||
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const selectChannel = useCallback((id) => {
|
||||
setActiveId(id);
|
||||
setActiveType('channel');
|
||||
if (window.innerWidth < 768) setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeId,
|
||||
activeType,
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
selectChat,
|
||||
selectChannel,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function _loadStored() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw);
|
||||
return { id: obj.id || null, type: obj.type || null };
|
||||
}
|
||||
} catch (_) {}
|
||||
return { id: null, type: null };
|
||||
}
|
||||
167
src/js/tokens.js
167
src/js/tokens.js
@@ -1,167 +0,0 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Token Estimation
|
||||
// ==========================================
|
||||
// Context tracking, token estimation, and context warning.
|
||||
//
|
||||
// Exports: window.Tokens,window.updateInputTokens,window.updateContextWarning
|
||||
|
||||
|
||||
// ── Token Estimation + Context Tracking ─────
|
||||
|
||||
const Tokens = {
|
||||
// Rough heuristic: ~4 chars per token for English (GPT/Claude average).
|
||||
// Not exact, but good enough for a UI indicator.
|
||||
estimate(text) {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
},
|
||||
|
||||
// Estimate tokens for the full conversation context sent to the model
|
||||
estimateConversation(messages, systemPrompt) {
|
||||
let total = 0;
|
||||
// System prompt
|
||||
if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
|
||||
// Messages
|
||||
for (const m of messages) {
|
||||
total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// Estimate tokens for a set of files (staged or sent).
|
||||
// Images: ~765 tokens (base tile estimate, reasonable cross-provider).
|
||||
// Documents with extracted text: text length / 4.
|
||||
// Other/unknown: raw size / 4 (rough fallback).
|
||||
estimateFiles(files) {
|
||||
if (!files?.length) return 0;
|
||||
let total = 0;
|
||||
for (const att of files) {
|
||||
if (att.content_type?.startsWith('image/')) {
|
||||
total += 765; // base tile estimate
|
||||
} else if (att.extracted_text) {
|
||||
total += this.estimate(att.extracted_text);
|
||||
} else if (att.size_bytes) {
|
||||
total += Math.ceil(att.size_bytes / 4);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
// Get context budget for current model
|
||||
getContextBudget() {
|
||||
const caps = UI.getSelectedModelCaps();
|
||||
return {
|
||||
maxContext: caps.max_context || 0,
|
||||
maxOutput: caps.max_output_tokens || 0,
|
||||
};
|
||||
},
|
||||
|
||||
// Format token count for display
|
||||
format(n) {
|
||||
if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
|
||||
if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||
return String(n);
|
||||
},
|
||||
|
||||
_warningDismissed: false,
|
||||
};
|
||||
|
||||
// Update the token counter below the input
|
||||
function updateInputTokens() {
|
||||
const el = document.getElementById('inputTokenCount');
|
||||
if (!el) return;
|
||||
|
||||
const inputText = (typeof ChatInput !== 'undefined') ? ChatInput.getValue() : '';
|
||||
const inputTokens = Tokens.estimate(inputText);
|
||||
|
||||
if (!inputText.trim()) {
|
||||
el.textContent = '';
|
||||
el.className = 'input-token-count';
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = Tokens.getContextBudget();
|
||||
if (budget.maxContext > 0) {
|
||||
// Show relative to available context
|
||||
const chat = App.chats.find(c => c.id === App.activeId);
|
||||
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
||||
// Include staged file estimates
|
||||
const fileTokens = Tokens.estimateFiles(
|
||||
(App.stagedFiles || []).filter(a => a.status !== 'error').map(a => ({
|
||||
content_type: a.contentType,
|
||||
size_bytes: a.sizeBytes,
|
||||
}))
|
||||
);
|
||||
const totalWithInput = convTokens + inputTokens + fileTokens;
|
||||
const pct = totalWithInput / budget.maxContext;
|
||||
const attLabel = fileTokens > 0 ? ` +${Tokens.format(fileTokens)} files` : '';
|
||||
el.textContent = `~${Tokens.format(inputTokens)} tokens${attLabel} · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
|
||||
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
|
||||
} else {
|
||||
el.textContent = `~${Tokens.format(inputTokens)} tokens`;
|
||||
el.className = 'input-token-count';
|
||||
}
|
||||
}
|
||||
|
||||
// Check conversation length and show/hide warning
|
||||
function updateContextWarning() {
|
||||
const warning = document.getElementById('contextWarning');
|
||||
const text = document.getElementById('contextWarningText');
|
||||
const summarizeBtn = document.getElementById('summarizeBtn');
|
||||
if (!warning || !text) return;
|
||||
|
||||
const budget = Tokens.getContextBudget();
|
||||
if (budget.maxContext <= 0) {
|
||||
warning.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.activeId);
|
||||
if (!chat || !chat.messages?.length) {
|
||||
warning.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
|
||||
const pct = convTokens / budget.maxContext;
|
||||
|
||||
// Show summarize button at ≥75% context usage (if enough messages)
|
||||
const showSummarize = pct >= 0.75 && chat.messages.length >= 4;
|
||||
if (summarizeBtn) {
|
||||
summarizeBtn.style.display = showSummarize ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
// Show auto-compact toggle next to summarize button
|
||||
const autoToggle = document.getElementById('autoCompactToggle');
|
||||
const autoCheck = document.getElementById('autoCompactCheck');
|
||||
if (autoToggle && autoCheck) {
|
||||
autoToggle.style.display = showSummarize ? 'inline-flex' : 'none';
|
||||
// Reflect current channel setting
|
||||
const chatSettings = chat?.settings || {};
|
||||
autoCheck.checked = chatSettings.auto_compaction !== false; // default: on
|
||||
}
|
||||
|
||||
if (pct >= 0.9 && !Tokens._warningDismissed) {
|
||||
warning.style.display = 'flex';
|
||||
warning.className = 'context-warning danger';
|
||||
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context.`;
|
||||
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
|
||||
warning.style.display = 'flex';
|
||||
warning.className = 'context-warning';
|
||||
text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function dismissContextWarning() {
|
||||
Tokens._warningDismissed = true;
|
||||
const el = document.getElementById('contextWarning');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────
|
||||
sb.ns('Tokens', Tokens);
|
||||
sb.register('updateInputTokens', updateInputTokens);
|
||||
sb.register('updateContextWarning', updateContextWarning);
|
||||
@@ -1,245 +0,0 @@
|
||||
// ── tools-toggle.js ─────────────────────────
|
||||
// Tools toggle popup: category-based enable/disable of server-side tools.
|
||||
// Categories expand to show individual tool toggles.
|
||||
// State persisted in localStorage, sent as disabled_tools[] in completion requests.
|
||||
|
||||
|
||||
const ToolsToggle = (() => {
|
||||
const STORAGE_KEY = 'cs-disabled-tools';
|
||||
|
||||
// Category display config
|
||||
const CATEGORY_META = {
|
||||
search: { label: 'Web Search', icon: '🔍' },
|
||||
knowledge: { label: 'Knowledge Bases', icon: '📚' },
|
||||
notes: { label: 'Notes', icon: '📝' },
|
||||
utilities: { label: 'Utilities', icon: '🧮' },
|
||||
workspace: { label: 'Workspace', icon: '🗂️' },
|
||||
context: { label: 'Context', icon: '📎' },
|
||||
memory: { label: 'Memory', icon: '🧠' },
|
||||
browser: { label: 'Extensions', icon: '🧩' },
|
||||
};
|
||||
|
||||
// Cached tool manifest from GET /api/v1/tools
|
||||
let _tools = []; // [{name, display_name, description, category}]
|
||||
let _categories = {}; // {category: [{name, displayName}]}
|
||||
let _disabled = new Set();
|
||||
let _expanded = new Set(); // which categories are expanded
|
||||
|
||||
// ── Public API ──────────────────────────
|
||||
|
||||
/** Initialize: load disabled set from storage, fetch tool manifest. */
|
||||
async function init() {
|
||||
_loadDisabled();
|
||||
try {
|
||||
const resp = await API.getTools();
|
||||
_tools = resp.tools || [];
|
||||
_buildCategories();
|
||||
_render();
|
||||
_show();
|
||||
} catch (e) {
|
||||
console.warn('Tools toggle: failed to load tools', e);
|
||||
}
|
||||
_wireEvents();
|
||||
}
|
||||
|
||||
/** Returns array of disabled tool names for the completion request. */
|
||||
function getDisabledTools() {
|
||||
return [..._disabled];
|
||||
}
|
||||
|
||||
/** Refresh tool list (e.g. after browser extension connects). */
|
||||
async function refresh() {
|
||||
try {
|
||||
const resp = await API.getTools();
|
||||
_tools = resp.tools || [];
|
||||
_buildCategories();
|
||||
_render();
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────
|
||||
|
||||
function _loadDisabled() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_disabled = raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { _disabled = new Set(); }
|
||||
}
|
||||
|
||||
function _saveDisabled() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([..._disabled]));
|
||||
_updateBtnState();
|
||||
}
|
||||
|
||||
function _buildCategories() {
|
||||
_categories = {};
|
||||
for (const t of _tools) {
|
||||
const cat = t.category || 'other';
|
||||
if (!_categories[cat]) _categories[cat] = [];
|
||||
_categories[cat].push({
|
||||
name: t.name,
|
||||
displayName: t.display_name || t.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns 'all' | 'none' | 'partial' for a category's enabled state. */
|
||||
function _categoryState(cat) {
|
||||
const tools = _categories[cat] || [];
|
||||
if (tools.length === 0) return 'none';
|
||||
const enabledCount = tools.filter(t => !_disabled.has(t.name)).length;
|
||||
if (enabledCount === tools.length) return 'all';
|
||||
if (enabledCount === 0) return 'none';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function _toggleCategory(cat) {
|
||||
const tools = _categories[cat] || [];
|
||||
const state = _categoryState(cat);
|
||||
if (state === 'all') {
|
||||
// All on → all off
|
||||
tools.forEach(t => _disabled.add(t.name));
|
||||
} else {
|
||||
// Partial or none → all on
|
||||
tools.forEach(t => _disabled.delete(t.name));
|
||||
}
|
||||
_saveDisabled();
|
||||
_render();
|
||||
}
|
||||
|
||||
function _toggleTool(name) {
|
||||
if (_disabled.has(name)) {
|
||||
_disabled.delete(name);
|
||||
} else {
|
||||
_disabled.add(name);
|
||||
}
|
||||
_saveDisabled();
|
||||
_render();
|
||||
}
|
||||
|
||||
function _toggleExpand(cat) {
|
||||
if (_expanded.has(cat)) {
|
||||
_expanded.delete(cat);
|
||||
} else {
|
||||
_expanded.add(cat);
|
||||
}
|
||||
_render();
|
||||
}
|
||||
|
||||
function _show() {
|
||||
const wrap = document.querySelector('.tools-toggle-wrap');
|
||||
if (wrap && _tools.length > 0) {
|
||||
wrap.style.display = '';
|
||||
_updateBtnState();
|
||||
}
|
||||
}
|
||||
|
||||
function _updateBtnState() {
|
||||
const btn = document.getElementById('toolsToggleBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('has-disabled', _disabled.size > 0);
|
||||
const count = _disabled.size;
|
||||
btn.title = count > 0 ? `Tools (${count} disabled)` : 'Tools';
|
||||
}
|
||||
|
||||
function _render() {
|
||||
const popup = document.getElementById('toolsPopup');
|
||||
if (!popup) return;
|
||||
|
||||
const cats = Object.keys(_categories);
|
||||
const order = Object.keys(CATEGORY_META);
|
||||
cats.sort((a, b) => {
|
||||
const ai = order.indexOf(a), bi = order.indexOf(b);
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const cat of cats) {
|
||||
const meta = CATEGORY_META[cat] || { label: cat, icon: '🔧' };
|
||||
const state = _categoryState(cat);
|
||||
const tools = _categories[cat];
|
||||
const expanded = _expanded.has(cat);
|
||||
const switchClass = state === 'all' ? 'on' : state === 'partial' ? 'on partial' : '';
|
||||
const chevron = tools.length > 1
|
||||
? `<span class="tools-popup-expand ${expanded ? 'open' : ''}" data-expand="${cat}">›</span>`
|
||||
: '';
|
||||
const labelExpand = tools.length > 1 ? ` data-expand="${cat}"` : '';
|
||||
|
||||
html += `<div class="tools-popup-category" data-category="${cat}">
|
||||
<span class="tools-popup-switch ${switchClass}" data-switch="${cat}"><span class="tools-popup-switch-knob"></span></span>
|
||||
<span class="tools-popup-label"${labelExpand}>${meta.icon} ${meta.label}</span>
|
||||
${chevron}
|
||||
</div>`;
|
||||
|
||||
// Expanded children
|
||||
if (expanded && tools.length > 1) {
|
||||
for (const t of tools) {
|
||||
const on = !_disabled.has(t.name);
|
||||
html += `<div class="tools-popup-tool" data-tool="${t.name}">
|
||||
<span class="tools-popup-switch ${on ? 'on' : ''}" data-tool-switch="${t.name}"><span class="tools-popup-switch-knob"></span></span>
|
||||
<span class="tools-popup-label">${t.displayName}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popup.innerHTML = html;
|
||||
}
|
||||
|
||||
function _wireEvents() {
|
||||
const btn = document.getElementById('toolsToggleBtn');
|
||||
const popup = document.getElementById('toolsPopup');
|
||||
if (!btn || !popup) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
popup.classList.toggle('open');
|
||||
});
|
||||
|
||||
popup.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle switch on category row
|
||||
const catSwitch = e.target.closest('[data-switch]');
|
||||
if (catSwitch) {
|
||||
_toggleCategory(catSwitch.dataset.switch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle switch on individual tool row
|
||||
const toolSwitch = e.target.closest('[data-tool-switch]');
|
||||
if (toolSwitch) {
|
||||
_toggleTool(toolSwitch.dataset.toolSwitch);
|
||||
return;
|
||||
}
|
||||
|
||||
// Expand/collapse: chevron or label click on multi-tool categories
|
||||
const expander = e.target.closest('[data-expand]');
|
||||
if (expander) {
|
||||
_toggleExpand(expander.dataset.expand);
|
||||
return;
|
||||
}
|
||||
|
||||
// Individual tool label (no expand, just toggle)
|
||||
const toolRow = e.target.closest('.tools-popup-tool');
|
||||
if (toolRow) {
|
||||
_toggleTool(toolRow.dataset.tool);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-tool category label click (no chevron) — toggle
|
||||
const catRow = e.target.closest('.tools-popup-category');
|
||||
if (catRow) {
|
||||
_toggleCategory(catRow.dataset.category);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', () => popup.classList.remove('open'));
|
||||
}
|
||||
|
||||
return { init, getDisabledTools, refresh };
|
||||
})();
|
||||
|
||||
sb.ns('ToolsToggle', ToolsToggle);
|
||||
1699
src/js/ui-core.js
1699
src/js/ui-core.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user