Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
1345 lines
54 KiB
JavaScript
1345 lines
54 KiB
JavaScript
// ==========================================
|
||
// 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);
|