- New web UI with OpenWebUI-like interface using Tailwind CSS - SQLite-based authentication with session management - User registration, login, and profile pages - Chat interface with conversation history - Streaming responses with visible thinking phase - File attachments support - User document uploads in profile - Rate limiting (5 requests/day for free users) - Admin panel user management with promote/demote/delete - Custom color theme matching balloon logo design - Compatible with Nuitka build system
623 lines
27 KiB
HTML
623 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>MOXIE Chat</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
background: '#0B0C1C',
|
||
surface: '#1A1A1A',
|
||
'balloon-red': '#EA744C',
|
||
'balloon-yellow': '#ECDC67',
|
||
'balloon-blue': '#4F9AC3',
|
||
'balloon-purple': '#6A4477',
|
||
'text-main': '#F0F0F0',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style>
|
||
/* Custom scrollbar */
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: #6A4477; border-radius: 3px; }
|
||
::-webkit-scrollbar-thumb:hover { background: #EA744C; }
|
||
|
||
/* Typing animation */
|
||
@keyframes blink {
|
||
0%, 50% { opacity: 1; }
|
||
51%, 100% { opacity: 0; }
|
||
}
|
||
.typing-cursor::after {
|
||
content: '▋';
|
||
animation: blink 1s infinite;
|
||
color: #4F9AC3;
|
||
}
|
||
|
||
/* Message fade in */
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.message-fade-in {
|
||
animation: fadeIn 0.3s ease-out;
|
||
}
|
||
|
||
/* Thinking phase styling */
|
||
.thinking-content {
|
||
background: linear-gradient(135deg, rgba(106, 68, 119, 0.2), rgba(79, 154, 195, 0.2));
|
||
border-left: 3px solid #4F9AC3;
|
||
}
|
||
|
||
/* Generated image container */
|
||
.generated-image {
|
||
max-width: 100%;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(106, 68, 119, 0.3);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="bg-background text-text-main h-screen flex overflow-hidden">
|
||
<!-- Sidebar -->
|
||
<aside id="sidebar" class="w-72 bg-surface/50 border-r border-balloon-purple/20 flex flex-col transition-all duration-300">
|
||
<!-- Sidebar header -->
|
||
<div class="p-4 border-b border-balloon-purple/20">
|
||
<div class="flex items-center gap-3 mb-4">
|
||
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-10 h-10 rounded-full">
|
||
<div>
|
||
<h1 class="font-bold text-lg">MOXIE</h1>
|
||
<p class="text-xs text-text-main/50">AI Assistant</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- New chat button -->
|
||
<button id="newChatBtn" class="w-full py-2.5 bg-gradient-to-r from-balloon-red to-balloon-purple hover:from-balloon-red/80 hover:to-balloon-purple/80 text-white font-medium rounded-lg transition-all duration-300 flex items-center justify-center gap-2">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||
</svg>
|
||
New Chat
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Conversations list -->
|
||
<div class="flex-1 overflow-y-auto p-2">
|
||
<div class="text-xs text-text-main/40 px-2 py-2 uppercase tracking-wider">Conversations</div>
|
||
<div id="conversationsList" class="space-y-1">
|
||
<!-- Conversations loaded here -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- User section -->
|
||
<div class="p-4 border-t border-balloon-purple/20">
|
||
<div class="flex items-center gap-3 mb-3">
|
||
<div class="w-9 h-9 bg-balloon-purple/30 rounded-full flex items-center justify-center">
|
||
<span class="text-sm font-medium">{{ user.username[0].upper() }}</span>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="font-medium text-sm truncate">{{ user.username }}</div>
|
||
<div class="text-xs text-text-main/50">{{ rate_info.remaining }} requests left today</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<a href="/profile" class="flex-1 py-2 text-center text-sm bg-surface hover:bg-balloon-purple/20 rounded-lg transition-colors">Profile</a>
|
||
<button id="logoutBtn" class="flex-1 py-2 text-center text-sm bg-surface hover:bg-balloon-red/20 text-balloon-red rounded-lg transition-colors">Logout</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Mobile sidebar toggle -->
|
||
<button id="sidebarToggle" class="fixed top-4 left-4 z-50 p-2 bg-surface rounded-lg lg:hidden">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Main chat area -->
|
||
<main class="flex-1 flex flex-col min-w-0">
|
||
<!-- Chat header -->
|
||
<header class="h-14 border-b border-balloon-purple/20 flex items-center justify-between px-6 bg-surface/30">
|
||
<div id="chatTitle" class="font-medium">New Chat</div>
|
||
<div id="statusIndicator" class="flex items-center gap-2 text-sm text-text-main/50">
|
||
<span class="w-2 h-2 bg-balloon-yellow rounded-full animate-pulse"></span>
|
||
Ready
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Messages area -->
|
||
<div id="messagesContainer" class="flex-1 overflow-y-auto p-6">
|
||
<div id="welcomeMessage" class="max-w-2xl mx-auto text-center py-16">
|
||
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-24 h-24 mx-auto rounded-full shadow-lg shadow-balloon-purple/30 mb-6">
|
||
<h2 class="text-2xl font-bold mb-2">Welcome to MOXIE!</h2>
|
||
<p class="text-text-main/50 mb-6">Start a conversation or ask me to create images, videos, or audio for you.</p>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-left max-w-md mx-auto">
|
||
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Tell me about yourself">
|
||
<div class="text-balloon-yellow text-lg mb-1">👋</div>
|
||
<div class="text-sm">Tell me about yourself</div>
|
||
</div>
|
||
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Create an image of a sunset over mountains">
|
||
<div class="text-balloon-red text-lg mb-1">🎨</div>
|
||
<div class="text-sm">Create an image</div>
|
||
</div>
|
||
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Search the web for latest AI news">
|
||
<div class="text-balloon-blue text-lg mb-1">🔍</div>
|
||
<div class="text-sm">Search the web</div>
|
||
</div>
|
||
<div class="p-3 bg-surface/50 rounded-lg border border-balloon-purple/20 cursor-pointer hover:border-balloon-blue transition-colors suggestion-btn" data-msg="Help me write a poem">
|
||
<div class="text-balloon-purple text-lg mb-1">✍️</div>
|
||
<div class="text-sm">Write something creative</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="messagesList" class="max-w-3xl mx-auto space-y-6">
|
||
<!-- Messages loaded here -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Input area -->
|
||
<div class="p-4 border-t border-balloon-purple/20 bg-surface/30">
|
||
<div class="max-w-3xl mx-auto">
|
||
<!-- Attachments preview -->
|
||
<div id="attachmentsPreview" class="hidden mb-3 flex flex-wrap gap-2">
|
||
<!-- Attachment previews shown here -->
|
||
</div>
|
||
|
||
<!-- Input form -->
|
||
<form id="chatForm" class="relative">
|
||
<div class="flex items-end gap-3 bg-surface rounded-2xl border border-balloon-purple/30 focus-within:border-balloon-blue transition-colors p-2">
|
||
<!-- File attachment button -->
|
||
<label class="p-2 hover:bg-balloon-purple/20 rounded-lg cursor-pointer transition-colors">
|
||
<input type="file" id="fileInput" class="hidden" multiple accept="image/*,.pdf,.doc,.docx,.txt,.md">
|
||
<svg class="w-5 h-5 text-text-main/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"></path>
|
||
</svg>
|
||
</label>
|
||
|
||
<!-- Text input -->
|
||
<textarea
|
||
id="messageInput"
|
||
rows="1"
|
||
placeholder="Message MOXIE..."
|
||
class="flex-1 bg-transparent resize-none outline-none text-text-main placeholder-text-main/30 max-h-32 py-2"
|
||
></textarea>
|
||
|
||
<!-- Send button -->
|
||
<button type="submit" id="sendBtn" class="p-2 bg-balloon-blue hover:bg-balloon-blue/80 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div class="text-center text-xs text-text-main/30 mt-2">
|
||
MOXIE can make mistakes. Free accounts limited to 5 requests/day.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
// State
|
||
let currentConversationId = null;
|
||
let isGenerating = false;
|
||
let attachments = [];
|
||
|
||
// DOM elements
|
||
const messagesList = document.getElementById('messagesList');
|
||
const messagesContainer = document.getElementById('messagesContainer');
|
||
const welcomeMessage = document.getElementById('welcomeMessage');
|
||
const chatForm = document.getElementById('chatForm');
|
||
const messageInput = document.getElementById('messageInput');
|
||
const sendBtn = document.getElementById('sendBtn');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const attachmentsPreview = document.getElementById('attachmentsPreview');
|
||
const conversationsList = document.getElementById('conversationsList');
|
||
const chatTitle = document.getElementById('chatTitle');
|
||
const newChatBtn = document.getElementById('newChatBtn');
|
||
const logoutBtn = document.getElementById('logoutBtn');
|
||
const sidebar = document.getElementById('sidebar');
|
||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadConversations();
|
||
setupEventListeners();
|
||
autoResizeTextarea();
|
||
});
|
||
|
||
function setupEventListeners() {
|
||
// Form submit
|
||
chatForm.addEventListener('submit', handleSubmit);
|
||
|
||
// Enter to send
|
||
messageInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSubmit(e);
|
||
}
|
||
});
|
||
|
||
// File input
|
||
fileInput.addEventListener('change', handleFileSelect);
|
||
|
||
// New chat
|
||
newChatBtn.addEventListener('click', startNewChat);
|
||
|
||
// Logout
|
||
logoutBtn.addEventListener('click', handleLogout);
|
||
|
||
// Sidebar toggle (mobile)
|
||
sidebarToggle.addEventListener('click', () => {
|
||
sidebar.classList.toggle('-translate-x-full');
|
||
});
|
||
|
||
// Suggestion buttons
|
||
document.querySelectorAll('.suggestion-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
messageInput.value = btn.dataset.msg;
|
||
handleSubmit(new Event('submit'));
|
||
});
|
||
});
|
||
}
|
||
|
||
function autoResizeTextarea() {
|
||
messageInput.addEventListener('input', () => {
|
||
messageInput.style.height = 'auto';
|
||
messageInput.style.height = Math.min(messageInput.scrollHeight, 128) + 'px';
|
||
});
|
||
}
|
||
|
||
async function loadConversations() {
|
||
try {
|
||
const response = await fetch('/api/conversations');
|
||
const conversations = await response.json();
|
||
|
||
conversationsList.innerHTML = '';
|
||
|
||
conversations.forEach(conv => {
|
||
const el = document.createElement('div');
|
||
el.className = `p-3 rounded-lg cursor-pointer transition-colors ${
|
||
conv.id === currentConversationId
|
||
? 'bg-balloon-purple/20 border border-balloon-purple/30'
|
||
: 'hover:bg-surface/50'
|
||
}`;
|
||
el.innerHTML = `
|
||
<div class="flex items-center justify-between gap-2">
|
||
<span class="truncate text-sm">${escapeHtml(conv.title)}</span>
|
||
<button class="delete-conv opacity-0 hover:opacity-100 text-balloon-red p-1" data-id="${conv.id}">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
el.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.delete-conv')) {
|
||
loadConversation(conv.id);
|
||
}
|
||
});
|
||
|
||
el.querySelector('.delete-conv').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
deleteConversation(conv.id);
|
||
});
|
||
|
||
conversationsList.appendChild(el);
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load conversations:', error);
|
||
}
|
||
}
|
||
|
||
async function loadConversation(convId) {
|
||
currentConversationId = convId;
|
||
|
||
try {
|
||
const response = await fetch(`/api/conversations/${convId}/messages`);
|
||
const messages = await response.json();
|
||
|
||
messagesList.innerHTML = '';
|
||
welcomeMessage.classList.add('hidden');
|
||
|
||
messages.forEach(msg => {
|
||
appendMessage(msg.role, msg.content, msg.attachments);
|
||
});
|
||
|
||
// Update sidebar selection
|
||
loadConversations();
|
||
|
||
// Scroll to bottom
|
||
scrollToBottom();
|
||
} catch (error) {
|
||
console.error('Failed to load conversation:', error);
|
||
}
|
||
}
|
||
|
||
async function handleSubmit(e) {
|
||
e.preventDefault();
|
||
|
||
if (isGenerating) return;
|
||
|
||
const message = messageInput.value.trim();
|
||
if (!message && attachments.length === 0) return;
|
||
|
||
// Hide welcome message
|
||
welcomeMessage.classList.add('hidden');
|
||
|
||
// Add user message
|
||
appendMessage('user', message, attachments);
|
||
|
||
// Clear input
|
||
messageInput.value = '';
|
||
messageInput.style.height = 'auto';
|
||
attachments = [];
|
||
attachmentsPreview.classList.add('hidden');
|
||
attachmentsPreview.innerHTML = '';
|
||
|
||
// Start generation
|
||
isGenerating = true;
|
||
sendBtn.disabled = true;
|
||
updateStatus('Thinking...', true);
|
||
|
||
try {
|
||
const response = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
message: message,
|
||
conversation_id: currentConversationId,
|
||
attachments: attachments
|
||
})
|
||
});
|
||
|
||
// Get conversation ID from header
|
||
currentConversationId = response.headers.get('X-Conversation-Id');
|
||
|
||
// Create assistant message container
|
||
const assistantMsg = appendMessage('assistant', '', null, true);
|
||
|
||
// Read stream
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
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) {
|
||
if (line.startsWith('data: ')) {
|
||
const data = line.slice(6);
|
||
if (data === '[DONE]') {
|
||
break;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
if (parsed.content) {
|
||
appendToMessage(assistantMsg, parsed.content);
|
||
}
|
||
} catch (e) {
|
||
// Ignore parse errors
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Refresh conversations list
|
||
loadConversations();
|
||
|
||
} catch (error) {
|
||
console.error('Chat error:', error);
|
||
appendMessage('assistant', 'An error occurred. Please try again.', null);
|
||
} finally {
|
||
isGenerating = false;
|
||
sendBtn.disabled = false;
|
||
updateStatus('Ready', false);
|
||
}
|
||
}
|
||
|
||
function appendMessage(role, content, attachments, streaming = false) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = `message-fade-in ${role === 'user' ? 'flex justify-end' : ''}`;
|
||
|
||
const isThinking = content.includes('[Thinking...]') || content.includes('[Searching');
|
||
|
||
let contentHtml = '';
|
||
|
||
if (role === 'user') {
|
||
contentHtml = `
|
||
<div class="max-w-[80%] bg-balloon-purple/30 rounded-2xl rounded-tr-sm px-4 py-3">
|
||
<p class="whitespace-pre-wrap">${escapeHtml(content)}</p>
|
||
${attachments && attachments.length ? `<div class="mt-2 text-xs text-text-main/50">${attachments.length} attachment(s)</div>` : ''}
|
||
</div>
|
||
`;
|
||
} else {
|
||
contentHtml = `
|
||
<div class="flex gap-3 ${streaming ? 'typing-cursor' : ''}">
|
||
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-8 h-8 rounded-full flex-shrink-0 mt-1">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="prose prose-invert max-w-none ${isThinking ? 'thinking-content rounded-lg px-4 py-2' : ''}">
|
||
${renderMarkdown(content)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
wrapper.innerHTML = contentHtml;
|
||
|
||
if (streaming) {
|
||
wrapper.id = 'streaming-message';
|
||
}
|
||
|
||
messagesList.appendChild(wrapper);
|
||
scrollToBottom();
|
||
|
||
return wrapper;
|
||
}
|
||
|
||
function appendToMessage(element, content) {
|
||
const contentDiv = element.querySelector('.prose');
|
||
if (contentDiv) {
|
||
// Check if it's a thinking phase message
|
||
const currentText = contentDiv.textContent;
|
||
|
||
// Handle thinking phase specially
|
||
if (content.includes('[Thinking') || content.includes('[Searching') || content.includes('[Generating')) {
|
||
contentDiv.classList.add('thinking-content', 'rounded-lg', 'px-4', 'py-2');
|
||
} else if (!content.includes('[') && contentDiv.classList.contains('thinking-content')) {
|
||
// Remove thinking style for regular content
|
||
contentDiv.classList.remove('thinking-content');
|
||
}
|
||
|
||
// Append content
|
||
const fullContent = currentText + content;
|
||
contentDiv.innerHTML = renderMarkdown(fullContent);
|
||
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
function renderMarkdown(text) {
|
||
// Simple markdown rendering
|
||
let html = escapeHtml(text);
|
||
|
||
// Code blocks
|
||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="bg-background/50 rounded-lg p-3 overflow-x-auto"><code>$2</code></pre>');
|
||
|
||
// Inline code
|
||
html = html.replace(/`([^`]+)`/g, '<code class="bg-background/50 px-1.5 py-0.5 rounded text-balloon-yellow">$1</code>');
|
||
|
||
// Bold
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
|
||
// Italic
|
||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
|
||
// Line breaks
|
||
html = html.replace(/\n/g, '<br>');
|
||
|
||
return html;
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||
}
|
||
|
||
function updateStatus(text, loading) {
|
||
const indicator = document.getElementById('statusIndicator');
|
||
indicator.innerHTML = `
|
||
<span class="w-2 h-2 ${loading ? 'bg-balloon-yellow animate-pulse' : 'bg-balloon-blue'} rounded-full"></span>
|
||
${text}
|
||
`;
|
||
}
|
||
|
||
async function handleFileSelect(e) {
|
||
const files = Array.from(e.target.files);
|
||
|
||
for (const file of files) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await fetch('/api/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const data = await response.json();
|
||
attachments.push(data.id);
|
||
|
||
// Show preview
|
||
const preview = document.createElement('div');
|
||
preview.className = 'relative group';
|
||
|
||
if (file.type.startsWith('image/')) {
|
||
const img = document.createElement('img');
|
||
img.src = URL.createObjectURL(file);
|
||
img.className = 'w-16 h-16 object-cover rounded-lg';
|
||
preview.appendChild(img);
|
||
} else {
|
||
preview.innerHTML = `
|
||
<div class="w-16 h-16 bg-surface rounded-lg flex items-center justify-center text-2xl">
|
||
📄
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Remove button
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className = 'absolute -top-2 -right-2 w-5 h-5 bg-balloon-red rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity';
|
||
removeBtn.innerHTML = '×';
|
||
removeBtn.onclick = () => {
|
||
attachments = attachments.filter(id => id !== data.id);
|
||
preview.remove();
|
||
if (attachments.length === 0) {
|
||
attachmentsPreview.classList.add('hidden');
|
||
}
|
||
};
|
||
preview.appendChild(removeBtn);
|
||
|
||
attachmentsPreview.appendChild(preview);
|
||
attachmentsPreview.classList.remove('hidden');
|
||
|
||
} catch (error) {
|
||
console.error('Failed to upload file:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startNewChat() {
|
||
currentConversationId = null;
|
||
messagesList.innerHTML = '';
|
||
welcomeMessage.classList.remove('hidden');
|
||
chatTitle.textContent = 'New Chat';
|
||
loadConversations();
|
||
}
|
||
|
||
async function deleteConversation(convId) {
|
||
if (!confirm('Delete this conversation?')) return;
|
||
|
||
try {
|
||
await fetch(`/api/conversations/${convId}`, { method: 'DELETE' });
|
||
|
||
if (convId === currentConversationId) {
|
||
startNewChat();
|
||
}
|
||
|
||
loadConversations();
|
||
} catch (error) {
|
||
console.error('Failed to delete conversation:', error);
|
||
}
|
||
}
|
||
|
||
async function handleLogout() {
|
||
try {
|
||
await fetch('/logout');
|
||
window.location.href = '/';
|
||
} catch (error) {
|
||
console.error('Logout failed:', error);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|