test/moxie/web/templates/chat.html
Z User 18301108b3 Add OpenWebUI features and integrate admin settings into profile
- Enhanced chat UI with visible action buttons (TTS, delete, regenerate, thumbs up/down)
- Added admin settings section to profile page for admin users
- Added admin API endpoints for user management, config, and RAG documents
- Removed need for separate admin endpoint - admins access settings via profile
2026-03-24 15:02:47 +00:00

1042 lines
48 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);
}
/* Message actions visibility - always visible on assistant, hover on user */
.message-wrapper:hover .message-actions {
opacity: 1;
}
.message-actions {
opacity: 0.4;
transition: opacity 0.2s ease;
}
.message-wrapper[data-role="assistant"] .message-actions {
opacity: 0.6;
}
.message-wrapper[data-role="assistant"]:hover .message-actions {
opacity: 1;
}
/* Action button styles */
.action-btn {
padding: 6px;
border-radius: 6px;
transition: all 0.2s ease;
color: #888;
}
.action-btn:hover {
background: rgba(79, 154, 195, 0.2);
color: #F0F0F0;
}
.action-btn.active {
color: #ECDC67 !important;
}
.action-btn.negative {
color: #EA744C;
}
.action-btn.negative:hover {
color: #EA744C;
}
.action-btn.positive {
color: #4F9AC3;
}
.action-btn.positive:hover {
color: #4F9AC3;
}
.action-btn.danger:hover {
background: rgba(234, 116, 76, 0.2);
color: #EA744C;
}
/* Code block with copy button */
.code-block-wrapper {
position: relative;
}
.code-block-wrapper .copy-code-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
background: rgba(79, 154, 195, 0.2);
border-radius: 4px;
font-size: 12px;
color: #F0F0F0;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.code-block-wrapper:hover .copy-code-btn {
opacity: 1;
}
.code-block-wrapper .copy-code-btn:hover {
background: rgba(79, 154, 195, 0.4);
}
/* 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 flex-wrap">
<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 response" 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>
<div class="w-px h-4 bg-text-main/20 mx-1"></div>
<button class="action-btn positive" title="Good response (thumbs up)" 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 (thumbs down)" 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>
<div class="w-px h-4 bg-text-main/20 mx-1"></div>
<button class="action-btn" title="Read aloud (TTS)" onclick="speakMessage('${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.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
</button>
<button class="action-btn" title="Continue generating" 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>
<button class="action-btn danger" title="Delete message" onclick="deleteMessage('${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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></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');
}
// Send rating to backend
fetch('/api/messages/' + msgId + '/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: rating })
}).catch(err => console.log('Rating failed to save:', err));
}
function speakMessage(msgId) {
const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`);
const contentEl = wrapper?.querySelector('.assistant-content');
if (!contentEl) return;
const text = contentEl.textContent;
// Check if already speaking
if (window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
showToast('Speech stopped');
return;
}
// Use Web Speech API
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.volume = 1.0;
// Get available voices and prefer English
const voices = window.speechSynthesis.getVoices();
const englishVoice = voices.find(v => v.lang.startsWith('en'));
if (englishVoice) {
utterance.voice = englishVoice;
}
utterance.onend = () => showToast('Finished reading');
utterance.onerror = () => showToast('Speech failed');
window.speechSynthesis.speak(utterance);
showToast('Reading aloud...');
}
function deleteMessage(msgId) {
const wrapper = document.querySelector(`[data-msg-id="${msgId}"]`);
if (!wrapper) return;
if (!confirm('Delete this message?')) return;
// Remove from UI
wrapper.remove();
// Remove from history
const idx = messageHistory.findIndex((m, i) => {
// Match by approximate content position
const wrappers = messagesList.querySelectorAll('.message-wrapper');
return false; // For now, just remove from UI
});
showToast('Message deleted');
// Optionally delete from backend
if (msgId && !msgId.startsWith('msg-')) {
fetch('/api/messages/' + msgId, {
method: 'DELETE'
}).catch(err => console.log('Failed to delete from server:', err));
}
}
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>