633 lines
24 KiB
JavaScript
633 lines
24 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Chat Operations
|
||
// ==========================================
|
||
// Chat management, send, stream, regenerate, edit, branch,
|
||
// summarize, per-chat model persistence.
|
||
|
||
// ── Summarize & Continue ──────────────────
|
||
|
||
async function summarizeAndContinue() {
|
||
const channelId = App.currentChatId;
|
||
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.path || []).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,
|
||
}));
|
||
UI.renderMessages(chat.messages);
|
||
updateContextWarning();
|
||
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';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Chat Management ──────────────────────────
|
||
|
||
async function loadChats() {
|
||
try {
|
||
const resp = await API.listChannels(1, 100, 'direct');
|
||
App.chats = (resp.data || []).map(c => ({
|
||
id: c.id,
|
||
title: c.title,
|
||
type: c.type || 'direct',
|
||
model: c.model || '',
|
||
messageCount: c.message_count || 0,
|
||
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 attachments from previous chat
|
||
App.currentChatId = chatId;
|
||
UI.renderChatList();
|
||
if (window.innerWidth <= 768) {
|
||
document.getElementById('sidebar').classList.add('collapsed');
|
||
const ov = document.getElementById('sidebarOverlay');
|
||
if (ov) ov.style.display = 'none';
|
||
}
|
||
|
||
const chat = App.chats.find(c => c.id === chatId);
|
||
if (!chat) return;
|
||
|
||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||
try {
|
||
const resp = await API.getActivePath(chatId);
|
||
chat.messages = (resp.path || []).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,
|
||
}));
|
||
} catch (e) {
|
||
console.error('Failed to load messages:', e.message);
|
||
UI.toast('Failed to load messages', 'error');
|
||
}
|
||
}
|
||
|
||
// Restore the last-used model/preset for this chat
|
||
_restoreChatModel(chatId, chat);
|
||
|
||
// Load attachments for message rendering (non-blocking, best-effort)
|
||
await loadChannelAttachments(chatId);
|
||
|
||
UI.renderMessages(chat.messages);
|
||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||
Tokens._warningDismissed = false;
|
||
updateContextWarning();
|
||
updateInputTokens();
|
||
}
|
||
|
||
// ── Per-Chat Model/Preset 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.isPreset && !m.hidden);
|
||
if (match) {
|
||
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 4. 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 attachments
|
||
App.currentChatId = null;
|
||
UI.renderChatList();
|
||
UI.showEmptyState();
|
||
UI.showRegenerate(false);
|
||
Tokens._warningDismissed = false;
|
||
updateContextWarning();
|
||
updateInputTokens();
|
||
document.getElementById('messageInput').focus();
|
||
if (window.innerWidth <= 768) {
|
||
document.getElementById('sidebar').classList.add('collapsed');
|
||
const ov = document.getElementById('sidebarOverlay');
|
||
if (ov) ov.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
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.currentChatId === chatId) { clearPreview(); newChat(); }
|
||
UI.renderChatList();
|
||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ── Send Message ─────────────────────────────
|
||
|
||
async function sendMessage() {
|
||
const input = document.getElementById('messageInput');
|
||
const text = input.value.trim();
|
||
const hasAttachments = hasStagedAttachments();
|
||
|
||
// Need text or attachments, not generating, not blocked by uploads
|
||
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
|
||
|
||
input.value = '';
|
||
input.style.height = 'auto';
|
||
|
||
// Snapshot attachment IDs before clearing staged state
|
||
const attachmentIds = getStagedAttachmentIds();
|
||
|
||
const selectedId = UI.getModelValue();
|
||
const modelInfo = App.findModel(selectedId);
|
||
// For presets, send the base model ID; for regular models, send the model ID
|
||
const model = modelInfo?.baseModelId || selectedId;
|
||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||
|
||
if (!App.currentChatId) {
|
||
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, updatedAt: resp.updated_at };
|
||
App.chats.unshift(chat);
|
||
App.currentChatId = chat.id;
|
||
UI.renderChatList();
|
||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||
}
|
||
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (!chat) return;
|
||
|
||
// Optimistic: show user message immediately
|
||
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.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 attachments (they're now part of this message)
|
||
if (hasAttachments) consumeStaged();
|
||
|
||
App.isGenerating = true;
|
||
App.abortController = new AbortController();
|
||
UI.setGenerating(true);
|
||
|
||
try {
|
||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
|
||
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/preset selection for this chat
|
||
_saveChatModel(App.currentChatId);
|
||
} 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.currentChatId) return;
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (!chat) return;
|
||
|
||
try {
|
||
const resp = await API.getActivePath(App.currentChatId);
|
||
chat.messages = (resp.path || []).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,
|
||
}));
|
||
chat.messageCount = chat.messages.length;
|
||
await loadChannelAttachments(App.currentChatId);
|
||
UI.renderMessages(chat.messages);
|
||
updateContextWarning();
|
||
} catch (e) {
|
||
console.error('Failed to reload path:', e.message);
|
||
}
|
||
}
|
||
|
||
// ── Regenerate (tree-aware: creates sibling) ─
|
||
|
||
async function regenerateMessage(messageId) {
|
||
if (App.isGenerating || !App.currentChatId) return;
|
||
|
||
const selectedId = UI.getModelValue();
|
||
const modelInfo = App.findModel(selectedId);
|
||
const model = modelInfo?.baseModelId || selectedId;
|
||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : 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.chats.find(c => c.id === App.currentChatId);
|
||
if (chat) {
|
||
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||
if (msgIdx > 0) {
|
||
const truncated = chat.messages.slice(0, msgIdx);
|
||
UI.renderMessages(truncated);
|
||
}
|
||
}
|
||
|
||
App.isGenerating = true;
|
||
App.abortController = new AbortController();
|
||
UI.setGenerating(true);
|
||
|
||
try {
|
||
const resp = await API.streamRegenerate(
|
||
App.currentChatId, messageId, App.abortController.signal,
|
||
model, presetId, modelInfo?.configId
|
||
);
|
||
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.currentChatId) return;
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
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.currentChatId) return;
|
||
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
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.currentChatId) return;
|
||
if (!newContent.trim()) return;
|
||
|
||
const selectedId = UI.getModelValue();
|
||
const modelInfo = App.findModel(selectedId);
|
||
const model = modelInfo?.baseModelId || selectedId;
|
||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||
|
||
App.isGenerating = true;
|
||
App.abortController = new AbortController();
|
||
UI.setGenerating(true);
|
||
|
||
try {
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
|
||
// Step 1: Create sibling user message via edit endpoint
|
||
const editResp = await API.editMessage(App.currentChatId, 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.currentChatId, editResp.id, App.abortController.signal,
|
||
model, presetId, modelInfo?.configId
|
||
);
|
||
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.chats.find(c => c.id === App.currentChatId);
|
||
if (chat) UI.renderMessages(chat.messages);
|
||
}
|
||
|
||
// ── Switch Branch (sibling navigation) ──────
|
||
|
||
async function switchSibling(messageId, direction) {
|
||
if (App.isGenerating || !App.currentChatId) return;
|
||
|
||
try {
|
||
// Get siblings for this message
|
||
const data = await API.listSiblings(App.currentChatId, 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.currentChatId, targetSibling.id);
|
||
|
||
// Update local state with the new path
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (chat && resp.path) {
|
||
chat.messages = resp.path.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,
|
||
}));
|
||
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);
|
||
|
||
// Split button dropdown
|
||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.split-btn')) {
|
||
document.getElementById('newChatDropdown').classList.remove('open');
|
||
}
|
||
});
|
||
|
||
// 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 fetchModels();
|
||
const visible = App.models.filter(m => !m.hidden).length;
|
||
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
|
||
});
|
||
|
||
// Input
|
||
const input = document.getElementById('messageInput');
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||
});
|
||
input.addEventListener('input', function() {
|
||
this.style.height = 'auto';
|
||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||
updateInputTokens();
|
||
});
|
||
|
||
// Close modals on overlay click
|
||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) closeModal(overlay.id);
|
||
});
|
||
});
|
||
}
|