1084 lines
50 KiB
JavaScript
1084 lines
50 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – UI Core
|
||
// ==========================================
|
||
// UI object definition: sidebar, chat list, messages, streaming,
|
||
// model selector, capability badges, user, generating state, toast,
|
||
// settings modal dispatcher, export, message actions, scroll.
|
||
// Extended by ui-settings.js and ui-admin.js via Object.assign.
|
||
|
||
// ── Avatar Helper ────────────────────────────
|
||
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
|
||
function avatarHTML(role, avatarDataURI) {
|
||
if (avatarDataURI) {
|
||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||
}
|
||
return role === 'user' ? '👤' : '🤖';
|
||
}
|
||
|
||
// Look up the current model/preset avatar for assistant messages.
|
||
function assistantAvatarURI(msg) {
|
||
// Try to find preset avatar from the model that generated this message
|
||
const modelId = msg?.model || msg?.preset_id;
|
||
if (modelId) {
|
||
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
|
||
if (m?.presetAvatar) return m.presetAvatar;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Shared Preset Form Component ────────────
|
||
// Renders a preset creation/edit form into a container element.
|
||
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
|
||
// Returns: { getValues(), setValues(preset), clearForm(), container }
|
||
function renderPresetForm(containerEl, options = {}) {
|
||
const pfx = options.prefix || 'pf';
|
||
const showAvatar = options.showAvatar !== false;
|
||
const showConfig = !!options.showProviderConfig;
|
||
|
||
containerEl.innerHTML = `
|
||
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
|
||
${showAvatar ? `
|
||
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
||
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
|
||
<span id="${pfx}_avatarPlaceholder">🤖</span>
|
||
</div>
|
||
<div class="avatar-actions">
|
||
<button class="btn-small" type="button" id="${pfx}_avatarUploadBtn">Upload Avatar</button>
|
||
<button class="btn-small btn-danger" type="button" id="${pfx}_avatarRemoveBtn" style="display:none">Remove</button>
|
||
<input type="file" id="${pfx}_avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
|
||
<div class="form-hint">Optional · 128×128 · shown in chat</div>
|
||
</div>
|
||
</div>` : ''}
|
||
<div class="form-group"><label>Description</label><input type="text" id="${pfx}_desc" placeholder="Reviews code for best practices"></div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label>Base Model</label><select id="${pfx}_model"></select></div>
|
||
${showConfig ? `<div class="form-group"><label>Provider Config</label><select id="${pfx}_config"><option value="">Auto-resolve</option></select></div>` : ''}
|
||
</div>
|
||
<div class="form-group"><label>System Prompt</label><textarea id="${pfx}_prompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
|
||
<div class="form-row">
|
||
<div class="form-group"><label>Temperature</label><input type="number" id="${pfx}_temp" step="0.1" min="0" max="2" placeholder="default"></div>
|
||
<div class="form-group"><label>Max Tokens</label><input type="number" id="${pfx}_maxTokens" placeholder="default"></div>
|
||
</div>
|
||
<div class="form-group memory-persona-section" style="border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||
<label class="checkbox-label" style="font-weight:500"><input type="checkbox" id="${pfx}_memoryEnabled" checked> Enable Memory</label>
|
||
<p class="form-hint" style="margin:2px 0 6px">When enabled, the AI can save and recall facts about users across conversations.</p>
|
||
<div id="${pfx}_memoryExtractionPromptWrap">
|
||
<label>Custom Extraction Prompt <span class="form-hint">(optional)</span></label>
|
||
<textarea id="${pfx}_memoryExtractionPrompt" rows="2" placeholder="Override the default extraction prompt for this persona..."></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
|
||
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
||
</div>
|
||
`;
|
||
|
||
// Avatar wiring
|
||
if (showAvatar) {
|
||
const uploadBtn = document.getElementById(`${pfx}_avatarUploadBtn`);
|
||
const fileInput = document.getElementById(`${pfx}_avatarFileInput`);
|
||
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
|
||
uploadBtn?.addEventListener('click', () => fileInput.click());
|
||
fileInput?.addEventListener('change', function() {
|
||
const file = this.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = () => form.updateAvatarPreview(reader.result);
|
||
reader.readAsDataURL(file);
|
||
this.value = '';
|
||
});
|
||
removeBtn?.addEventListener('click', () => form.updateAvatarPreview(null));
|
||
}
|
||
|
||
// Cancel wiring
|
||
document.getElementById(`${pfx}_cancelBtn`)?.addEventListener('click', () => {
|
||
if (options.onCancel) options.onCancel();
|
||
});
|
||
|
||
// Submit wiring
|
||
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
|
||
if (options.onSubmit) options.onSubmit(form.getValues());
|
||
});
|
||
|
||
const form = {
|
||
getValues() {
|
||
const v = {
|
||
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
||
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
|
||
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
|
||
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
|
||
};
|
||
if (showConfig) {
|
||
const cfgId = document.getElementById(`${pfx}_config`)?.value;
|
||
if (cfgId) v.provider_config_id = cfgId;
|
||
}
|
||
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
|
||
if (!isNaN(temp)) v.temperature = temp;
|
||
const maxTok = parseInt(document.getElementById(`${pfx}_maxTokens`)?.value);
|
||
if (!isNaN(maxTok) && maxTok > 0) v.max_tokens = maxTok;
|
||
// Pending avatar data URI
|
||
const img = document.querySelector(`#${pfx}_avatarPreview img`);
|
||
if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src;
|
||
// Memory fields (v0.18.0)
|
||
const memCb = document.getElementById(`${pfx}_memoryEnabled`);
|
||
if (memCb) v.memory_enabled = memCb.checked;
|
||
const memPrompt = document.getElementById(`${pfx}_memoryExtractionPrompt`)?.value?.trim();
|
||
if (memPrompt) v.memory_extraction_prompt = memPrompt;
|
||
return v;
|
||
},
|
||
setValues(p) {
|
||
const el = id => document.getElementById(`${pfx}_${id}`);
|
||
if (el('name')) el('name').value = p.name || '';
|
||
if (el('desc')) el('desc').value = p.description || '';
|
||
if (el('prompt')) el('prompt').value = p.system_prompt || '';
|
||
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
|
||
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
|
||
if (el('model')) el('model').value = p.base_model_id || '';
|
||
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
|
||
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
|
||
// Memory fields (v0.18.0)
|
||
if (el('memoryEnabled')) el('memoryEnabled').checked = p.memory_enabled !== false;
|
||
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
|
||
},
|
||
clearForm() {
|
||
['name','desc','prompt','temp','maxTokens'].forEach(f => {
|
||
const el = document.getElementById(`${pfx}_${f}`);
|
||
if (el) el.value = '';
|
||
});
|
||
const modelSel = document.getElementById(`${pfx}_model`);
|
||
if (modelSel) modelSel.selectedIndex = 0;
|
||
if (showConfig) {
|
||
const cfgSel = document.getElementById(`${pfx}_config`);
|
||
if (cfgSel) cfgSel.selectedIndex = 0;
|
||
}
|
||
if (showAvatar) form.updateAvatarPreview(null);
|
||
},
|
||
updateAvatarPreview(dataURI) {
|
||
const preview = document.getElementById(`${pfx}_avatarPreview`);
|
||
const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`);
|
||
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
|
||
if (!preview) return;
|
||
const existing = preview.querySelector('img');
|
||
if (dataURI) {
|
||
if (placeholder) placeholder.style.display = 'none';
|
||
if (existing) { existing.src = dataURI; }
|
||
else {
|
||
const img = document.createElement('img');
|
||
img.src = dataURI; img.alt = 'Preset avatar';
|
||
preview.insertBefore(img, placeholder);
|
||
}
|
||
if (removeBtn) removeBtn.style.display = '';
|
||
} else {
|
||
if (existing) existing.remove();
|
||
if (placeholder) placeholder.style.display = '';
|
||
if (removeBtn) removeBtn.style.display = 'none';
|
||
}
|
||
},
|
||
setSubmitLabel(label) {
|
||
const btn = document.getElementById(`${pfx}_submitBtn`);
|
||
if (btn) btn.textContent = label;
|
||
},
|
||
getModelSelect() { return document.getElementById(`${pfx}_model`); },
|
||
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
|
||
};
|
||
return form;
|
||
}
|
||
|
||
// ── Summary Message Helpers ─────────────────
|
||
|
||
function _isSummaryMessage(msg) {
|
||
if (!msg.metadata) return false;
|
||
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
|
||
return meta?.type === 'summary';
|
||
}
|
||
|
||
function toggleSummarizedHistory() {
|
||
const el = document.getElementById('summarizedHistory');
|
||
const btn = document.querySelector('.message-summary-divider .summary-toggle');
|
||
if (!el) return;
|
||
const show = el.style.display === 'none';
|
||
el.style.display = show ? 'block' : 'none';
|
||
if (btn) {
|
||
const count = el.querySelectorAll('.message').length;
|
||
btn.textContent = show ? `▾ Hide ${count} earlier messages` : `▸ ${count} earlier messages summarized`;
|
||
}
|
||
}
|
||
|
||
const UI = {
|
||
|
||
// ── Sidebar ──────────────────────────────
|
||
|
||
toggleSidebar() {
|
||
const sb = document.getElementById('sidebar');
|
||
sb.classList.toggle('collapsed');
|
||
const collapsed = sb.classList.contains('collapsed');
|
||
localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
|
||
const overlay = document.getElementById('sidebarOverlay');
|
||
if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
|
||
},
|
||
|
||
restoreSidebar() {
|
||
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
|
||
document.getElementById('sidebar').classList.add('collapsed');
|
||
}
|
||
},
|
||
|
||
// ── User Menu Flyout ─────────────────────
|
||
|
||
toggleUserMenu() {
|
||
const flyout = document.getElementById('userFlyout');
|
||
const opening = !flyout.classList.contains('open');
|
||
flyout.classList.toggle('open');
|
||
|
||
// On open: refresh team admin button visibility (fire-and-forget)
|
||
if (opening) {
|
||
const menuBtn = document.getElementById('menuTeamAdmin');
|
||
if (menuBtn) {
|
||
// Show from cache immediately if available
|
||
if (UI._myTeams && UI._myTeams.length > 0) {
|
||
const cached = UI._myTeams.some(t => t.my_role === 'admin');
|
||
menuBtn.style.display = cached ? '' : 'none';
|
||
}
|
||
// Refresh in background
|
||
API.listMyTeams().then(resp => {
|
||
const teams = resp.data || [];
|
||
UI._myTeams = teams;
|
||
menuBtn.style.display = teams.some(t => t.my_role === 'admin') ? '' : 'none';
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
},
|
||
|
||
closeUserMenu() {
|
||
document.getElementById('userFlyout').classList.remove('open');
|
||
},
|
||
|
||
// ── Chat List ────────────────────────────
|
||
|
||
renderChatList() {
|
||
const el = document.getElementById('chatHistory');
|
||
const searchInput = document.getElementById('chatSearchInput');
|
||
const searchWrap = document.getElementById('sidebarSearch');
|
||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||
|
||
// Toggle clear button visibility
|
||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||
|
||
// Filter chats by search query
|
||
let chats = App.chats;
|
||
if (query) {
|
||
chats = chats.filter(c =>
|
||
(c.title || '').toLowerCase().includes(query)
|
||
);
|
||
}
|
||
|
||
const projects = App.projects || [];
|
||
|
||
if (chats.length === 0 && projects.length === 0 && query) {
|
||
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
||
return;
|
||
}
|
||
if (chats.length === 0 && projects.length === 0) {
|
||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// ── Chat item renderer ──────────────────
|
||
const renderChatItem = (c) => {
|
||
const time = _relativeTime(c.updatedAt);
|
||
return `
|
||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||
onclick="selectChat('${c.id}')"
|
||
oncontextmenu="showChatContextMenu(event,'${c.id}')"
|
||
draggable="true"
|
||
ondragstart="onChatDragStart(event,'${c.id}')"
|
||
ondragend="onChatDragEnd(event)">
|
||
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
|
||
<span class="chat-item-time">${time}</span>
|
||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||
</div>`;
|
||
};
|
||
|
||
// ── Project groups ──────────────────────
|
||
if (projects.length > 0) {
|
||
projects.forEach(proj => {
|
||
const projChats = chats.filter(c => c.projectId === proj.id);
|
||
if (projChats.length === 0 && query) return; // hide empty projects during search
|
||
const isCollapsed = App.collapsedProjects[proj.id];
|
||
const colorDot = proj.color
|
||
? `<span class="project-dot" style="background:${esc(proj.color)}"></span>`
|
||
: '<span class="project-dot"></span>';
|
||
const arrow = isCollapsed ? '▸' : '▾';
|
||
|
||
html += `<div class="project-group"
|
||
ondragover="onProjectDragOver(event)"
|
||
ondragleave="onProjectDragLeave(event)"
|
||
ondrop="onProjectDrop(event,'${proj.id}')">
|
||
<div class="project-header" onclick="toggleProjectCollapse('${proj.id}')">
|
||
<span class="project-arrow">${arrow}</span>
|
||
${colorDot}
|
||
<span class="project-name">${esc(proj.name)}</span>
|
||
<span class="project-count">${projChats.length}</span>
|
||
<button class="project-menu-btn" onclick="event.stopPropagation();showProjectMenu(event,'${proj.id}')" title="Project options">⋯</button>
|
||
</div>`;
|
||
|
||
if (!isCollapsed) {
|
||
if (projChats.length === 0) {
|
||
html += '<div class="project-empty">Drop chats here</div>';
|
||
} else {
|
||
projChats.forEach(c => { html += renderChatItem(c); });
|
||
}
|
||
}
|
||
html += '</div>';
|
||
});
|
||
}
|
||
|
||
// ── Recent (unassigned) chats ───────────
|
||
const recentChats = chats.filter(c => !c.projectId);
|
||
|
||
if (recentChats.length > 0 || projects.length > 0) {
|
||
// Only show "Recent" label if there are also projects
|
||
if (projects.length > 0) {
|
||
html += `<div class="recent-section"
|
||
ondragover="onProjectDragOver(event)"
|
||
ondragleave="onProjectDragLeave(event)"
|
||
ondrop="onRecentDrop(event)">
|
||
<div class="chat-group-label" style="padding-top:8px">Recent</div>`;
|
||
}
|
||
}
|
||
|
||
// Group recent by time
|
||
const now = new Date();
|
||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const yesterday = new Date(today - 86400000);
|
||
const weekAgo = new Date(today - 7 * 86400000);
|
||
|
||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||
|
||
recentChats.forEach(c => {
|
||
const d = new Date(c.updatedAt);
|
||
if (d >= today) groups.today.push(c);
|
||
else if (d >= yesterday) groups.yesterday.push(c);
|
||
else if (d >= weekAgo) groups.week.push(c);
|
||
else groups.older.push(c);
|
||
});
|
||
|
||
const renderGroup = (label, items) => {
|
||
if (items.length === 0) return;
|
||
// Only show time labels when there are no projects (preserve original look)
|
||
// or when inside the Recent section
|
||
if (projects.length > 0) {
|
||
html += `<div class="chat-group-label chat-time-label">${label}</div>`;
|
||
} else {
|
||
html += `<div class="chat-group-label">${label}</div>`;
|
||
}
|
||
items.forEach(c => { html += renderChatItem(c); });
|
||
};
|
||
|
||
renderGroup('Today', groups.today);
|
||
renderGroup('Yesterday', groups.yesterday);
|
||
renderGroup('Previous 7 days', groups.week);
|
||
renderGroup('Older', groups.older);
|
||
|
||
if (projects.length > 0 && recentChats.length > 0) {
|
||
html += '</div>'; // close recent-section
|
||
} else if (projects.length > 0 && recentChats.length === 0) {
|
||
// Close the recent-section div we opened
|
||
html += '</div>';
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
},
|
||
|
||
// ── Messages ─────────────────────────────
|
||
|
||
renderMessages(messages) {
|
||
const el = document.getElementById('chatMessages');
|
||
if (!messages || messages.length === 0) {
|
||
this.showEmptyState();
|
||
return;
|
||
}
|
||
|
||
// Find the most recent summary boundary
|
||
let summaryIdx = -1;
|
||
for (let i = 0; i < messages.length; i++) {
|
||
if (_isSummaryMessage(messages[i])) summaryIdx = i;
|
||
}
|
||
|
||
// Render: if summary exists, show collapsed-history toggle + summary + messages after
|
||
let html = '';
|
||
if (summaryIdx >= 0) {
|
||
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
|
||
if (hiddenCount > 0) {
|
||
html += `<div class="message-summary-divider" id="summaryDivider">
|
||
<button class="summary-toggle" onclick="toggleSummarizedHistory()">
|
||
▸ ${hiddenCount} earlier messages summarized
|
||
</button>
|
||
</div>
|
||
<div id="summarizedHistory" style="display:none">
|
||
${messages.slice(0, summaryIdx)
|
||
.filter(m => m.role !== 'system')
|
||
.map((m, i) => this._messageHTML(m, i, true))
|
||
.join('')}
|
||
</div>`;
|
||
}
|
||
// Render summary node
|
||
html += this._summaryHTML(messages[summaryIdx]);
|
||
// Render messages after summary
|
||
html += messages.slice(summaryIdx + 1)
|
||
.filter(m => m.role !== 'system' && !_isSummaryMessage(m))
|
||
.map((m, i) => this._messageHTML(m, summaryIdx + 1 + i))
|
||
.join('');
|
||
} else {
|
||
html = messages
|
||
.filter(m => m.role !== 'system')
|
||
.map((m, i) => this._messageHTML(m, i))
|
||
.join('');
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
|
||
if (typeof loadAuthImages === 'function') loadAuthImages(el);
|
||
this._scrollToBottom(true);
|
||
},
|
||
|
||
_summaryHTML(msg) {
|
||
const meta = msg.metadata || {};
|
||
const count = meta.summarized_count || '?';
|
||
const model = meta.utility_model || 'utility model';
|
||
const trigger = meta.trigger === 'auto' ? ' · auto' : '';
|
||
return `
|
||
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
|
||
<div class="summary-header">
|
||
<span>📝 Conversation summary (${count} messages, by ${esc(model)}${trigger})</span>
|
||
</div>
|
||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||
</div>`;
|
||
},
|
||
|
||
_messageHTML(msg, index, dimmed) {
|
||
const isUser = msg.role === 'user';
|
||
// Skip summary messages in normal rendering — they get _summaryHTML
|
||
if (_isSummaryMessage(msg)) return '';
|
||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||
let assistantLabel = 'Assistant';
|
||
if (!isUser) {
|
||
// Use stored modelName, or resolve from current model list, or fall back to model id
|
||
if (msg.modelName) {
|
||
assistantLabel = msg.modelName;
|
||
} else if (msg.model) {
|
||
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
||
assistantLabel = m?.name || msg.model;
|
||
}
|
||
}
|
||
|
||
// Branch indicator: show ‹ 2/3 › when siblings exist
|
||
const hasSiblings = (msg.siblingCount || 1) > 1;
|
||
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
||
const sibTotal = msg.siblingCount || 1;
|
||
const branchNav = hasSiblings ? `
|
||
<div class="branch-nav">
|
||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
|
||
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">‹</button>
|
||
<span class="branch-pos">${sibPos}/${sibTotal}</span>
|
||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
|
||
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">›</button>
|
||
</div>` : '';
|
||
|
||
// Action buttons
|
||
const msgId = msg.id ? esc(msg.id) : '';
|
||
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||
|
||
return `
|
||
<div class="message ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
||
<div class="msg-inner">
|
||
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
|
||
<div class="msg-body">
|
||
<div class="msg-head">
|
||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||
${branchNav}
|
||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||
<div class="msg-actions">
|
||
${editBtn}
|
||
${regenBtn}
|
||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||
<button class="msg-action-btn" onclick="saveMessageToNote(${index})" title="Save to note">Note</button>
|
||
</div>
|
||
</div>
|
||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||
${typeof renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
},
|
||
|
||
showEmptyState() {
|
||
document.getElementById('chatMessages').innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||
<h2>Chat Switchboard</h2>
|
||
<p>Select a model and start chatting</p>
|
||
</div>`;
|
||
},
|
||
|
||
showEditInline(messageId, currentContent) {
|
||
const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`);
|
||
if (!msgEl) return;
|
||
|
||
const textEl = msgEl.querySelector('.msg-text');
|
||
if (!textEl) return;
|
||
|
||
// Replace message text with an edit form
|
||
const escaped = currentContent.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
textEl.innerHTML = `
|
||
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
|
||
<div class="msg-edit-actions">
|
||
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
|
||
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
|
||
</div>`;
|
||
|
||
// Focus and select all
|
||
const textarea = document.getElementById(`editInput_${messageId}`);
|
||
if (textarea) {
|
||
textarea.focus();
|
||
textarea.selectionStart = textarea.value.length;
|
||
// Auto-resize
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = textarea.scrollHeight + 'px';
|
||
textarea.addEventListener('input', () => {
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = textarea.scrollHeight + 'px';
|
||
});
|
||
// Ctrl+Enter to submit
|
||
textarea.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
submitEdit(messageId, textarea.value);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
cancelEdit();
|
||
}
|
||
});
|
||
}
|
||
},
|
||
|
||
// ── Streaming ────────────────────────────
|
||
|
||
async streamResponse(response, messages, modelName) {
|
||
const container = document.getElementById('chatMessages');
|
||
document.getElementById('typingIndicator')?.remove();
|
||
|
||
const label = modelName || 'Assistant';
|
||
const currentModel = App.findModel(App.settings.model);
|
||
const streamAvatar = currentModel?.presetAvatar || null;
|
||
const div = document.createElement('div');
|
||
div.className = 'message assistant';
|
||
div.innerHTML = `
|
||
<div class="msg-inner">
|
||
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
|
||
<div class="msg-body">
|
||
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
||
<div class="msg-text" id="streamContent"></div>
|
||
</div>
|
||
</div>`;
|
||
container.appendChild(div);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let content = '';
|
||
let reasoning = '';
|
||
let currentEvent = ''; // SSE event type
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
// SSE event type line
|
||
if (line.startsWith('event: ')) {
|
||
currentEvent = line.slice(7).trim();
|
||
continue;
|
||
}
|
||
|
||
if (!line.startsWith('data: ')) continue;
|
||
const data = line.slice(6);
|
||
if (data === '[DONE]') { currentEvent = ''; continue; }
|
||
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
|
||
if (currentEvent === 'tool_use') {
|
||
// parsed = array of tool calls
|
||
this._renderToolUse(parsed);
|
||
currentEvent = '';
|
||
continue;
|
||
}
|
||
|
||
if (currentEvent === 'tool_result') {
|
||
// parsed = { tool_call_id, name, content, is_error }
|
||
this._renderToolResult(parsed);
|
||
currentEvent = '';
|
||
continue;
|
||
}
|
||
|
||
// Reasoning content delta (thinking blocks)
|
||
const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || '';
|
||
if (reasoningDelta) {
|
||
reasoning += reasoningDelta;
|
||
// Render accumulated reasoning + content together
|
||
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
||
document.getElementById('streamContent').innerHTML = formatMessage(full);
|
||
this._scrollToBottom();
|
||
}
|
||
|
||
// Normal content delta
|
||
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||
if (delta) {
|
||
content += delta;
|
||
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
||
document.getElementById('streamContent').innerHTML = formatMessage(full);
|
||
this._scrollToBottom();
|
||
|
||
// Live-update preview panel if open (debounced)
|
||
if (typeof _livePreviewUpdate === 'function') {
|
||
_livePreviewUpdate(content);
|
||
}
|
||
}
|
||
currentEvent = '';
|
||
} catch (e) { /* partial JSON */ }
|
||
}
|
||
}
|
||
|
||
return content;
|
||
},
|
||
|
||
_renderToolUse(toolCalls) {
|
||
const el = document.getElementById('streamTools');
|
||
if (!el) return;
|
||
el.style.display = '';
|
||
for (const tc of toolCalls) {
|
||
const name = tc.function?.name || tc.name || 'unknown';
|
||
const toolDiv = document.createElement('div');
|
||
toolDiv.className = 'tool-activity';
|
||
toolDiv.id = `tool_${tc.id}`;
|
||
toolDiv.innerHTML = `
|
||
<span class="tool-icon">🔧</span>
|
||
<span class="tool-name">${esc(name)}</span>
|
||
<span class="tool-status tool-running">running…</span>`;
|
||
el.appendChild(toolDiv);
|
||
}
|
||
this._scrollToBottom();
|
||
},
|
||
|
||
_renderToolResult(result) {
|
||
const el = document.getElementById(`tool_${result.tool_call_id}`);
|
||
if (el) {
|
||
const statusEl = el.querySelector('.tool-status');
|
||
if (statusEl) {
|
||
statusEl.textContent = result.is_error ? 'error' : 'done';
|
||
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
|
||
}
|
||
// Parse tool result content for a brief summary
|
||
try {
|
||
const parsed = JSON.parse(result.content);
|
||
let summary = '';
|
||
if (parsed.title) summary = parsed.title;
|
||
else if (parsed.count != null) summary = `${parsed.count} results`;
|
||
if (summary) {
|
||
const hint = document.createElement('span');
|
||
hint.className = 'tool-hint';
|
||
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
|
||
el.appendChild(hint);
|
||
}
|
||
// Add "View note" link for note tools
|
||
if (result.name && result.name.startsWith('note_') && parsed.id) {
|
||
const link = document.createElement('button');
|
||
link.className = 'tool-note-link';
|
||
link.textContent = '📝 View note';
|
||
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
|
||
el.appendChild(link);
|
||
}
|
||
} catch (e) { /* not JSON or no useful summary */ }
|
||
}
|
||
this._scrollToBottom();
|
||
},
|
||
|
||
// ── Model Selector (Custom Dropdown) ────
|
||
|
||
_modelValue: '',
|
||
|
||
getModelValue() { return UI._modelValue || App.settings.model || ''; },
|
||
|
||
setModelValue(val, label) {
|
||
UI._modelValue = val;
|
||
if (val) { App.settings.model = val; saveSettings(); }
|
||
const btn = document.getElementById('modelDropdownLabel');
|
||
if (btn) btn.textContent = label || val || 'Select a model';
|
||
// Highlight selected item
|
||
document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => {
|
||
el.classList.toggle('selected', el.dataset.value === val);
|
||
});
|
||
UI.updateCapabilityBadges();
|
||
if (typeof updateContextWarning === 'function') updateContextWarning();
|
||
if (typeof updateInputTokens === 'function') updateInputTokens();
|
||
},
|
||
|
||
updateModelSelector() {
|
||
const menu = document.getElementById('modelDropdownMenu');
|
||
if (!menu) return;
|
||
const current = App.settings.model;
|
||
menu.innerHTML = '';
|
||
|
||
if (App.models.length === 0) {
|
||
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||
UI.setModelValue('', 'No models loaded');
|
||
} else {
|
||
const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global');
|
||
const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team');
|
||
const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal');
|
||
const models = App.models.filter(m => !m.isPreset && !m.hidden);
|
||
|
||
const addGroup = (label, items) => {
|
||
if (items.length === 0) return;
|
||
const hdr = document.createElement('div');
|
||
hdr.className = 'model-dropdown-group';
|
||
hdr.textContent = label;
|
||
menu.appendChild(hdr);
|
||
items.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
|
||
};
|
||
|
||
addGroup('⚡ Presets', globalPresets);
|
||
|
||
// Group team presets by team name
|
||
const teamGroups = {};
|
||
teamPresets.forEach(m => {
|
||
const tn = m.presetTeamName || 'Team';
|
||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||
});
|
||
Object.keys(teamGroups).sort().forEach(tn => {
|
||
addGroup(`👥 ${tn}`, teamGroups[tn]);
|
||
});
|
||
|
||
addGroup('🔧 My Presets', personalPresets);
|
||
|
||
// No team model groups — team providers are for preset building only.
|
||
// Team members access team models through curated presets.
|
||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
|
||
|
||
// Restore selection — resolution chain: localStorage → admin default → first visible
|
||
const visibleModels = App.models.filter(m => !m.hidden);
|
||
const match = visibleModels.find(m => m.id === current || m.baseModelId === current);
|
||
if (match) {
|
||
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||
} else if (App.defaultModel && visibleModels.length > 0) {
|
||
// Admin-configured default
|
||
const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel);
|
||
if (def) {
|
||
UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : ''));
|
||
} else {
|
||
const first = visibleModels[0];
|
||
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
|
||
}
|
||
} else if (visibleModels.length > 0) {
|
||
const first = visibleModels[0];
|
||
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
|
||
} else {
|
||
UI.setModelValue('', 'No visible models');
|
||
}
|
||
}
|
||
|
||
// Sync settings modal selector (flat select is fine there)
|
||
const settingsSel = document.getElementById('settingsModel');
|
||
if (settingsSel) {
|
||
settingsSel.innerHTML = '';
|
||
App.models.filter(m => !m.hidden).forEach(m => {
|
||
const opt = document.createElement('option');
|
||
opt.value = m.id;
|
||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||
settingsSel.appendChild(opt);
|
||
});
|
||
if (current) settingsSel.value = current;
|
||
}
|
||
},
|
||
|
||
_createDropdownItem(m) {
|
||
const div = document.createElement('div');
|
||
div.className = 'model-dropdown-item';
|
||
div.dataset.value = m.id;
|
||
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
||
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||
div.addEventListener('click', () => {
|
||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||
UI._closeModelDropdown();
|
||
});
|
||
return div;
|
||
},
|
||
|
||
_closeModelDropdown() {
|
||
document.getElementById('modelDropdownMenu')?.classList.remove('open');
|
||
},
|
||
|
||
initModelDropdown() {
|
||
const btn = document.getElementById('modelDropdownBtn');
|
||
const menu = document.getElementById('modelDropdownMenu');
|
||
if (!btn || !menu) return;
|
||
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
menu.classList.toggle('open');
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('#modelDropdown')) menu.classList.remove('open');
|
||
});
|
||
},
|
||
|
||
// ── Capability Badges ────────────────────
|
||
|
||
getSelectedModelCaps() {
|
||
const modelId = UI.getModelValue();
|
||
if (!modelId) return {};
|
||
const model = App.findModel(modelId);
|
||
// Model in list has resolved caps from backend (catalog → heuristic)
|
||
return model?.capabilities || {};
|
||
},
|
||
|
||
updateCapabilityBadges() {
|
||
const el = document.getElementById('modelCaps');
|
||
if (!el) return;
|
||
|
||
const caps = this.getSelectedModelCaps();
|
||
if (!caps || Object.keys(caps).length === 0) {
|
||
el.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = renderCapBadges(caps);
|
||
},
|
||
|
||
// ── User / Connection ────────────────────
|
||
|
||
updateUser() {
|
||
const name = API.user?.display_name || API.user?.username || 'User';
|
||
const letter = (name[0] || '?').toUpperCase();
|
||
const avatarEl = document.getElementById('userAvatar');
|
||
const letterEl = document.getElementById('avatarLetter');
|
||
// Show avatar image or initial letter
|
||
let existingImg = avatarEl.querySelector('.user-avatar-img');
|
||
if (API.user?.avatar) {
|
||
letterEl.style.display = 'none';
|
||
if (existingImg) {
|
||
existingImg.src = API.user.avatar;
|
||
} else {
|
||
const img = document.createElement('img');
|
||
img.src = API.user.avatar;
|
||
img.alt = '';
|
||
img.className = 'user-avatar-img';
|
||
img.onerror = function() {
|
||
this.remove();
|
||
letterEl.style.display = '';
|
||
letterEl.textContent = letter;
|
||
};
|
||
avatarEl.insertBefore(img, letterEl);
|
||
}
|
||
} else {
|
||
if (existingImg) existingImg.remove();
|
||
letterEl.style.display = '';
|
||
letterEl.textContent = letter;
|
||
}
|
||
document.getElementById('userName').textContent = name;
|
||
},
|
||
|
||
showAdminButton(show) {
|
||
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
|
||
},
|
||
|
||
// ── Generating State ─────────────────────
|
||
|
||
setGenerating(on) {
|
||
document.getElementById('stopBtn').classList.toggle('visible', on);
|
||
document.getElementById('sendBtn').disabled = on;
|
||
|
||
// Swap favicon to animated version during generation
|
||
const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
|
||
if (faviconLink) {
|
||
if (on) {
|
||
// Pulsing favicon: dots fade in/out rapidly
|
||
faviconLink._origHref = faviconLink._origHref || faviconLink.href;
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||
<rect x="4" y="4" width="56" height="56" rx="10" fill="%23252220" stroke="%234a4540" stroke-width="1.5"/>
|
||
<circle cx="22" cy="23" r="8" fill="%23111"/><circle cx="42" cy="23" r="8" fill="%23111"/>
|
||
<circle cx="42" cy="43" r="8" fill="%23111"/><circle cx="22" cy="43" r="8" fill="%23111"/>
|
||
<circle cx="22" cy="23" r="5.5" fill="%232D7DD2"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0s"/></circle>
|
||
<circle cx="42" cy="23" r="5.5" fill="%23E8852E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.3s"/></circle>
|
||
<circle cx="42" cy="43" r="5.5" fill="%239B59B6"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.6s"/></circle>
|
||
<circle cx="22" cy="43" r="5.5" fill="%232EAA4E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.9s"/></circle>
|
||
</svg>`;
|
||
faviconLink.href = 'data:image/svg+xml,' + svg;
|
||
} else if (faviconLink._origHref) {
|
||
faviconLink.href = faviconLink._origHref;
|
||
}
|
||
}
|
||
|
||
if (on) {
|
||
const container = document.getElementById('chatMessages');
|
||
const currentModel = App.findModel(App.settings.model);
|
||
const typingAvatar = currentModel?.presetAvatar || null;
|
||
const typing = document.createElement('div');
|
||
typing.id = 'typingIndicator';
|
||
typing.className = 'message assistant';
|
||
typing.innerHTML = `
|
||
<div class="msg-inner">
|
||
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
|
||
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||
</div>`;
|
||
container.appendChild(typing);
|
||
this._scrollToBottom(true);
|
||
} else {
|
||
document.getElementById('typingIndicator')?.remove();
|
||
}
|
||
},
|
||
|
||
showRegenerate(show) {
|
||
// Bottom-bar regenerate button removed in v0.7.1.
|
||
// Regen is now per-message via inline buttons.
|
||
},
|
||
|
||
// ── Toast ────────────────────────────────
|
||
|
||
toast(message, type = 'success') {
|
||
const container = document.getElementById('toastContainer');
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${type}`;
|
||
el.innerHTML = `<span>${esc(message)}</span><button onclick="this.parentElement.remove()">✕</button>`;
|
||
container.appendChild(el);
|
||
setTimeout(() => el.remove(), 5000);
|
||
},
|
||
|
||
// ── Settings Modal ───────────────────────
|
||
|
||
openSettings() {
|
||
document.getElementById('settingsModel').value = App.settings.model;
|
||
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
|
||
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
|
||
document.getElementById('settingsTemp').value = App.settings.temperature;
|
||
document.getElementById('settingsThinking').checked = App.settings.showThinking;
|
||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||
|
||
// Show model's max output in the hint
|
||
const caps = this.getSelectedModelCaps();
|
||
const hint = document.getElementById('settingsMaxHint');
|
||
if (hint) {
|
||
if (caps.max_output_tokens > 0) {
|
||
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
|
||
hint.textContent = `(model max: ${k})`;
|
||
} else {
|
||
hint.textContent = '';
|
||
}
|
||
}
|
||
|
||
UI.loadProfileIntoSettings();
|
||
UI.loadMyTeams();
|
||
UI.checkUserProvidersAllowed();
|
||
UI.checkUserPresetsAllowed();
|
||
UI.switchSettingsTab('general');
|
||
openModal('settingsModal');
|
||
},
|
||
closeSettings() { closeModal('settingsModal'); },
|
||
|
||
switchSettingsTab(tab) {
|
||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
||
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
|
||
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||
if (panel) panel.style.display = '';
|
||
|
||
if (tab === 'providers') {
|
||
UI.loadProviderList();
|
||
UI.checkUserProvidersAllowed();
|
||
}
|
||
if (tab === 'models') UI.loadUserModels();
|
||
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
||
if (tab === 'usage') UI.loadMyUsage();
|
||
if (tab === 'roles') UI.loadUserRoles();
|
||
if (tab === 'knowledgeBases') {
|
||
if (typeof KnowledgeUI === 'undefined') {
|
||
console.error('[Settings] KnowledgeUI not loaded — check js/knowledge-ui.js');
|
||
const c = document.getElementById('kbManagePanel');
|
||
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Knowledge UI failed to load. Check browser console.</div>';
|
||
} else {
|
||
KnowledgeUI.openManagePanel();
|
||
}
|
||
}
|
||
if (tab === 'appearance') UI.loadAppearanceSettings();
|
||
if (tab === 'memory') {
|
||
if (typeof MemoryUI !== 'undefined') {
|
||
MemoryUI.openSettingsPanel();
|
||
} else {
|
||
const c = document.getElementById('settingsMemoryContent');
|
||
if (c) c.innerHTML = '<div style="padding:20px;color:var(--text-3);text-align:center">Memory UI failed to load.</div>';
|
||
}
|
||
}
|
||
},
|
||
|
||
// ── Export ────────────────────────────────
|
||
|
||
exportChat(format) {
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (!chat) return UI.toast('No chat to export', 'warning');
|
||
|
||
let content, ext, mime;
|
||
const safeName = chat.title.replace(/[^a-z0-9]/gi, '_');
|
||
|
||
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
|
||
else if (format === 'md') {
|
||
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
|
||
.map(m => `## ${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}\n\n${m.content}`).join('\n\n---\n\n');
|
||
ext = 'md'; mime = 'text/markdown';
|
||
} else {
|
||
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}]\n${m.content}`).join('\n\n');
|
||
ext = 'txt'; mime = 'text/plain';
|
||
}
|
||
|
||
const blob = new Blob([content], { type: mime });
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||
a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
|
||
},
|
||
|
||
// ── Message Actions ──────────────────────
|
||
|
||
copyMessage(index) {
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (chat?.messages[index]) {
|
||
navigator.clipboard.writeText(chat.messages[index].content)
|
||
.then(() => UI.toast('Copied', 'success'))
|
||
.catch(() => UI.toast('Copy failed', 'error'));
|
||
}
|
||
},
|
||
|
||
// ── Scroll ───────────────────────────────
|
||
|
||
_isNearBottom() {
|
||
const el = document.getElementById('chatMessages');
|
||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||
},
|
||
|
||
_scrollToBottom(force) {
|
||
if (force || this._isNearBottom()) {
|
||
const el = document.getElementById('chatMessages');
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
}
|
||
};
|