Changeset 0.37.8 (#220)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [0.37.8] — 2026-03-21
|
||||
|
||||
### Summary
|
||||
|
||||
Preact ChatPane kit — composable chat components replacing the old
|
||||
imperative `chat-pane.js` standalone mode. Every piece (hooks,
|
||||
components, utilities) independently importable. `ChatPane` is the
|
||||
default assembly; surfaces import individual pieces for custom layouts.
|
||||
|
||||
### New
|
||||
|
||||
- **ChatPane kit** (`src/js/sw/components/chat-pane/`) — 9 files:
|
||||
- `ChatPane` — default assembly: header + messages + input
|
||||
- `useChat(opts)` — channel state machine (messages, send/receive,
|
||||
model selection, channel CRUD)
|
||||
- `useStream()` — SSE ReadableStream parser with rAF-batched updates
|
||||
- `MessageList` — scrollable messages with auto-scroll-to-bottom
|
||||
- `MessageBubble` — single message with role styling + markdown
|
||||
- `MessageInput` — auto-resize textarea, Enter-to-send, handleRef
|
||||
- `ModelSelector` — model dropdown from `sw.api.models.enabled()`
|
||||
- `ChatHistory` — channel history select + new-chat button
|
||||
- `renderMarkdown` — `marked` + `DOMPurify` wrapper with fallback
|
||||
- **`sw.chatPane(container, opts)`** — SDK mount helper, lazy-imports
|
||||
Preact ChatPane, returns imperative handle
|
||||
- **`sw-chat-pane.css`** — component styles with `sw-` prefix convention
|
||||
- **Layer 1.5: Components** — `src/js/sw/components/` directory
|
||||
established for reusable compound components (between SDK and surfaces)
|
||||
|
||||
### Changed
|
||||
|
||||
- `switchboard-sdk.js` — `sw.chat()` now prefers `window.sw.chatPane()`
|
||||
when new SDK is loaded, falls back to legacy `ChatPane.mount()`
|
||||
- `chat-pane.js` — stripped standalone mode (446 → 160 lines). Removed
|
||||
`mount()`, `_initStandalone()`, and all streaming/model/history logic.
|
||||
Retained DOM-binding `create()` for legacy chat surface (`app.js`)
|
||||
- `server/version.go` — version `0.37.6` → `0.37.8`
|
||||
- `VERSION` — `0.37.6` → `0.37.8`
|
||||
- `src/js/sw/sdk/index.js` — `sw.chatPane()` helper, `sw._sdk` → `0.37.8`
|
||||
- `server/pages/templates/base.html` — added `sw-chat-pane.css` link
|
||||
|
||||
### Design Notes
|
||||
|
||||
- **Kit of Parts + Default Assembly** — every hook and component is
|
||||
independently importable. Future Chat surface (v0.37.10) will import
|
||||
`useChat`, `useStream`, `MessageList`, `MessageInput` individually
|
||||
with a completely different layout.
|
||||
- **handleRef pattern** — core Preact only exposes `{ h, render }`;
|
||||
`forwardRef` requires `preact/compat`. Components use a `handleRef`
|
||||
prop pattern instead: caller passes `{ current: null }`, component
|
||||
populates with imperative methods via `useEffect`.
|
||||
|
||||
---
|
||||
|
||||
## [0.37.7] — 2026-03-21
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -632,7 +632,7 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
|
||||
|---------|---------|-------|
|
||||
| v0.37.6 | Admin | Largest section count, no real-time |
|
||||
| v0.37.7 | Team Admin | Inherits from Admin patterns |
|
||||
| v0.37.8 | ChatPane | Reusable component, NOT a surface |
|
||||
| v0.37.8 | ChatPane ✅ | Composable kit (9 files), standalone stripped from old chat-pane.js |
|
||||
| v0.37.9 | NotesPane | Reusable component, NOT a surface |
|
||||
| v0.37.10 | Chat surface | Composes ChatPane + sidebar + panels |
|
||||
| v0.37.11 | Notes surface | Composes NotesPane + graph |
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/splash.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/pane-container.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
const Version = "0.37.7"
|
||||
const Version = "0.37.8"
|
||||
|
||||
379
src/css/sw-chat-pane.css
Normal file
379
src/css/sw-chat-pane.css
Normal file
@@ -0,0 +1,379 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Preact ChatPane Kit CSS
|
||||
==========================================
|
||||
v0.37.8: Styles for the composable ChatPane kit.
|
||||
sw- prefixed, no conflicts with legacy chat-pane.css.
|
||||
========================================== */
|
||||
|
||||
/* ── Container ─────────────────────────────── */
|
||||
|
||||
.sw-chat-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Header Bar (standalone mode) ──────────── */
|
||||
|
||||
.sw-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;
|
||||
}
|
||||
|
||||
.sw-chat-pane__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sw-chat-pane__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Chat History Select ──────────────────── */
|
||||
|
||||
.sw-chat-history__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;
|
||||
}
|
||||
|
||||
.sw-chat-history__select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sw-chat-history__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;
|
||||
}
|
||||
|
||||
.sw-chat-history__new-btn:hover {
|
||||
color: var(--accent, #b38a4e);
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
||||
}
|
||||
|
||||
/* ── Model Selector ───────────────────────── */
|
||||
|
||||
.sw-model-selector__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;
|
||||
}
|
||||
|
||||
.sw-model-selector__select:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
outline: none;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Messages Area ─────────────────────────── */
|
||||
|
||||
.sw-chat-pane__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
padding: 12px 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Message Bubbles ───────────────────────── */
|
||||
|
||||
.sw-chat-msg {
|
||||
padding: 8px 16px;
|
||||
font-size: var(--msg-font, 14px);
|
||||
line-height: 1.65;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.sw-chat-msg + .sw-chat-msg {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* User messages — right-aligned bubble */
|
||||
.sw-chat-msg--user {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--user .sw-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 */
|
||||
.sw-chat-msg--assistant {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--assistant .sw-chat-msg__content {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* System/error messages */
|
||||
.sw-chat-msg--system {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--system .sw-chat-msg__content {
|
||||
color: var(--danger, #f44336);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Streaming indicator ───────────────────── */
|
||||
|
||||
.sw-chat-msg--streaming .sw-chat-msg__content::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
background: var(--accent, #b38a4e);
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: sw-cursor-blink 0.8s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes sw-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Typing dots ───────────────────────────── */
|
||||
|
||||
.sw-chat-msg--typing {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.sw-chat-msg--typing .sw-typing-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sw-chat-msg--typing .sw-typing-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3, #555);
|
||||
animation: sw-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sw-chat-msg--typing .sw-typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.sw-chat-msg--typing .sw-typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes sw-dot-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Markdown content ──────────────────────── */
|
||||
|
||||
.sw-chat-msg__content p { margin: 0 0 0.5em; }
|
||||
.sw-chat-msg__content p:last-child { margin-bottom: 0; }
|
||||
|
||||
.sw-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);
|
||||
}
|
||||
|
||||
.sw-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);
|
||||
}
|
||||
|
||||
.sw-chat-msg__content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content ul, .sw-chat-msg__content ol {
|
||||
margin: 4px 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content blockquote {
|
||||
border-left: 3px solid var(--accent, #b38a4e);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-2, #999);
|
||||
}
|
||||
|
||||
.sw-chat-msg__content a {
|
||||
color: var(--accent, #b38a4e);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content table {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content th,
|
||||
.sw-chat-msg__content td {
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.sw-chat-msg__content th {
|
||||
background: var(--bg-secondary, #151517);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Welcome / Empty State ─────────────────── */
|
||||
|
||||
.sw-chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.sw-chat-welcome__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.sw-chat-welcome__hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-3, #555);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Input Bar ─────────────────────────────── */
|
||||
|
||||
.sw-chat-pane__input-bar {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
background: var(--bg-secondary, #151517);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.sw-msg-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;
|
||||
}
|
||||
|
||||
.sw-msg-input__wrap:focus-within {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.sw-msg-input__textarea {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
max-height: 160px;
|
||||
width: 100%;
|
||||
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 ───────────────────────────── */
|
||||
|
||||
.sw-msg-input__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;
|
||||
}
|
||||
|
||||
.sw-msg-input__send:hover {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--accent-hover, #c9a05e);
|
||||
}
|
||||
|
||||
.sw-msg-input__send:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - ChatPane Component
|
||||
// Chat Switchboard - ChatPane Component (DOM-binding mode)
|
||||
// ==========================================
|
||||
// v0.31.1: Self-contained chat pane with optional standalone mode.
|
||||
// When standalone: true, provides full chat functionality (streaming,
|
||||
// model selector, history, channel creation) with zero external wiring.
|
||||
// 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
|
||||
|
||||
@@ -147,298 +148,12 @@ const ChatPane = {
|
||||
|
||||
active() { return this.primary; },
|
||||
|
||||
// v0.31.0: Self-mount — creates DOM + binds in one call.
|
||||
mount(container, opts) {
|
||||
const pfx = opts.id || 'cp';
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-pane';
|
||||
el.id = pfx + 'ChatPane';
|
||||
el.innerHTML =
|
||||
'<div class="chat-pane-header" id="' + pfx + 'ChatHeader" style="display:none;">' +
|
||||
'<div class="chat-pane-header-left">' +
|
||||
'<select class="chat-pane-chat-select" id="' + pfx + 'ChatSelect" title="Switch chat">' +
|
||||
'<option value="">New conversation</option>' +
|
||||
'</select>' +
|
||||
'<button class="chat-pane-new-btn" id="' + pfx + 'ChatNewBtn" title="New chat">' +
|
||||
'<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>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="chat-pane-header-right">' +
|
||||
'<div class="chat-pane-model-sel" id="' + pfx + 'ModelSel"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-pane-messages" id="' + pfx + 'ChatMessages"></div>' +
|
||||
'<div class="chat-pane-input-bar">' +
|
||||
'<div class="chat-pane-input-wrap">' +
|
||||
'<div class="chat-pane-input" id="' + pfx + 'ChatInput"></div>' +
|
||||
'<button class="chat-pane-send" id="' + pfx + 'SendBtn" title="Send">' +
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>' +
|
||||
'</svg>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="chat-pane-toolbar" id="' + pfx + 'Toolbar"></div>' +
|
||||
'</div>';
|
||||
container.appendChild(el);
|
||||
const instance = this.create({
|
||||
id: pfx,
|
||||
messagesEl: document.getElementById(pfx + 'ChatMessages'),
|
||||
inputEl: document.getElementById(pfx + 'ChatInput'),
|
||||
sendBtnEl: document.getElementById(pfx + 'SendBtn'),
|
||||
modelSelEl: document.getElementById(pfx + 'ModelSel'),
|
||||
toolbarEl: document.getElementById(pfx + 'Toolbar'),
|
||||
channelId: opts.channelId || null,
|
||||
standalone: opts.standalone !== false,
|
||||
});
|
||||
|
||||
// v0.31.1: Standalone mode — self-contained chat with streaming
|
||||
if (opts.standalone) {
|
||||
_initStandalone(instance, pfx, opts);
|
||||
}
|
||||
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ── Standalone Chat Logic ─────────────────────
|
||||
// Absorbed from editor package v0.31.0. Provides full chat functionality
|
||||
// (textarea, model selector, history, streaming, channel management)
|
||||
// so that sw.chat(el, {standalone: true}) works with zero external wiring.
|
||||
|
||||
function _initStandalone(pane, pfx, opts) {
|
||||
const headerEl = document.getElementById(pfx + 'ChatHeader');
|
||||
const selectEl = document.getElementById(pfx + 'ChatSelect');
|
||||
const newBtnEl = document.getElementById(pfx + 'ChatNewBtn');
|
||||
const modelSelEl = document.getElementById(pfx + 'ModelSel');
|
||||
const inputEl = pane.inputEl;
|
||||
const sendBtn = pane.sendBtnEl;
|
||||
if (!inputEl) return;
|
||||
|
||||
// Show header (chat-select + model-selector + new-chat button)
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
|
||||
// Show welcome
|
||||
pane.showWelcome();
|
||||
|
||||
let _channelId = null, _messages = [], _sending = false, _abortController = null;
|
||||
let _selectedModel = (typeof App !== 'undefined' && App.settings?.model) || '';
|
||||
const _getContext = opts.getContext || null;
|
||||
|
||||
// ── Model Selector ──────────────────────
|
||||
|
||||
async function _initModelSelector() {
|
||||
if (!modelSelEl) return;
|
||||
if (typeof App === 'undefined') return;
|
||||
if (!App.models?.length && typeof fetchModels === 'function') await fetchModels();
|
||||
const models = App.models || [];
|
||||
if (!models.length) return;
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'chat-pane-model-select';
|
||||
sel.title = 'Select model';
|
||||
models.forEach(function (m) {
|
||||
if (m.hidden) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
opt.textContent = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);
|
||||
if (m.id === _selectedModel || m.personaId === _selectedModel) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.addEventListener('change', function () { _selectedModel = sel.value; });
|
||||
modelSelEl.innerHTML = '';
|
||||
modelSelEl.appendChild(sel);
|
||||
}
|
||||
|
||||
// ── Chat History ────────────────────────
|
||||
|
||||
async function _loadChatHistory() {
|
||||
if (!selectEl || typeof API === 'undefined') return;
|
||||
try {
|
||||
const resp = await API.listChannels(1, 20, 'direct');
|
||||
const channels = resp.data || resp || [];
|
||||
selectEl.innerHTML = '<option value="">New conversation</option>';
|
||||
channels.forEach(function (ch) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ch.id;
|
||||
opt.textContent = ch.title || 'Untitled';
|
||||
if (ch.id === _channelId) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (selectEl) selectEl.addEventListener('change', function () {
|
||||
selectEl.value ? _switchToChannel(selectEl.value) : _newChat();
|
||||
});
|
||||
if (newBtnEl) newBtnEl.addEventListener('click', _newChat);
|
||||
|
||||
async function _switchToChannel(channelId) {
|
||||
_channelId = channelId;
|
||||
_messages = [];
|
||||
pane.clear();
|
||||
pane.appendTyping();
|
||||
try {
|
||||
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
|
||||
_messages = resp.data || resp || [];
|
||||
pane.removeTyping();
|
||||
_renderAllMessages();
|
||||
} catch (e) {
|
||||
pane.removeTyping();
|
||||
_appendSystemMsg('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _newChat() {
|
||||
_channelId = null;
|
||||
_messages = [];
|
||||
pane.showWelcome();
|
||||
if (selectEl) selectEl.value = '';
|
||||
}
|
||||
|
||||
// ── Textarea ────────────────────────────
|
||||
|
||||
const ta = document.createElement('textarea');
|
||||
ta.placeholder = 'Type a message\u2026';
|
||||
ta.rows = 1;
|
||||
inputEl.appendChild(ta);
|
||||
ta.addEventListener('input', function () {
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
});
|
||||
|
||||
// ── Send + Streaming ────────────────────
|
||||
|
||||
async function _sendMessage() {
|
||||
const text = ta.value.trim();
|
||||
if (!text || _sending) return;
|
||||
_sending = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
ta.value = '';
|
||||
ta.style.height = 'auto';
|
||||
|
||||
if (!_channelId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
|
||||
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
|
||||
_channelId = resp.id;
|
||||
_loadChatHistory();
|
||||
} catch (e) {
|
||||
_appendSystemMsg('Failed to create chat: ' + e.message);
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_messages.push({ role: 'user', content: text });
|
||||
_renderAllMessages();
|
||||
|
||||
try {
|
||||
_abortController = new AbortController();
|
||||
let content = text;
|
||||
if (_getContext) {
|
||||
const ctx = _getContext();
|
||||
if (ctx) content = text + '\n\n[Context: Currently editing ' + ctx.path + ']\n```\n' + ctx.content + '\n```';
|
||||
}
|
||||
const resp = await API.streamCompletion(_channelId, content, _selectedModel, _abortController.signal);
|
||||
await _handleStream(resp);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') _appendSystemMsg('Error: ' + e.message);
|
||||
}
|
||||
_abortController = null;
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', _sendMessage);
|
||||
ta.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); _sendMessage(); }
|
||||
});
|
||||
|
||||
// ── Stream Handler ──────────────────────
|
||||
|
||||
async function _handleStream(response) {
|
||||
const messagesEl = pane.messagesEl;
|
||||
if (!messagesEl) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'chat-msg__content';
|
||||
div.appendChild(contentEl);
|
||||
messagesEl.appendChild(div);
|
||||
let content = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
content += delta;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||
} else {
|
||||
contentEl.textContent = content;
|
||||
}
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') content += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
div.classList.remove('chat-msg--streaming');
|
||||
_messages.push({ role: 'assistant', content: content });
|
||||
}
|
||||
|
||||
// ── Message Rendering ───────────────────
|
||||
|
||||
function _renderAllMessages() {
|
||||
if (!pane.messagesEl) return;
|
||||
pane.messagesEl.innerHTML = '';
|
||||
_messages.forEach(function (m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
const c = document.createElement('div');
|
||||
c.className = 'chat-msg__content';
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
c.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
|
||||
} else {
|
||||
c.textContent = m.content || '';
|
||||
}
|
||||
div.appendChild(c);
|
||||
pane.messagesEl.appendChild(div);
|
||||
});
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _appendSystemMsg(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--system';
|
||||
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' +
|
||||
(typeof esc === 'function' ? esc(text) : text) + '</div>';
|
||||
pane.messagesEl?.appendChild(div);
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
|
||||
// ── Deferred Init ───────────────────────
|
||||
|
||||
function _deferredInit() { _initModelSelector(); _loadChatHistory(); }
|
||||
if (typeof App !== 'undefined' && App.models?.length) _deferredInit();
|
||||
else document.addEventListener('sb:ready', _deferredInit, { once: true });
|
||||
}
|
||||
// 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 ─────────────────────────────────
|
||||
|
||||
57
src/js/sw/components/chat-pane/chat-history.js
Normal file
57
src/js/sw/components/chat-pane/chat-history.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — ChatHistory Component
|
||||
// ==========================================
|
||||
// Channel history select + new-chat button.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${ChatHistory} channelId=${id} onSelect=${fn} onNew=${fn} />`
|
||||
|
||||
const { useState, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
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>`;
|
||||
|
||||
/**
|
||||
* @param {{ channelId: string|null, onSelect: (id: string) => void, onNew: () => void }} props
|
||||
*/
|
||||
export function ChatHistory({ channelId, onSelect, onNew }) {
|
||||
const [channels, setChannels] = useState([]);
|
||||
|
||||
function _load() {
|
||||
if (!window.sw?.api?.channels?.list) return;
|
||||
window.sw.api.channels.list({ page: 1, per_page: 20, channel_type: 'direct' })
|
||||
.then(resp => {
|
||||
setChannels(resp?.data || resp || []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
useEffect(() => { _load(); }, [channelId]);
|
||||
|
||||
function _onChange(e) {
|
||||
const val = e.target.value;
|
||||
if (val) onSelect(val);
|
||||
else onNew();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__header-left">
|
||||
<select class="sw-chat-history__select"
|
||||
title="Switch chat"
|
||||
value=${channelId || ''}
|
||||
onChange=${_onChange}>
|
||||
<option value="">New conversation</option>
|
||||
${channels.map(ch => html`
|
||||
<option key=${ch.id} value=${ch.id}>${ch.title || 'Untitled'}</option>`)}
|
||||
</select>
|
||||
<button class="sw-chat-history__new-btn" title="New chat" onClick=${onNew}>
|
||||
${PLUS_SVG}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
106
src/js/sw/components/chat-pane/index.js
Normal file
106
src/js/sw/components/chat-pane/index.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — Default Assembly
|
||||
// ==========================================
|
||||
// Wires useChat + all sub-components into the standard
|
||||
// 1-on-1 chat experience. Surfaces that need a different
|
||||
// layout import individual pieces instead.
|
||||
//
|
||||
// Usage:
|
||||
// import { ChatPane } from './components/chat-pane/index.js';
|
||||
// html`<${ChatPane} handleRef=${ref} standalone=${true} getContext=${fn} />`
|
||||
//
|
||||
// Re-exports all pieces for kit consumers:
|
||||
// import { useChat, useStream, MessageList, MessageInput, ... } from './components/chat-pane/index.js';
|
||||
|
||||
import { useChat } from './use-chat.js';
|
||||
import { useStream } from './use-stream.js';
|
||||
import { MessageList } from './message-list.js';
|
||||
import { MessageBubble } from './message-bubble.js';
|
||||
import { MessageInput } from './message-input.js';
|
||||
import { ModelSelector } from './model-selector.js';
|
||||
import { ChatHistory } from './chat-history.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* Default ChatPane assembly.
|
||||
*
|
||||
* @param {{
|
||||
* channelId?: string,
|
||||
* standalone?: boolean,
|
||||
* getContext?: () => {path: string, content: string}|null,
|
||||
* onChannelChange?: (id: string) => void,
|
||||
* handleRef?: { current: any },
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function ChatPane(props) {
|
||||
const {
|
||||
channelId: initialChannelId,
|
||||
standalone = true,
|
||||
getContext,
|
||||
onChannelChange,
|
||||
handleRef,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const inputHandle = useRef(null);
|
||||
|
||||
const chat = useChat({
|
||||
initialChannelId,
|
||||
getContext,
|
||||
onChannelChange,
|
||||
});
|
||||
|
||||
// Expose imperative handle via handleRef prop
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
getChannel() { return chat.channelId; },
|
||||
setChannel(id) { chat.switchChannel(id); },
|
||||
focusInput() { inputHandle.current?.focus(); },
|
||||
getInputValue() { return inputHandle.current?.getValue() || ''; },
|
||||
setInputValue(v) { inputHandle.current?.setValue(v); },
|
||||
clearInput() { inputHandle.current?.clear(); },
|
||||
clear() { chat.newChat(); },
|
||||
stop() { chat.stop(); },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
|
||||
${standalone && html`
|
||||
<div class="sw-chat-pane__header">
|
||||
<${ChatHistory}
|
||||
channelId=${chat.channelId}
|
||||
onSelect=${chat.switchChannel}
|
||||
onNew=${chat.newChat} />
|
||||
<div class="sw-chat-pane__header-right">
|
||||
<${ModelSelector}
|
||||
value=${chat.model}
|
||||
onChange=${chat.setModel} />
|
||||
</div>
|
||||
</div>`}
|
||||
<${MessageList}
|
||||
messages=${chat.messages}
|
||||
streamContent=${chat.streamContent}
|
||||
streaming=${chat.streaming} />
|
||||
<${MessageInput}
|
||||
handleRef=${inputHandle}
|
||||
onSend=${chat.sendMessage}
|
||||
disabled=${chat.sending} />
|
||||
</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';
|
||||
41
src/js/sw/components/chat-pane/markdown.js
Normal file
41
src/js/sw/components/chat-pane/markdown.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — Markdown Renderer
|
||||
// ==========================================
|
||||
// Wraps window.marked + window.DOMPurify with fallback.
|
||||
// Independently importable utility.
|
||||
//
|
||||
// Usage:
|
||||
// import { renderMarkdown } from './markdown.js';
|
||||
// const html = renderMarkdown('**bold** text');
|
||||
|
||||
let _lastInput = '';
|
||||
let _lastOutput = '';
|
||||
|
||||
const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown text to sanitized HTML.
|
||||
* Falls back to escaped plaintext when marked/DOMPurify unavailable.
|
||||
*
|
||||
* @param {string} text — raw markdown
|
||||
* @returns {string} sanitized HTML
|
||||
*/
|
||||
export function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
if (text === _lastInput) return _lastOutput;
|
||||
|
||||
let result;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
result = DOMPurify.sanitize(marked.parse(text));
|
||||
} else {
|
||||
result = _escapeHtml(text);
|
||||
}
|
||||
|
||||
_lastInput = text;
|
||||
_lastOutput = result;
|
||||
return result;
|
||||
}
|
||||
35
src/js/sw/components/chat-pane/message-bubble.js
Normal file
35
src/js/sw/components/chat-pane/message-bubble.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageBubble Component
|
||||
// ==========================================
|
||||
// Single message rendering with role styling and markdown.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageBubble} role="assistant" content=${text} />`
|
||||
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{ role: string, content: string, streaming?: boolean }} props
|
||||
*/
|
||||
export function MessageBubble({ role, content, streaming }) {
|
||||
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>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<div class="sw-chat-msg__content"
|
||||
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
|
||||
</div>`;
|
||||
}
|
||||
89
src/js/sw/components/chat-pane/message-input.js
Normal file
89
src/js/sw/components/chat-pane/message-input.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageInput Component
|
||||
// ==========================================
|
||||
// Auto-resize textarea with Enter-to-send.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// const inputHandle = useRef(null);
|
||||
// html`<${MessageInput} onSend=${fn} disabled=${false} handleRef=${inputHandle} />`
|
||||
// inputHandle.current.focus();
|
||||
|
||||
const { useRef, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SEND_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">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{ onSend: (text: string) => void, disabled?: boolean, handleRef?: { current: any } }} props
|
||||
*/
|
||||
export function MessageInput({ onSend, disabled, handleRef }) {
|
||||
const taRef = useRef(null);
|
||||
|
||||
function _autoResize() {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
// Expose imperative handle via handleRef prop
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
getValue() { return taRef.current?.value || ''; },
|
||||
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
|
||||
focus() { taRef.current?.focus(); },
|
||||
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
|
||||
};
|
||||
}
|
||||
return () => { if (handleRef) handleRef.current = null; };
|
||||
}, [handleRef]);
|
||||
|
||||
const _onInput = useCallback(() => _autoResize(), []);
|
||||
|
||||
const _onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = taRef.current?.value?.trim();
|
||||
if (text && !disabled) {
|
||||
onSend(text);
|
||||
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
}
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
const _onClickSend = useCallback(() => {
|
||||
const text = taRef.current?.value?.trim();
|
||||
if (text && !disabled) {
|
||||
onSend(text);
|
||||
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__input-bar">
|
||||
<div class="sw-msg-input__wrap">
|
||||
<textarea
|
||||
ref=${taRef}
|
||||
class="sw-msg-input__textarea"
|
||||
placeholder="Type a message\u2026"
|
||||
rows="1"
|
||||
disabled=${disabled}
|
||||
onInput=${_onInput}
|
||||
onKeyDown=${_onKeyDown}
|
||||
/>
|
||||
<button
|
||||
class="sw-msg-input__send"
|
||||
title="Send"
|
||||
disabled=${disabled}
|
||||
onClick=${_onClickSend}
|
||||
>${SEND_SVG}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
55
src/js/sw/components/chat-pane/message-list.js
Normal file
55
src/js/sw/components/chat-pane/message-list.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageList Component
|
||||
// ==========================================
|
||||
// Scrollable message list with auto-scroll-to-bottom.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageList} messages=${msgs} streamContent=${text} streaming=${true} />`
|
||||
|
||||
import { MessageBubble } from './message-bubble.js';
|
||||
|
||||
const { useRef, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SCROLL_THRESHOLD = 60;
|
||||
|
||||
/**
|
||||
* @param {{ messages: Array<{role: string, content: string}>, streamContent?: string, streaming?: boolean, className?: string }} props
|
||||
*/
|
||||
export function MessageList({ messages, streamContent, streaming, className }) {
|
||||
const scrollRef = useRef(null);
|
||||
const wasAtBottom = useRef(true);
|
||||
|
||||
// Track scroll position
|
||||
function _onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
wasAtBottom.current = (el.scrollHeight - el.scrollTop - el.clientHeight) < SCROLL_THRESHOLD;
|
||||
}
|
||||
|
||||
// Auto-scroll when new content arrives (only if was at bottom)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && wasAtBottom.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages, streamContent]);
|
||||
|
||||
const hasContent = messages.length > 0 || streaming;
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
|
||||
ref=${scrollRef}
|
||||
onScroll=${_onScroll}>
|
||||
${!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} />`)}
|
||||
${streaming && html`
|
||||
<${MessageBubble} role="assistant" content=${streamContent} streaming=${true} />`}
|
||||
</div>`;
|
||||
}
|
||||
47
src/js/sw/components/chat-pane/model-selector.js
Normal file
47
src/js/sw/components/chat-pane/model-selector.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — ModelSelector Component
|
||||
// ==========================================
|
||||
// Model dropdown loaded from sw.api.models.enabled().
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${ModelSelector} value=${modelId} onChange=${fn} />`
|
||||
|
||||
const { useState, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{ value: string, onChange: (id: string) => void }} props
|
||||
*/
|
||||
export function ModelSelector({ value, onChange }) {
|
||||
const [models, setModels] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.sw?.api?.models?.enabled) return;
|
||||
let cancelled = false;
|
||||
window.sw.api.models.enabled().then(resp => {
|
||||
if (cancelled) return;
|
||||
const list = resp?.data || resp || [];
|
||||
setModels(list.filter(m => !m.hidden));
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (!models.length) return null;
|
||||
|
||||
function _onChange(e) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
|
||||
return html`
|
||||
<select class="sw-model-selector__select"
|
||||
title="Select model"
|
||||
value=${value}
|
||||
onChange=${_onChange}>
|
||||
${models.map(m => {
|
||||
const id = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);
|
||||
return html`<option key=${id} value=${id}>${label}</option>`;
|
||||
})}
|
||||
</select>`;
|
||||
}
|
||||
155
src/js/sw/components/chat-pane/use-chat.js
Normal file
155
src/js/sw/components/chat-pane/use-chat.js
Normal file
@@ -0,0 +1,155 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — useChat Hook
|
||||
// ==========================================
|
||||
// Core chat state machine: messages, channel CRUD, send/receive,
|
||||
// model selection. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// const chat = useChat({ onChannelChange });
|
||||
// chat.sendMessage('Hello');
|
||||
|
||||
import { useStream } from './use-stream.js';
|
||||
|
||||
const { useState, useRef, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
|
||||
*/
|
||||
export function useChat(opts = {}) {
|
||||
const { initialChannelId, getContext, onChannelChange } = opts;
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [channelId, setChannelId] = useState(initialChannelId || null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [model, setModel] = useState('');
|
||||
|
||||
const channelRef = useRef(channelId);
|
||||
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
|
||||
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 ───────────────────────
|
||||
const sendMessage = useCallback(async (text) => {
|
||||
if (!text.trim() || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
let cid = channelRef.current;
|
||||
|
||||
// Create channel if needed
|
||||
if (!cid) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
|
||||
const resp = await window.sw.api.channels.create({ title, model_id: model, channel_type: 'direct' });
|
||||
cid = resp.id;
|
||||
channelRef.current = cid;
|
||||
setChannelId(cid);
|
||||
if (onChannelChange) onChannelChange(cid);
|
||||
} catch (e) {
|
||||
setError('Failed to create chat: ' + e.message);
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Append user message optimistically
|
||||
const userMsg = { role: 'user', content: text };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
try {
|
||||
// Build content with optional context injection
|
||||
let content = text;
|
||||
if (getContext) {
|
||||
const ctx = getContext();
|
||||
if (ctx) {
|
||||
content = text + '\n\n[Context: Currently editing ' + ctx.path + ']\n```\n' + ctx.content + '\n```';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, model, getContext, onChannelChange, startStream]);
|
||||
|
||||
// Finalize: when streaming ends, capture the final content into messages
|
||||
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 ─────────────────────
|
||||
const switchChannel = useCallback(async (id) => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
channelRef.current = id;
|
||||
setChannelId(id);
|
||||
if (onChannelChange) onChannelChange(id);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: 100 });
|
||||
setMessages(resp?.data || resp || []);
|
||||
} catch (e) {
|
||||
setError('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}, [onChannelChange]);
|
||||
|
||||
// ── New Chat ───────────────────────────
|
||||
const newChat = useCallback(() => {
|
||||
stopStream();
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
channelRef.current = null;
|
||||
setChannelId(null);
|
||||
if (onChannelChange) onChannelChange(null);
|
||||
}, [stopStream, onChannelChange]);
|
||||
|
||||
// ── Stop ───────────────────────────────
|
||||
const stop = useCallback(() => {
|
||||
stopStream();
|
||||
setSending(false);
|
||||
}, [stopStream]);
|
||||
|
||||
// ── Clear Error ────────────────────────
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
channelId,
|
||||
sending,
|
||||
error,
|
||||
model,
|
||||
setModel,
|
||||
sendMessage,
|
||||
switchChannel,
|
||||
newChat,
|
||||
stop,
|
||||
clearError,
|
||||
// Streaming state (passthrough from useStream)
|
||||
streamContent,
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
103
src/js/sw/components/chat-pane/use-stream.js
Normal file
103
src/js/sw/components/chat-pane/use-stream.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — useStream Hook
|
||||
// ==========================================
|
||||
// SSE ReadableStream parser with rAF-batched state updates.
|
||||
// Independently importable — no ChatPane dependency.
|
||||
//
|
||||
// Usage:
|
||||
// const { streamContent, streaming, startStream, stopStream } = useStream();
|
||||
// // Pass a Response from sw.api.channels.complete():
|
||||
// startStream(response);
|
||||
|
||||
const { useState, useRef, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* Hook that parses an SSE stream from a fetch Response.
|
||||
*
|
||||
* @returns {{ streamContent: string, streaming: boolean, startStream: (resp: Response) => void, stopStream: () => void }}
|
||||
*/
|
||||
export function useStream() {
|
||||
const [streamContent, setStreamContent] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const contentRef = useRef('');
|
||||
const abortRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
|
||||
const _flush = useCallback(() => {
|
||||
setStreamContent(contentRef.current);
|
||||
rafRef.current = null;
|
||||
}, []);
|
||||
|
||||
const _scheduleFlush = useCallback(() => {
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(_flush);
|
||||
}
|
||||
}, [_flush]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStreamContent(contentRef.current);
|
||||
setStreaming(false);
|
||||
}, []);
|
||||
|
||||
const startStream = useCallback(async (response) => {
|
||||
// Reset
|
||||
contentRef.current = '';
|
||||
setStreamContent('');
|
||||
setStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
contentRef.current += delta;
|
||||
_scheduleFlush();
|
||||
}
|
||||
} catch (_) { /* skip malformed JSON */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
contentRef.current += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
}
|
||||
|
||||
// Final flush
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStreamContent(contentRef.current);
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
}, [_scheduleFlush]);
|
||||
|
||||
return { streamContent, streaming, startStream, stopStream };
|
||||
}
|
||||
@@ -116,8 +116,19 @@ export async function boot() {
|
||||
});
|
||||
};
|
||||
|
||||
// ChatPane render helper — surfaces call sw.chatPane(container, opts)
|
||||
// Returns Promise<imperative handle> for backward-compat with old ChatPane API.
|
||||
sw.chatPane = function (container, opts = {}) {
|
||||
return import('../components/chat-pane/index.js').then(({ ChatPane }) => {
|
||||
const handleRef = { current: null };
|
||||
const { render } = preact;
|
||||
render(html`<${ChatPane} handleRef=${handleRef} ...${opts} />`, container);
|
||||
return handleRef.current;
|
||||
});
|
||||
};
|
||||
|
||||
// Marker for idempotency
|
||||
sw._sdk = '0.37.6';
|
||||
sw._sdk = '0.37.8';
|
||||
|
||||
// 8. Expose globally
|
||||
window.sw = sw;
|
||||
@@ -139,7 +150,7 @@ export async function boot() {
|
||||
// 10. Signal ready
|
||||
events.emit('sdk.ready', {}, { localOnly: true });
|
||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||
console.log('[sw] SDK v0.37.5 ready');
|
||||
console.log('[sw] SDK v0.37.8 ready');
|
||||
|
||||
return sw;
|
||||
}
|
||||
|
||||
@@ -458,6 +458,11 @@ const Switchboard = {
|
||||
* @returns {object} ChatPane instance (renderMessages, destroy, etc.)
|
||||
*/
|
||||
sw.chat = function (container, opts) {
|
||||
// v0.37.8: Prefer Preact ChatPane kit when new SDK is loaded
|
||||
if (window.sw?.chatPane) {
|
||||
return window.sw.chatPane(container, opts || {});
|
||||
}
|
||||
// Legacy fallback (DOM-binding ChatPane.mount)
|
||||
if (typeof ChatPane === 'undefined') {
|
||||
console.error('[Switchboard] ChatPane not available');
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user