test/moxie/web/templates/chat.html
Z User 1f9535d683 Add complete MOXIE web UI with authentication and user management
- 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
2026-03-24 05:15:50 +00:00

623 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>