923 lines
42 KiB
HTML
923 lines
42 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);
|
||
}
|
||
|
||
/* Message actions visibility */
|
||
.message-wrapper:hover .message-actions {
|
||
opacity: 1;
|
||
}
|
||
.message-actions {
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
|
||
/* Action button styles */
|
||
.action-btn {
|
||
padding: 6px;
|
||
border-radius: 6px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.action-btn:hover {
|
||
background: rgba(79, 154, 195, 0.2);
|
||
}
|
||
.action-btn.active {
|
||
color: #ECDC67;
|
||
}
|
||
.action-btn.negative {
|
||
color: #EA744C;
|
||
}
|
||
.action-btn.positive {
|
||
color: #4F9AC3;
|
||
}
|
||
|
||
/* Edit textarea */
|
||
.edit-textarea {
|
||
background: rgba(11, 12, 28, 0.8);
|
||
border: 1px solid #6A4477;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
width: 100%;
|
||
min-height: 80px;
|
||
color: #F0F0F0;
|
||
resize: vertical;
|
||
}
|
||
.edit-textarea:focus {
|
||
outline: none;
|
||
border-color: #4F9AC3;
|
||
}
|
||
|
||
/* Copy notification */
|
||
.copy-toast {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #6A4477;
|
||
color: #F0F0F0;
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
z-index: 1000;
|
||
animation: fadeUp 0.3s ease;
|
||
}
|
||
@keyframes fadeUp {
|
||
from { opacity: 0; transform: translate(-50%, 10px); }
|
||
to { opacity: 1; transform: translate(-50%, 0); }
|
||
}
|
||
</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 class="flex items-center gap-3">
|
||
<div id="chatTitle" class="font-medium">New Chat</div>
|
||
<button id="editTitleBtn" class="p-1 hover:bg-balloon-purple/20 rounded transition-colors opacity-50 hover:opacity-100" title="Rename chat">
|
||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||
</svg>
|
||
</button>
|
||
</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>
|
||
|
||
<!-- Stop/Regenerate button (hidden by default) -->
|
||
<button type="button" id="stopBtn" class="hidden p-2 bg-balloon-red hover:bg-balloon-red/80 rounded-lg transition-colors">
|
||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 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 = [];
|
||
let messageHistory = []; // Store messages for retry/edit
|
||
let currentReader = null; // For stop functionality
|
||
let lastUserMessage = ''; // For retry
|
||
|
||
// 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 stopBtn = document.getElementById('stopBtn');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const attachmentsPreview = document.getElementById('attachmentsPreview');
|
||
const conversationsList = document.getElementById('conversationsList');
|
||
const chatTitle = document.getElementById('chatTitle');
|
||
const editTitleBtn = document.getElementById('editTitleBtn');
|
||
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);
|
||
|
||
// Stop generation
|
||
stopBtn.addEventListener('click', stopGeneration);
|
||
|
||
// Edit title
|
||
editTitleBtn.addEventListener('click', editChatTitle);
|
||
|
||
// 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;
|
||
messageHistory = [];
|
||
|
||
try {
|
||
const response = await fetch(`/api/conversations/${convId}/messages`);
|
||
const messages = await response.json();
|
||
|
||
messagesList.innerHTML = '';
|
||
welcomeMessage.classList.add('hidden');
|
||
|
||
messages.forEach(msg => {
|
||
messageHistory.push({ role: msg.role, content: msg.content });
|
||
appendMessage(msg.role, msg.content, msg.attachments, msg.id);
|
||
});
|
||
|
||
// 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');
|
||
|
||
// Store for retry
|
||
lastUserMessage = message;
|
||
|
||
// Add user message
|
||
const userMsgId = 'msg-' + Date.now();
|
||
appendMessage('user', message, attachments, userMsgId);
|
||
messageHistory.push({ role: 'user', content: message });
|
||
|
||
// Clear input
|
||
messageInput.value = '';
|
||
messageInput.style.height = 'auto';
|
||
attachments = [];
|
||
attachmentsPreview.classList.add('hidden');
|
||
attachmentsPreview.innerHTML = '';
|
||
|
||
// Generate response
|
||
await generateResponse();
|
||
}
|
||
|
||
async function generateResponse(retry = false) {
|
||
isGenerating = true;
|
||
sendBtn.classList.add('hidden');
|
||
stopBtn.classList.remove('hidden');
|
||
updateStatus('Thinking...', true);
|
||
|
||
try {
|
||
const response = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
message: retry ? lastUserMessage : messageHistory[messageHistory.length - 1]?.content || '',
|
||
conversation_id: currentConversationId,
|
||
attachments: []
|
||
})
|
||
});
|
||
|
||
// Get conversation ID from header
|
||
currentConversationId = response.headers.get('X-Conversation-Id');
|
||
|
||
// Create assistant message container
|
||
const assistantMsgId = 'msg-' + Date.now();
|
||
const assistantMsg = appendMessage('assistant', '', null, assistantMsgId, true);
|
||
|
||
// Store reader for stop functionality
|
||
currentReader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let fullContent = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await currentReader.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) {
|
||
fullContent += parsed.content;
|
||
appendToMessage(assistantMsg, parsed.content);
|
||
}
|
||
} catch (e) {
|
||
// Ignore parse errors
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Store in history
|
||
messageHistory.push({ role: 'assistant', content: fullContent });
|
||
|
||
// Refresh conversations list
|
||
loadConversations();
|
||
|
||
} catch (error) {
|
||
if (error.name !== 'AbortError') {
|
||
console.error('Chat error:', error);
|
||
appendMessage('assistant', 'An error occurred. Please try again.', null, 'msg-error');
|
||
}
|
||
} finally {
|
||
isGenerating = false;
|
||
currentReader = null;
|
||
sendBtn.classList.remove('hidden');
|
||
stopBtn.classList.add('hidden');
|
||
updateStatus('Ready', false);
|
||
}
|
||
}
|
||
|
||
function stopGeneration() {
|
||
if (currentReader) {
|
||
currentReader.cancel();
|
||
currentReader = null;
|
||
}
|
||
isGenerating = false;
|
||
sendBtn.classList.remove('hidden');
|
||
stopBtn.classList.add('hidden');
|
||
updateStatus('Stopped', false);
|
||
}
|
||
|
||
function appendMessage(role, content, attachments, msgId, streaming = false) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = `message-wrapper message-fade-in ${role === 'user' ? 'flex justify-end' : ''}`;
|
||
wrapper.dataset.msgId = msgId || '';
|
||
wrapper.dataset.role = role;
|
||
|
||
const isThinking = content.includes('[Thinking...]') || content.includes('[Searching') || content.includes('[Creating');
|
||
|
||
let contentHtml = '';
|
||
|
||
if (role === 'user') {
|
||
contentHtml = `
|
||
<div class="max-w-[80%]">
|
||
<div class="bg-balloon-purple/30 rounded-2xl rounded-tr-sm px-4 py-3">
|
||
<p class="whitespace-pre-wrap user-content">${escapeHtml(content)}</p>
|
||
${attachments && attachments.length ? `<div class="mt-2 text-xs text-text-main/50">${attachments.length} attachment(s)</div>` : ''}
|
||
</div>
|
||
<!-- User message actions -->
|
||
<div class="message-actions flex items-center gap-1 mt-1 justify-end">
|
||
<button class="action-btn edit-btn" title="Edit message" onclick="editMessage('${msgId}')">
|
||
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||
</svg>
|
||
</button>
|
||
<button class="action-btn" title="Copy" onclick="copyMessage('${msgId}')">
|
||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||
</svg>
|
||
</button>
|
||
</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' : ''} assistant-content">
|
||
${renderMarkdown(content)}
|
||
</div>
|
||
<!-- Assistant message actions -->
|
||
<div class="message-actions flex items-center gap-1 mt-2">
|
||
<button class="action-btn copy-btn" title="Copy" onclick="copyMessage('${msgId}')">
|
||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||
</svg>
|
||
</button>
|
||
<button class="action-btn" title="Regenerate" onclick="regenerateResponse()">
|
||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||
</svg>
|
||
</button>
|
||
<button class="action-btn positive" title="Good response" onclick="rateMessage('${msgId}', 'positive')">
|
||
<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="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"></path>
|
||
</svg>
|
||
</button>
|
||
<button class="action-btn negative" title="Bad response" onclick="rateMessage('${msgId}', 'negative')">
|
||
<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="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"></path>
|
||
</svg>
|
||
</button>
|
||
<button class="action-btn" title="Continue generation" onclick="continueGeneration()">
|
||
<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="M13 5l7 7-7 7M5 5l7 7-7 7"></path>
|
||
</svg>
|
||
</button>
|
||
</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) {
|
||
const currentText = contentDiv.textContent;
|
||
|
||
if (content.includes('[Thinking') || content.includes('[Searching') || content.includes('[Generating') || content.includes('[Creating')) {
|
||
contentDiv.classList.add('thinking-content', 'rounded-lg', 'px-4', 'py-2');
|
||
} else if (!content.includes('[') && contentDiv.classList.contains('thinking-content')) {
|
||
contentDiv.classList.remove('thinking-content');
|
||
}
|
||
|
||
const fullContent = currentText + content;
|
||
contentDiv.innerHTML = renderMarkdown(fullContent);
|
||
|
||
scrollToBottom();
|
||
}
|
||
}
|
||
|
||
function renderMarkdown(text) {
|
||
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 my-2"><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;
|
||
}
|
||
|
||
// Message Actions
|
||
function copyMessage(msgId) {
|
||
const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`);
|
||
const contentEl = wrapper?.querySelector('.user-content, .assistant-content');
|
||
if (contentEl) {
|
||
navigator.clipboard.writeText(contentEl.textContent).then(() => {
|
||
showToast('Copied to clipboard');
|
||
});
|
||
}
|
||
}
|
||
|
||
function editMessage(msgId) {
|
||
const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`);
|
||
const contentEl = wrapper?.querySelector('.user-content');
|
||
if (!contentEl) return;
|
||
|
||
const originalContent = contentEl.textContent;
|
||
|
||
// Create edit form
|
||
const editForm = document.createElement('div');
|
||
editForm.className = 'mt-2';
|
||
editForm.innerHTML = `
|
||
<textarea class="edit-textarea">${escapeHtml(originalContent)}</textarea>
|
||
<div class="flex gap-2 mt-2">
|
||
<button class="px-3 py-1 bg-balloon-blue hover:bg-balloon-blue/80 rounded text-sm save-edit">Save</button>
|
||
<button class="px-3 py-1 bg-surface hover:bg-surface/80 rounded text-sm cancel-edit">Cancel</button>
|
||
</div>
|
||
`;
|
||
|
||
contentEl.style.display = 'none';
|
||
wrapper.querySelector('.bg-balloon-purple\\/30').appendChild(editForm);
|
||
|
||
// Focus textarea
|
||
const textarea = editForm.querySelector('textarea');
|
||
textarea.focus();
|
||
textarea.selectionStart = textarea.value.length;
|
||
|
||
// Save handler
|
||
editForm.querySelector('.save-edit').addEventListener('click', () => {
|
||
const newContent = textarea.value.trim();
|
||
if (newContent && newContent !== originalContent) {
|
||
contentEl.textContent = newContent;
|
||
lastUserMessage = newContent;
|
||
// Update history
|
||
const idx = messageHistory.findIndex(m => m.role === 'user' && m.content === originalContent);
|
||
if (idx !== -1) messageHistory[idx].content = newContent;
|
||
|
||
// Remove all messages after this one
|
||
let found = false;
|
||
const toRemove = [];
|
||
messagesList.querySelectorAll('.message-wrapper').forEach(el => {
|
||
if (found) toRemove.push(el);
|
||
if (el.dataset.msgId === msgId) found = true;
|
||
});
|
||
toRemove.forEach(el => el.remove());
|
||
|
||
// Regenerate response
|
||
generateResponse(true);
|
||
}
|
||
contentEl.style.display = '';
|
||
editForm.remove();
|
||
});
|
||
|
||
// Cancel handler
|
||
editForm.querySelector('.cancel-edit').addEventListener('click', () => {
|
||
contentEl.style.display = '';
|
||
editForm.remove();
|
||
});
|
||
}
|
||
|
||
function regenerateResponse() {
|
||
if (isGenerating) return;
|
||
|
||
// Remove last assistant message
|
||
const lastAssistant = messageHistory.findLast(m => m.role === 'assistant');
|
||
if (lastAssistant) {
|
||
const wrappers = messagesList.querySelectorAll('.message-wrapper');
|
||
for (let i = wrappers.length - 1; i >= 0; i--) {
|
||
if (wrappers[i].dataset.role === 'assistant') {
|
||
wrappers[i].remove();
|
||
break;
|
||
}
|
||
}
|
||
messageHistory = messageHistory.filter(m => m !== lastAssistant);
|
||
}
|
||
|
||
generateResponse(true);
|
||
}
|
||
|
||
function continueGeneration() {
|
||
if (isGenerating) return;
|
||
|
||
// Add a continuation prompt
|
||
messageHistory.push({ role: 'user', content: 'Continue' });
|
||
lastUserMessage = 'Continue';
|
||
generateResponse();
|
||
}
|
||
|
||
function rateMessage(msgId, rating) {
|
||
const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`);
|
||
const positiveBtn = wrapper?.querySelector('.positive');
|
||
const negativeBtn = wrapper?.querySelector('.negative');
|
||
|
||
if (rating === 'positive') {
|
||
positiveBtn?.classList.toggle('active');
|
||
negativeBtn?.classList.remove('active');
|
||
showToast('Thanks for the feedback!');
|
||
} else {
|
||
negativeBtn?.classList.toggle('active');
|
||
positiveBtn?.classList.remove('active');
|
||
showToast('Thanks for the feedback - we\'ll work on improving');
|
||
}
|
||
|
||
// TODO: Send rating to backend for analytics
|
||
}
|
||
|
||
function editChatTitle() {
|
||
const currentTitle = chatTitle.textContent;
|
||
const newTitle = prompt('Enter new chat title:', currentTitle);
|
||
if (newTitle && newTitle !== currentTitle && currentConversationId) {
|
||
fetch(`/api/conversations/${currentConversationId}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: newTitle })
|
||
}).then(() => {
|
||
chatTitle.textContent = newTitle;
|
||
loadConversations();
|
||
});
|
||
}
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = document.createElement('div');
|
||
toast.className = 'copy-toast';
|
||
toast.textContent = message;
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 2000);
|
||
}
|
||
|
||
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);
|
||
|
||
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>
|
||
`;
|
||
}
|
||
|
||
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;
|
||
messageHistory = [];
|
||
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>
|