315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
// ==========================================
|
|
// UI Functions
|
|
// ==========================================
|
|
|
|
function updateSettingsUI() {
|
|
document.getElementById('apiEndpoint').value = State.settings.apiEndpoint || '';
|
|
document.getElementById('apiKey').value = State.settings.apiKey || '';
|
|
|
|
const modelSelect = document.getElementById('model');
|
|
const modelCustom = document.getElementById('modelCustom');
|
|
const savedModel = State.settings.model || 'gpt-3.5-turbo';
|
|
|
|
populateModelSelect(modelSelect);
|
|
|
|
const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel);
|
|
if (optionExists) {
|
|
modelSelect.value = savedModel;
|
|
modelCustom.value = '';
|
|
} else {
|
|
modelSelect.value = '';
|
|
modelCustom.value = savedModel;
|
|
}
|
|
|
|
document.getElementById('streamResponse').checked = State.settings.stream;
|
|
document.getElementById('saveHistory').checked = State.settings.saveHistory;
|
|
document.getElementById('showThinking').checked = State.settings.showThinking;
|
|
document.getElementById('systemPrompt').value = State.settings.systemPrompt || '';
|
|
document.getElementById('maxTokens').value = State.settings.maxTokens;
|
|
document.getElementById('temperature').value = State.settings.temperature;
|
|
document.getElementById('topP').value = State.settings.topP;
|
|
document.getElementById('presencePenalty').value = State.settings.presencePenalty;
|
|
}
|
|
|
|
function populateModelSelect(selectElement) {
|
|
const currentValue = selectElement.value;
|
|
selectElement.innerHTML = '<option value="">-- Select a model --</option>';
|
|
|
|
State.models.forEach(model => {
|
|
const option = document.createElement('option');
|
|
option.value = model.id;
|
|
option.textContent = model.id + (model.owned_by ? ` (${model.owned_by})` : '');
|
|
selectElement.appendChild(option);
|
|
});
|
|
|
|
if (currentValue) {
|
|
selectElement.value = currentValue;
|
|
}
|
|
}
|
|
|
|
function updateQuickModelSelector() {
|
|
const quickSelect = document.getElementById('quickModel');
|
|
const currentModel = State.settings.model;
|
|
|
|
quickSelect.innerHTML = '';
|
|
|
|
if (State.models.length === 0) {
|
|
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>';
|
|
if (currentModel) {
|
|
const opt = document.createElement('option');
|
|
opt.value = currentModel;
|
|
opt.textContent = currentModel;
|
|
quickSelect.appendChild(opt);
|
|
quickSelect.value = currentModel;
|
|
}
|
|
return;
|
|
}
|
|
|
|
State.models.forEach(model => {
|
|
const option = document.createElement('option');
|
|
option.value = model.id;
|
|
option.textContent = model.id;
|
|
quickSelect.appendChild(option);
|
|
});
|
|
|
|
if (currentModel) {
|
|
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
|
|
if (exists) {
|
|
quickSelect.value = currentModel;
|
|
} else {
|
|
const opt = document.createElement('option');
|
|
opt.value = currentModel;
|
|
opt.textContent = currentModel + ' (custom)';
|
|
quickSelect.insertBefore(opt, quickSelect.firstChild);
|
|
quickSelect.value = currentModel;
|
|
}
|
|
}
|
|
}
|
|
|
|
function openSettings() {
|
|
updateSettingsUI();
|
|
document.getElementById('settingsModal').classList.add('active');
|
|
}
|
|
|
|
function closeSettings() {
|
|
document.getElementById('settingsModal').classList.remove('active');
|
|
}
|
|
|
|
function toggleSidebar() {
|
|
document.getElementById('sidebar').classList.toggle('collapsed');
|
|
}
|
|
|
|
function renderChatHistory() {
|
|
const container = document.getElementById('chatHistory');
|
|
if (State.chats.length === 0) {
|
|
container.innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">No chat history</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = State.chats.map(chat => `
|
|
<div class="chat-history-item ${chat.id === State.currentChatId ? 'active' : ''}"
|
|
onclick="loadChat('${chat.id}')">
|
|
<span class="title">${escapeHtml(chat.title)}</span>
|
|
<div class="item-actions">
|
|
<button class="item-btn delete" onclick="handleDeleteChat('${chat.id}', event)" title="Delete">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderMessages(messages) {
|
|
const container = document.getElementById('chatMessages');
|
|
if (!messages || messages.length === 0) {
|
|
newChat();
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = messages
|
|
.filter(m => m.role !== 'system')
|
|
.map((msg, idx) => createMessageHTML(msg, idx))
|
|
.join('');
|
|
scrollToBottom();
|
|
}
|
|
|
|
function createMessageHTML(message, index) {
|
|
const isUser = message.role === 'user';
|
|
const time = message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : '';
|
|
const formattedContent = formatMessage(message.content);
|
|
|
|
return `
|
|
<div class="message ${message.role}" data-index="${index}">
|
|
<div class="message-content">
|
|
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
|
|
<div class="message-body">
|
|
<div class="message-header">
|
|
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
|
|
<div class="message-actions">
|
|
<button class="message-action-btn" onclick="copyMessage(${index})" title="Copy">📋 Copy</button>
|
|
</div>
|
|
</div>
|
|
${time ? `<div class="message-time">${time}</div>` : ''}
|
|
<div class="message-text">${formattedContent}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatMessage(content) {
|
|
let formatted = content;
|
|
|
|
// Extract and format thinking blocks FIRST (before escaping)
|
|
if (State.settings.showThinking) {
|
|
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
|
|
const escapedContent = escapeHtml(thinkingContent.trim());
|
|
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
|
|
return `__THINKING_BLOCK_${blockId}__${escapedContent}__END_THINKING_BLOCK__`;
|
|
});
|
|
} else {
|
|
// Remove thinking blocks if setting is off
|
|
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
|
|
}
|
|
|
|
// Now escape HTML for the rest
|
|
formatted = escapeHtml(formatted);
|
|
|
|
// Restore thinking blocks with proper HTML
|
|
formatted = formatted.replace(/__THINKING_BLOCK_([\w-]+)__([\s\S]*?)__END_THINKING_BLOCK__/g, (match, blockId, content) => {
|
|
return `
|
|
<div class="thinking-block" id="${blockId}">
|
|
<div class="thinking-header" onclick="toggleThinkingBlock('${blockId}')">
|
|
<span class="thinking-toggle">▼</span>
|
|
<span class="thinking-label">💭 Thinking</span>
|
|
</div>
|
|
<div class="thinking-content">${content.replace(/\n/g, '<br>')}</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
// Code blocks with copy button
|
|
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
|
|
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
|
|
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
|
|
});
|
|
|
|
// Inline code
|
|
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
|
|
// Bold
|
|
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
|
|
// Italic
|
|
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
|
|
// Line breaks (but not inside pre/code)
|
|
formatted = formatted.replace(/\n/g, '<br>');
|
|
|
|
return formatted;
|
|
}
|
|
|
|
function toggleThinkingBlock(blockId) {
|
|
const block = document.getElementById(blockId);
|
|
if (block) {
|
|
block.classList.toggle('collapsed');
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
const container = document.getElementById('chatMessages');
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function updateRegenerateButton(messages) {
|
|
const btn = document.getElementById('regenerateBtn');
|
|
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
|
|
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toastContainer');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.innerHTML = `
|
|
<span>${message}</span>
|
|
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
|
|
`;
|
|
container.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 5000);
|
|
}
|
|
|
|
// Message actions
|
|
function copyMessage(index) {
|
|
const chat = getCurrentChat();
|
|
if (chat && chat.messages[index]) {
|
|
navigator.clipboard.writeText(chat.messages[index].content)
|
|
.then(() => showToast('📋 Copied to clipboard', 'success'))
|
|
.catch(() => showToast('❌ Failed to copy', 'error'));
|
|
}
|
|
}
|
|
|
|
function copyCode(codeId) {
|
|
const codeElement = document.getElementById(codeId);
|
|
if (codeElement) {
|
|
navigator.clipboard.writeText(codeElement.textContent)
|
|
.then(() => showToast('📋 Code copied', 'success'))
|
|
.catch(() => showToast('❌ Failed to copy', 'error'));
|
|
}
|
|
}
|
|
|
|
function exportChat(format) {
|
|
const chat = getCurrentChat();
|
|
if (!chat) {
|
|
showToast('⚠️ No chat to export', 'warning');
|
|
return;
|
|
}
|
|
|
|
let content, filename, mimeType;
|
|
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
|
|
|
|
switch (format) {
|
|
case 'markdown':
|
|
content = `# ${chat.title}\n\n` + chat.messages
|
|
.filter(m => m.role !== 'system')
|
|
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
|
|
.join('\n\n---\n\n');
|
|
filename = `${title}.md`;
|
|
mimeType = 'text/markdown';
|
|
break;
|
|
case 'json':
|
|
content = JSON.stringify(chat, null, 2);
|
|
filename = `${title}.json`;
|
|
mimeType = 'application/json';
|
|
break;
|
|
case 'text':
|
|
content = chat.messages
|
|
.filter(m => m.role !== 'system')
|
|
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
|
|
.join('\n\n');
|
|
filename = `${title}.txt`;
|
|
mimeType = 'text/plain';
|
|
break;
|
|
}
|
|
|
|
const blob = new Blob([content], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
showToast(`📥 Exported as ${format}`, 'success');
|
|
document.getElementById('exportDropdown').classList.remove('show');
|
|
}
|