This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/chat.js
Jeffrey Smith 8fee53e440 Changeset 0.30.1 (#200)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-18 13:22:15 +00:00

1345 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard Chat Operations
// ==========================================
// Chat management, send, stream, regenerate, edit, branch,
// summarize, per-chat model persistence.
//
// Exports: ChatInput, loadChats, selectChat, newChat, newGroupChat,
// deleteChat, startRenameChat, sendMessage, stopGeneration,
// reloadActivePath, regenerateMessage, regenerate, editMessage,
// submitEdit, cancelEdit, switchSibling, advanceWorkflowStage,
// rejectWorkflowStage, _initChatListeners,
// summarizeAndContinue, toggleChannelAutoCompact
// ── Chat Input Abstraction ──────────────────
// Wraps CM6 editor (when available) or fallback textarea.
// All code that reads/writes the message input goes through this.
const ChatInput = {
_editor: null, // CM6 instance, set during init
_textarea: null, // fallback textarea element
getValue() {
if (this._editor) return this._editor.getValue();
return this._textarea?.value || '';
},
setValue(text) {
if (this._editor) {
this._editor.setValue(text || '');
} else if (this._textarea) {
this._textarea.value = text || '';
this._textarea.style.height = 'auto';
}
},
focus() {
if (this._editor) this._editor.focus();
else this._textarea?.focus();
},
/** Get the DOM element (for paste listeners, etc.) */
getDom() {
if (this._editor) return this._editor.getView().contentDOM;
return this._textarea;
},
/** Get the visible input element (for positioning, getBoundingClientRect) */
get el() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Get the wrapper DOM element (for resize, etc.) */
getWrapDom() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Get cursor byte offset (for @mention autocomplete) */
getCursorPos() {
if (this._editor) {
const view = this._editor.getView();
return view.state.selection.main.head;
}
return this._textarea?.selectionStart || 0;
},
/** Set cursor byte offset (for @mention autocomplete) */
setCursorPos(pos) {
if (this._editor) {
const view = this._editor.getView();
view.dispatch({ selection: { anchor: pos } });
} else if (this._textarea) {
this._textarea.selectionStart = this._textarea.selectionEnd = pos;
}
},
/** Initialize — try CM6, fall back to textarea */
init() {
this._textarea = document.getElementById('messageInput');
const wrap = document.getElementById('messageInputWrap');
if (window.CM?.chatInput && wrap) {
// Hide the textarea, create CM6 editor in its place
this._textarea.style.display = 'none';
this._editor = CM.chatInput(wrap, {
placeholder: 'Type a message\u2026',
onSubmit: () => sendMessage(),
onChange: () => {
updateInputTokens();
// @mention autocomplete (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput);
// v0.23.2: Typing indicator for multi-user channels
_emitTyping();
},
maxHeight: 200,
});
// @mention autocomplete: intercept keys on CM6 contentDOM (v0.20.0)
this._editor.getView().contentDOM.addEventListener('keydown', (e) => {
if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) {
e.preventDefault();
e.stopPropagation();
}
});
DebugLog?.push?.('chat', '[CM6] Chat input initialized');
} else {
// Fallback: textarea with manual event handling
DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback');
this._textarea.addEventListener('keydown', (e) => {
// @mention autocomplete intercepts arrow/enter/escape (v0.20.0)
if (typeof ChannelModels !== 'undefined' && ChannelModels.handleKey(e)) return;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
this._textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
// @mention autocomplete (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this);
// v0.23.2: Typing indicator
_emitTyping();
});
}
},
};
// v0.23.2: Debounced typing indicator for multi-user channels
let _typingTimer = null;
function _emitTyping() {
if (!App.activeId) return;
// Only emit for multi-user conversation types
const type = App.activeConversation?.type;
if (type !== 'dm' && type !== 'channel' && type !== 'group') return;
// Debounce: emit at most once every 3s
if (_typingTimer) return;
_typingTimer = setTimeout(() => { _typingTimer = null; }, 3000);
API._post(`/api/v1/channels/${App.activeId}/typing`, {}).catch(() => {});
}
// ── Summarize & Continue ──────────────────
async function summarizeAndContinue() {
const channelId = App.activeId;
if (!channelId) return;
const btn = document.getElementById('summarizeBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Summarizing…';
}
try {
const result = await API.summarizeChannel(channelId);
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
// Reload the active path to get the updated message tree
const resp = await API.getActivePath(channelId);
const chat = App.chats.find(c => c.id === channelId);
if (chat && resp) {
chat.messages = (resp.messages || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
participant_type: m.participant_type || null,
participant_id: m.participant_id || null,
sender_name: m.sender_name || null,
sender_avatar: m.sender_avatar || null,
}));
UI.renderMessages(chat.messages);
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
}
} catch (err) {
console.error('Summarize failed:', err);
UI.toast(err.message || 'Summarization failed', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📝 Summarize & Continue';
}
}
}
// ── Per-Channel Auto-Compaction Toggle ──────
async function toggleChannelAutoCompact(enabled) {
const chatId = App.activeId;
if (!chatId) return;
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
// Update local state
if (!chat.settings) chat.settings = {};
chat.settings.auto_compaction = enabled;
// Persist to server (JSONB merge)
try {
await API.updateChannel(chatId, { settings: { auto_compaction: enabled } });
} catch (e) {
console.warn('Failed to save auto-compaction setting:', e.message);
}
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChannels(1, 100);
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
type: c.type || 'direct',
model: c.model || '',
messageCount: c.message_count || 0,
projectId: c.project_id || null,
folderId: c.folder || null,
workspace_id: c.workspace_id || null,
messages: [],
updatedAt: c.updated_at,
settings: c.settings || {},
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
}
}
async function selectChat(chatId) {
clearStaged(); // Discard any staged files from previous chat
// v0.28.6: Reset virtual scroll when switching conversations
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
const chat = App.chats.find(c => c.id === chatId);
App.setActive(chatId, chat?.type || 'direct');
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
UI.renderChatList();
UI.renderChannelsSection();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
if (!chat) return;
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.getActivePath(chatId);
chat.messages = (resp.messages || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
participant_type: m.participant_type || null,
participant_id: m.participant_id || null,
sender_name: m.sender_name || null,
sender_avatar: m.sender_avatar || null,
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
// Restore the last-used model/persona for this chat
_restoreChatModel(chatId, chat);
// Load multi-model roster for @mention routing (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chatId);
// Load files for message rendering (non-blocking, best-effort)
await loadChannelFiles(chatId);
UI.renderMessages(chat.messages);
UI.updateContextBanner();
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
// Refresh KB toggle state for the new channel
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
updateChatTokenCount();
// Notify surfaces and extensions about channel switch (v0.21.6)
sw.emit('chat.switched', { chatId, projectId: chat.projectId || null }, { localOnly: true });
// Update browser URL to reflect selected chat
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
// v0.23.2: Mark as read (non-blocking)
API.markRead(chatId).catch(() => {});
}
// ── Chat Header Token Count ──────────────────
function updateChatTokenCount() {
const el = document.getElementById('chatTokenCount');
if (!el) return;
const chat = App.getActiveChat();
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
const budget = Tokens.getContextBudget();
if (budget.maxContext > 0) {
const pct = tokens / budget.maxContext;
el.textContent = `${Tokens.format(tokens)} / ${Tokens.format(budget.maxContext)}`;
el.className = 'chat-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
} else {
el.textContent = `~${Tokens.format(tokens)}`;
el.className = 'chat-token-count';
}
}
// ── Per-Chat Model/Persona Persistence ────────
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
// localStorage used as write-through cache for instant restore without
// waiting for the channel list API call.
function _saveChatModel(chatId) {
if (!chatId) return;
const selectorId = UI.getModelValue();
if (!selectorId) return;
// Write-through: localStorage for instant restore on next visit
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
map[chatId] = selectorId;
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* quota exceeded, etc */ }
// Update in-memory chat object
const chat = App.chats.find(c => c.id === chatId);
if (chat) {
if (!chat.settings) chat.settings = {};
chat.settings.last_selector_id = selectorId;
}
// Persist to server (fire-and-forget — non-critical)
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
.catch(e => console.debug('Failed to persist model selection:', e.message));
}
function _restoreChatModel(chatId, chat) {
// Priority: server settings → localStorage fallback → channel base model → keep current
let targetId = null;
// 1. Server-side settings (most authoritative, roams across devices)
if (chat?.settings?.last_selector_id) {
targetId = chat.settings.last_selector_id;
}
// 2. localStorage fallback (covers race where settings haven't loaded yet)
if (!targetId) {
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
if (map[chatId]) targetId = map[chatId];
} catch (e) { /* */ }
}
// 3. Verify the stored ID still exists in available models
if (targetId) {
const found = App.models.find(m => m.id === targetId);
if (found) {
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
return;
}
// Stored model removed — clean up stale entries
_cleanStaleChatModel(chatId);
}
// 4. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPersona && !m.hidden);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
return;
}
}
// 5. Stored model gone — fall back to admin default → first visible
const visible = App.models.filter(m => !m.hidden);
const def = App.defaultModel
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
: null;
const fallback = def || visible[0];
if (fallback) {
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
}
}
function _cleanStaleChatModel(chatId) {
// Remove from localStorage
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
// Clear from in-memory settings (server cleanup is fire-and-forget)
const chat = App.chats.find(c => c.id === chatId);
if (chat?.settings?.last_selector_id) {
delete chat.settings.last_selector_id;
}
}
async function newChat() {
clearStaged(); // Discard any staged files
if (typeof VirtualScroll !== 'undefined') VirtualScroll.destroy();
App.setActive(null);
UI.renderChatList();
UI.renderChannelsSection();
UI.showEmptyState();
UI.showRegenerate(false);
Tokens._warningDismissed = false;
updateContextWarning(); updateChatTokenCount();
updateInputTokens();
ChatInput.focus();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
// Notify surfaces — no channel selected (v0.21.6)
sw.emit('chat.switched', { chatId: null, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
}
// ── New Group Chat (v0.23.0) ─────────────────
// Creates a group channel and adds persona participants.
// Personas can @mention each other → AI-to-AI chaining.
async function newGroupChat() {
const personas = (App.models || []).filter(m => m.isPersona);
if (personas.length === 0) {
UI.toast('No personas available. Ask your admin to create one.', 'warning');
return;
}
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
const personaCheckboxes = personas.map(p => {
const scope = p.personaScope || 'global';
const badge = scope === 'personal' ? ' <span class="dd-hint">personal</span>'
: scope === 'team' ? ` <span class="dd-hint">${esc(p.personaTeamName || 'team')}</span>` : '';
const handle = p.personaHandle ? ` <span class="group-persona-handle">@${esc(p.personaHandle)}</span>` : '';
return `<label class="group-persona-item">
<input type="checkbox" value="${esc(p.personaId)}" data-name="${esc(p.name)}" data-handle="${esc(p.personaHandle || '')}">
<span class="group-persona-name">${esc(p.name)}${handle}${badge}</span>
<span class="group-persona-model">${esc(p.baseModelId || '')}</span>
</label>`;
}).join('');
overlay.innerHTML = `
<div class="confirm-dialog" style="max-width:480px">
<div class="confirm-header">New Group Chat</div>
<div class="confirm-body">
<div style="margin-bottom:12px">
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:4px">Channel Name</label>
<input id="groupChatTitle" type="text" class="settings-input" placeholder="e.g. Code Review Team" style="width:100%;box-sizing:border-box" autofocus>
</div>
<div>
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:6px">Add Personas (select at least one)</label>
<div class="group-persona-list" style="max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">
${personaCheckboxes}
</div>
</div>
</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">Cancel</button>
<button class="btn-small btn-primary" data-action="ok">Create</button>
</div>
</div>`;
function close(result) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') close(false);
}
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
overlay.querySelector('[data-action="ok"]').addEventListener('click', async () => {
const title = overlay.querySelector('#groupChatTitle').value.trim() || 'Group Chat';
const checked = [...overlay.querySelectorAll('.group-persona-list input:checked')];
if (checked.length === 0) {
UI.toast('Select at least one persona', 'warning');
return;
}
try {
// Create group channel
const resp = await API.createChannel(title, null, null, 'group');
const chat = {
id: resp.id, title: resp.title, type: 'group', model: null,
messages: [], messageCount: 0, projectId: resp.project_id || null,
workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at,
settings: resp.settings || {}
};
// Add persona participants
for (const cb of checked) {
try {
await API.addParticipant(chat.id, {
participant_type: 'persona',
participant_id: cb.value,
role: 'member'
});
} catch (e) {
console.warn('Failed to add persona:', cb.dataset.name, e.message);
}
}
App.chats.unshift(chat);
App.setActive(chat.id, 'group');
UI.renderChatList();
sw.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
// Load the channel model roster (populated by participant auto-add)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chat.id);
UI.showEmptyState();
const names = checked.map(cb => cb.dataset.name).join(', ');
UI.toast(`Group created with ${checked.length} persona${checked.length > 1 ? 's' : ''}: ${names}`, 'success');
ChatInput.focus();
} catch (e) {
UI.toast('Failed to create group: ' + e.message, 'error');
}
close(true);
});
document.body.appendChild(overlay);
overlay.querySelector('#groupChatTitle').focus();
});
}
async function deleteChat(chatId) {
if (!await showConfirm('Delete this chat?')) return;
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
// Clean up stored model preference
try {
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
delete map[chatId];
localStorage.setItem('cs-chat-models', JSON.stringify(map));
} catch (e) { /* */ }
if (App.activeId === chatId) { clearPreview(); newChat(); }
UI.renderChatList();
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
}
// ── Auto-Name Chat (utility model) ───────────
let _autoNamePending = new Set(); // debounce: one inflight per chat
async function autoNameChat(chatId, chat) {
if (!chatId || !chat) return;
if (_autoNamePending.has(chatId)) return;
// Only auto-name if title looks auto-generated (first 50 chars of user msg or "New Chat")
const firstUserMsg = chat.messages.find(m => m.role === 'user');
if (!firstUserMsg) return;
const truncated = firstUserMsg.content.slice(0, 50).trim();
const currentTitle = (chat.title || '').trim();
const isAutoGenerated = !currentTitle ||
currentTitle === 'New Chat' ||
currentTitle === truncated ||
truncated.startsWith(currentTitle);
if (!isAutoGenerated) return;
_autoNamePending.add(chatId);
try {
const resp = await API.generateTitle(chatId);
if (resp?.title && resp.title !== chat.title) {
chat.title = resp.title;
UI.renderChatList();
}
} catch (e) {
// Silently fail — keep truncated title
console.debug('Auto-name failed:', e.message);
} finally {
_autoNamePending.delete(chatId);
}
}
// ── Chat Rename (inline edit) ────────────────
function startRenameChat(chatId) {
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
// Find the title span for this chat
const items = document.querySelectorAll('.chat-item');
let titleSpan = null;
for (const item of items) {
if (item.getAttribute('onclick')?.includes(chatId)) {
titleSpan = item.querySelector('.chat-item-title');
break;
}
}
if (!titleSpan) return;
// Replace span with input
const input = document.createElement('input');
input.type = 'text';
input.className = 'chat-rename-input';
input.value = chat.title || '';
input.onclick = (e) => e.stopPropagation();
input.ondblclick = (e) => e.stopPropagation();
const commit = async () => {
const newTitle = input.value.trim();
if (newTitle && newTitle !== chat.title) {
try {
await API.updateChannel(chatId, { title: newTitle });
chat.title = newTitle;
} catch (e) {
UI.toast('Rename failed: ' + e.message, 'error');
}
}
UI.renderChatList();
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { input.value = chat.title || ''; input.blur(); }
});
titleSpan.replaceWith(input);
input.focus();
input.select();
}
// ── Send Message ─────────────────────────────
// v0.28.5: Build context object for pre-send pipe filters.
function _buildPreSendContext(opts) {
const chat = opts.channel || {};
return {
message: opts.message || '',
channel: { id: chat.id || null, type: chat.type || 'direct', title: chat.title || '' },
attachments: opts.attachments || [],
model: opts.model || null,
persona: opts.personaId || null,
metadata: {},
regenerate: opts.regenerate || false,
regenerateMessageId: opts.regenerateMessageId || null,
};
}
async function sendMessage() {
const text = ChatInput.getValue().trim();
const hasFiles = hasStagedFiles();
// Need text or files, not generating, not blocked by uploads
if ((!text && !hasFiles) || App.isGenerating || isSendBlocked()) return;
ChatInput.setValue('');
// Snapshot file IDs before clearing staged state
const fileIds = getStagedFileIds();
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For personas, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
if (!App.activeId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
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, projectId: resp.project_id || null, workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at, settings: resp.settings || {} };
// Auto-assign to active project (v0.19.1)
if (App.activeProjectId && !chat.projectId) {
try {
await API.addChannelToProject(App.activeProjectId, chat.id, 0);
chat.projectId = App.activeProjectId;
} catch (_) { /* best effort — chat still works without project */ }
}
App.chats.unshift(chat);
App.setActive(chat.id, 'direct');
UI.renderChatList();
// Notify surfaces about new chat creation (v0.21.6)
sw.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}
const chat = App.getActiveChat();
if (!chat) return;
// ── v0.28.5: Pre-send pipe ──────────────────────────────
// Filters can transform message, inject metadata, or halt (return null).
if (typeof sw !== 'undefined' && sw.pipe) {
const preSendCtx = _buildPreSendContext({
message: text, channel: chat, model, personaId,
attachments: fileIds, regenerate: false,
});
const result = sw.pipe._runPre(preSendCtx);
if (result === null) {
// Filter halted — suppress message
UI.toast('Message blocked by extension', 'warning');
return;
}
// Apply any mutations from filters — model/persona may have changed
// but message text is the most common mutation (KB inject, rewrite).
// Note: we don't overwrite `text` (const) — the API call reads from ctx.
var _preSendText = result.message;
var _preSendModel = result.model || model;
var _preSendPersonaId = result.persona || personaId;
var _preSendMeta = result.metadata || {};
} else {
var _preSendText = text;
var _preSendModel = model;
var _preSendPersonaId = personaId;
var _preSendMeta = {};
}
// Optimistic: show user message immediately
const userContent = text || (hasFiles ? `[${fileIds.length} file${fileIds.length > 1 ? 's' : ''} attached]` : '');
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
// Clear staged files (they're now part of this message)
if (hasFiles) consumeStaged();
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.activeId, _preSendText, _preSendModel, App.abortController.signal, modelInfo?.configId, _preSendPersonaId, fileIds,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('application/json')) {
const data = await resp.json();
if (data.ai_skipped) {
// Message was persisted server-side; reload to get proper IDs
await reloadActivePath();
return;
}
}
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
// Reload active path from server to get proper IDs and sibling metadata
await reloadActivePath();
UI.showRegenerate(true);
// Persist the model/persona selection for this chat
_saveChatModel(App.activeId);
// Auto-name: title the chat after first assistant response (v0.17.0)
if (chat.messages.length <= 3) {
autoNameChat(App.activeId, chat);
}
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Completion error:', e);
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings', 'error');
} else {
UI.toast(msg, 'error');
}
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
// ── Reload Active Path from Server ──────────
async function reloadActivePath() {
if (!App.activeId) return;
const chat = App.getActiveChat();
if (!chat) return;
try {
const resp = await API.getActivePath(App.activeId);
chat.messages = (resp.messages || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
participant_type: m.participant_type || null,
participant_id: m.participant_id || null,
sender_name: m.sender_name || null,
sender_avatar: m.sender_avatar || null,
}));
chat.messageCount = chat.messages.length;
await loadChannelFiles(App.activeId);
UI.renderMessages(chat.messages);
updateContextWarning(); updateChatTokenCount();
} catch (e) {
console.error('Failed to reload path:', e.message);
}
}
// ── Regenerate (tree-aware: creates sibling) ─
async function regenerateMessage(messageId) {
if (App.isGenerating || !App.activeId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
// Truncate display to the parent of the message being regenerated.
// This makes the stream appear in the correct position — as a fresh
// response to the parent user message, not appended at the bottom.
const chat = App.getActiveChat();
if (chat) {
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
if (msgIdx > 0) {
const truncated = chat.messages.slice(0, msgIdx);
UI.renderMessages(truncated);
}
}
// ── v0.28.5: Pre-send pipe (regeneration path) ──────────
if (typeof sw !== 'undefined' && sw.pipe) {
const preSendCtx = _buildPreSendContext({
message: '', channel: chat, model, personaId,
regenerate: true, regenerateMessageId: messageId,
});
const result = sw.pipe._runPre(preSendCtx);
if (result === null) {
UI.toast('Regeneration blocked by extension', 'warning');
// Restore display since we truncated above
if (chat) UI.renderMessages(chat.messages);
return;
}
var _regenModel = result.model || model;
var _regenPersonaId = result.persona || personaId;
} else {
var _regenModel = model;
var _regenPersonaId = personaId;
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamRegenerate(
App.activeId, messageId, App.abortController.signal,
_regenModel, _regenPersonaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Reload from server to get proper tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); }
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
// Legacy: bottom-bar regenerate button targets the last assistant message
async function regenerate() {
if (App.isGenerating || !App.activeId) return;
const chat = App.getActiveChat();
if (!chat || chat.messages.length === 0) return;
// Find the last assistant message with an ID
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
return regenerateMessage(chat.messages[i].id);
}
}
UI.toast('No assistant message to regenerate', 'warning');
}
// ── Edit Message (creates sibling, triggers completion) ──
async function editMessage(messageId) {
if (App.isGenerating || !App.activeId) return;
const chat = App.getActiveChat();
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
if (!msg || msg.role !== 'user') return;
// Show inline edit UI
UI.showEditInline(messageId, msg.content);
}
async function submitEdit(messageId, newContent) {
if (App.isGenerating || !App.activeId) return;
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.getActiveChat();
// Step 1: Create sibling user message via edit endpoint
const editResp = await API.editMessage(App.activeId, messageId, newContent.trim());
// Step 2: Reload path to show the new edited message
await reloadActivePath();
// Step 3: Generate assistant response as child of the new edit.
// Uses regenerate endpoint (which handles user messages by creating
// a child response, not a sibling). This avoids the duplicate user
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.activeId, editResp.id, App.abortController.signal,
model, personaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Step 4: Final reload to get the complete tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Edit error:', e);
UI.toast(e.message || 'Edit failed', 'error');
await reloadActivePath();
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function cancelEdit() {
// Re-render to remove the edit form
const chat = App.getActiveChat();
if (chat) UI.renderMessages(chat.messages);
}
// ── Switch Branch (sibling navigation) ──────
async function switchSibling(messageId, direction) {
if (App.isGenerating || !App.activeId) return;
try {
// Get siblings for this message
const data = await API.listSiblings(App.activeId, messageId);
const siblings = data.siblings || [];
const currentIdx = data.current_index ?? 0;
const targetIdx = currentIdx + direction;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const targetSibling = siblings[targetIdx];
// Update cursor to the target sibling (backend walks to leaf)
const resp = await API.updateCursor(App.activeId, targetSibling.id);
// Update local state with the new path
const chat = App.getActiveChat();
if (chat && resp.messages) {
chat.messages = resp.messages.map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
metadata: m.metadata || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
participant_type: m.participant_type || null,
participant_id: m.participant_id || null,
sender_name: m.sender_name || null,
sender_avatar: m.sender_avatar || null,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
} catch (e) {
console.error('Branch switch failed:', e.message);
UI.toast('Failed to switch branch', 'error');
}
}
// ── Chat Listeners (extracted from initListeners) ──
function _initChatListeners() {
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', () => newChat());
// User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation();
UI.toggleUserMenu();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
});
// Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Sidebar chat search
const _chatSearchInput = document.getElementById('chatSearchInput');
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
// Model selector (custom dropdown)
UI.initModelDropdown();
UI.initAppearance();
// Mobile hamburger
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('sidebarOverlay').style.display = 'none';
localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModelsAndUpdateUI();
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Input — CM6 or textarea fallback
ChatInput.init();
// Multi-model channel roster (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.init();
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
// When a persona @mentions another persona, the follow-up completion
// runs server-side and delivers the result via WS, not SSE.
sw.on('message.created', (payload) => {
if (!payload || !payload.channel_id) return;
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
const knownChannel = (App.channels || []).some(c => c.id === payload.channel_id);
const knownChat = App.chats.some(c => c.id === payload.channel_id);
if (!knownChannel && !knownChat) {
// New channel we're a participant in — reload channels sidebar
if (typeof loadChannels === 'function') loadChannels();
UI.toast(`New message from ${payload.display_name || payload.user_id?.slice(0, 8) || 'someone'}`, 'info');
return;
}
// If it's a channel/DM we know but it's not the active one, bump unread
if (payload.channel_id !== App.activeId) {
const ch = (App.channels || []).find(c => c.id === payload.channel_id);
if (ch) {
ch.unread = (ch.unread || 0) + 1;
if (typeof UI !== 'undefined') UI.renderChannelsSection();
}
return;
}
const chat = App.chats.find(c => c.id === payload.channel_id);
if (!chat) return;
// Append the chained message
chat.messages.push({
id: payload.id,
parent_id: null,
role: payload.role || 'assistant',
content: payload.content || '',
model: payload.model || '',
modelName: payload.display_name || payload.model || '',
timestamp: new Date().toISOString(),
tool_calls: null,
metadata: payload.chain_depth ? { chain_depth: payload.chain_depth } : null,
siblingCount: 1,
siblingIndex: 0,
participant_type: payload.participant_type,
participant_id: payload.participant_id,
});
// Remove typing indicator and re-render
document.getElementById('typingIndicator')?.remove();
document.getElementById('userTypingIndicator')?.remove();
UI.renderMessages(chat.messages);
});
// Typing indicators from persona chain activity
sw.on('typing.start', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
if (payload.participant_type !== 'persona') return;
// Show typing indicator with persona name
document.getElementById('typingIndicator')?.remove();
document.getElementById('userTypingIndicator')?.remove();
const container = document.getElementById('chatMessages');
if (!container) return;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
const name = payload.display_name || 'AI';
typing.innerHTML = `
<div class="msg-row">
<div class="msg-avatar"><span class="avatar-emoji">⚡</span></div>
<div class="msg-body"><span class="chain-typing-name">${name}</span> <div class="typing-dots"><span></span><span></span><span></span></div></div>
</div>`;
container.appendChild(typing);
container.scrollTop = container.scrollHeight;
});
sw.on('typing.stop', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
document.getElementById('typingIndicator')?.remove();
document.getElementById('userTypingIndicator')?.remove();
});
// v0.23.2: Human typing indicator from other participants
sw.on('typing.user', (payload) => {
if (!payload || payload.channel_id !== App.activeId) return;
if (payload.user_id === sw.user?.id) return; // ignore self
// Show or refresh typing indicator
let typing = document.getElementById('userTypingIndicator');
if (!typing) {
const container = document.getElementById('chatMessages');
if (!container) return;
typing = document.createElement('div');
typing.id = 'userTypingIndicator';
typing.className = 'message other-user';
container.appendChild(typing);
}
const name = payload.display_name || 'Someone';
typing.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar" style="background:rgba(45,212,191,0.12);color:#2dd4bf;">👤</div>
<div class="msg-body" style="background:var(--bg-surface);border:1px solid var(--border);border-radius:16px 16px 16px 4px;padding:8px 12px;">
<span style="color:#2dd4bf;font-size:12px;font-weight:600;">${esc(name)}</span>
<div class="typing-dots"><span></span><span></span><span></span></div>
</div>
</div>`;
const container = document.getElementById('chatMessages');
if (container) container.scrollTop = container.scrollHeight;
// Auto-remove after 4s if no new event
clearTimeout(typing._timeout);
typing._timeout = setTimeout(() => typing.remove(), 4000);
});
// v0.23.2: User @mention notification
sw.on('user.mentioned', (payload) => {
if (!payload) return;
const channelId = payload.channel_id;
// Increment unread badge on the channel sidebar item
const ch = (App.channels || []).find(c => c.id === channelId);
if (ch) {
ch.unread = (ch.unread || 0) + 1;
if (typeof UI !== 'undefined') UI.renderChannelsSection();
}
// Toast with deep-link
const preview = payload.content || 'You were mentioned';
UI.toast(`@mention: ${preview}`, 'info');
});
// v0.23.2: Live presence updates for sidebar online dots
sw.on('user.presence', (payload) => {
if (!payload || !payload.user_id) return;
App.presence[payload.user_id] = payload.status || 'offline';
if (typeof UI !== 'undefined') UI.renderChannelsSection();
});
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
sw.on('workflow.advanced', (payload) => {
if (!payload) return;
const ac = App.activeConversation;
if (ac && ac.id === payload.channel_id) {
UI.updateWorkflowStageBar();
}
// Refresh queue sidebar if present
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
});
// v0.27.0: Workflow completed — refresh stage bar
sw.on('workflow.completed', (payload) => {
if (!payload) return;
const ac = App.activeConversation;
if (ac && ac.id === payload.channel_id) {
UI.updateWorkflowStageBar();
}
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
});
// v0.27.0: Workflow assigned — refresh queue + toast
sw.on('workflow.assigned', (payload) => {
if (!payload) return;
if (payload.assigned_to === sw.user?.id) {
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
}
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});
}
// v0.27.0: Advance workflow stage (called from stage bar button)
async function advanceWorkflowStage() {
var ac = App.activeConversation;
if (!ac || ac.type !== 'workflow') return;
var confirmed = await showConfirm('Advance to the next stage?', { title: 'Advance Workflow', danger: false, ok: 'Advance' });
if (!confirmed) return;
try {
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/advance', {});
UI.toast('Advanced to stage: ' + (resp.stage?.name || resp.current_stage), 'success');
UI.updateWorkflowStageBar();
} catch (e) {
UI.toast('Advance failed: ' + (e.message || e), 'error');
}
}
// v0.27.0: Reject workflow stage (called from stage bar button)
async function rejectWorkflowStage() {
var ac = App.activeConversation;
if (!ac || ac.type !== 'workflow') return;
var reason = await showPrompt({ title: 'Reject Stage', label: 'Reason for rejection:', placeholder: 'Enter reason...', ok: 'Reject' });
if (!reason) return;
try {
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/reject', { reason: reason });
UI.toast('Rejected to stage ' + resp.current_stage + ': ' + reason, 'warning');
UI.updateWorkflowStageBar();
} catch (e) {
UI.toast('Reject failed: ' + (e.message || e), 'error');
}
}
// ── Exports ─────────────────────────────────
sb.ns('ChatInput', ChatInput);
sb.register('loadChats', loadChats);
sb.register('selectChat', selectChat);
sb.register('newChat', newChat);
sb.register('newGroupChat', newGroupChat);
sb.register('deleteChat', deleteChat);
sb.register('startRenameChat', startRenameChat);
sb.register('sendMessage', sendMessage);
sb.register('stopGeneration', stopGeneration);
sb.register('reloadActivePath', reloadActivePath);
sb.register('regenerateMessage', regenerateMessage);
sb.register('regenerate', regenerate);
sb.register('editMessage', editMessage);
sb.register('submitEdit', submitEdit);
sb.register('cancelEdit', cancelEdit);
sb.register('switchSibling', switchSibling);
sb.register('advanceWorkflowStage', advanceWorkflowStage);
sb.register('rejectWorkflowStage', rejectWorkflowStage);
sb.register('_initChatListeners', _initChatListeners);
sb.register('summarizeAndContinue', summarizeAndContinue);
sb.register('toggleChannelAutoCompact', toggleChannelAutoCompact);