// ========================================== // 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' ? ' personal' : scope === 'team' ? ` ${esc(p.personaTeamName || 'team')}` : ''; const handle = p.personaHandle ? ` @${esc(p.personaHandle)}` : ''; return ``; }).join(''); overlay.innerHTML = `
New Group Chat
${personaCheckboxes}
`; 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 = `
${name}
`; 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 = `
👤
${esc(name)}
`; 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);