riteshraut
feat/audio
be8f70c
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CogniChat - Chat with your Documents</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Google+Sans:wght@400;500;700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {
--background: #f0f4f9;
--foreground: #1f1f1f;
--primary: #1a73e8;
--primary-hover: #1867cf;
--card: #ffffff;
--card-border: #dadce0;
--input-bg: #e8f0fe;
--user-bubble: #d9e7ff;
--bot-bubble: #f1f3f4;
--select-bg: #ffffff;
--select-border: #dadce0;
--select-text: #1f1f1f;
}
.dark {
--background: #111827; /* Darker background */
--foreground: #e5e7eb;
--primary: #3b82f6; /* Adjusted primary blue */
--primary-hover: #60a5fa; /* Lighter hover blue */
--card: #1f2937; /* Dark card background */
--card-border: #4b5563; /* Greyer border */
--input-bg: #374151; /* Darker input background */
--user-bubble: #374151; /* Darker user bubble */
--bot-bubble: #374151; /* Darker bot bubble */
--select-bg: #374151;
--select-border: #6b7280;
--select-text: #f3f4f6;
--code-bg: #2d2d2d; /* Specific background for code blocks */
--code-text: #d4d4d4; /* Light grey text for code */
--copy-btn-bg: #4a4a4a;
--copy-btn-hover-bg: #5a5a5a;
--copy-btn-text: #e0e0e0;
}
body {
font-family: 'Inter', 'Google Sans', 'Roboto', sans-serif;
background-color: var(--background);
color: var(--foreground);
overflow: hidden; /* Prevent body scroll */
}
#chat-window {
scroll-behavior: smooth; /* Ensure smooth programatic scroll */
}
#chat-window::-webkit-scrollbar { width: 8px; }
#chat-window::-webkit-scrollbar-track { background: transparent; }
#chat-window::-webkit-scrollbar-thumb { background-color: #4b5563; border-radius: 20px; }
.dark #chat-window::-webkit-scrollbar-thumb { background-color: #5f6368; }
.drop-zone--over {
border-color: var(--primary);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
}
.loader {
width: 48px;
height: 48px;
border: 3px solid var(--card-border);
border-radius: 50%;
display: inline-block;
position: relative;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
.loader::after {
content: '';
box-sizing: border-box;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 56px;
height: 56px;
border-radius: 50%;
border: 3px solid;
border-color: var(--primary) transparent;
}
@keyframes rotation {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* --- Updated Typing Indicator --- */
.typing-indicator {
display: inline-flex; /* Changed to inline-flex */
align-items: center;
padding: 8px 0; /* Add some vertical padding */
}
.typing-indicator span {
height: 8px; /* Slightly smaller dots */
width: 8px;
margin: 0 2px;
background-color: #9E9E9E;
border-radius: 50%;
opacity: 0; /* Start invisible */
animation: typing-pulse 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-pulse {
0%, 100% { opacity: 0; transform: scale(0.7); }
50% { opacity: 1; transform: scale(1); }
}
/* --- End Typing Indicator --- */
/* --- Updated Markdown Styling --- */
.markdown-content { /* Base styles for the content area */
line-height: 1.75;
}
.markdown-content p { margin-bottom: 1rem; }
.markdown-content h1, .markdown-content h2, .markdown-content h3,
.markdown-content h4, .markdown-content h5, .markdown-content h6 {
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.markdown-content h1 { font-size: 1.5em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3rem;}
.markdown-content h2 { font-size: 1.25em; }
.markdown-content h3 { font-size: 1.1em; }
.markdown-content ul, .markdown-content ol { padding-left: 1.75rem; margin-bottom: 1rem; }
.markdown-content li { margin-bottom: 0.5rem; }
.markdown-content a { color: var(--primary); text-decoration: none; font-weight: 500; }
.markdown-content a:hover { text-decoration: underline; }
.markdown-content strong, .markdown-content b { font-weight: 600; } /* Ensure bold works */
.markdown-content blockquote {
border-left: 4px solid var(--card-border);
padding-left: 1rem;
margin-left: 0;
margin-bottom: 1rem;
color: #a0aec0; /* Lighter text for quotes */
}
/* --- Code Block Styling --- */
.markdown-content pre {
position: relative;
background-color: var(--code-bg);
border: 1px solid var(--card-border);
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9em;
color: var(--code-text);
overflow: hidden; /* Hide horizontal overflow until hovered/focused */
}
.markdown-content pre code {
display: block;
padding: 1rem;
overflow-x: auto; /* Enable horizontal scroll on the code itself */
background: none !important; /* Override potential highlight.js background */
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
white-space: pre; /* Ensure whitespace is preserved */
}
/* --- Copy Button Styling --- */
.markdown-content pre .copy-code-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: var(--copy-btn-bg);
border: 1px solid var(--card-border);
color: var(--copy-btn-text);
padding: 0.3rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
opacity: 0; /* Initially hidden */
transition: opacity 0.2s, background-color 0.2s;
font-size: 0.8em;
display: flex; /* For icon alignment */
align-items: center;
gap: 0.25rem;
}
.markdown-content pre .copy-code-btn:hover {
background-color: var(--copy-btn-hover-bg);
}
.markdown-content pre:hover .copy-code-btn {
opacity: 1; /* Show on hover */
}
/* --- Inline Code Styling --- */
.markdown-content code:not(pre code) {
background-color: rgba(110, 118, 129, 0.4);
padding: 0.2em 0.4em;
margin: 0 0.1em; /* Add slight horizontal margin */
font-size: 85%;
border-radius: 6px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
}
/* --- End Markdown Styling --- */
.tts-button-loader {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 0.8s linear infinite;
border-bottom-color: transparent;
}
/* --- Style for TTS controls container --- */
.tts-controls {
display: flex;
align-items: center;
gap: 0.5rem; /* Space between play and speed buttons */
margin-top: 0.5rem;
}
/* --- Style for Speed Cycle Button --- */
.speed-cycle-btn {
padding: 0.25rem 0.6rem; /* Smaller padding */
font-size: 0.75rem; /* Smaller text */
background-color: #4b5563; /* Grey background */
color: #e5e7eb; /* Light text */
border-radius: 9999px; /* Pill shape */
border: none;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap; /* Prevent text wrapping */
margin-top: 0.5rem;
}
.speed-cycle-btn:hover {
background-color: #1f0bb8e6; /* Lighter grey on hover */
}
.speed-cycle-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* --- Select dropdown styles (kept for consistency if needed elsewhere) --- */
.select-wrapper {
position: relative;
}
.select-wrapper select {
background-color: var(--select-bg);
border: 1px solid var(--select-border);
color: var(--select-text);
padding: 0.75rem 2.5rem 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
width: 100%;
appearance: none;
-webkit-appearance: none;
transition: all 0.2s ease-in-out;
cursor: pointer;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.75rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
}
</style>
</head>
<body class="w-screen h-screen dark">
<main id="main-content" class="h-full flex flex-col transition-opacity duration-500">
<div id="chat-container" class="hidden flex-1 flex flex-col w-full mx-auto overflow-hidden">
<header class="p-4 border-b border-[var(--card-border)] flex-shrink-0 flex justify-between items-center w-full">
<div class="w-1/4"></div> <div class="w-1/2 text-center">
<h1 class="text-xl font-medium tracking-wide">CogniChat ✨</h1>
<p id="chat-filename" class="text-xs text-gray-400 mt-1 truncate"></p>
</div>
<div id="chat-session-info" class="w-1/4 text-right text-xs space-y-1 pr-4">
</div>
</header>
<div id="chat-window" class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-10">
<div id="chat-content" class="max-w-4xl mx-auto space-y-8"></div>
</div>
<div class="p-4 flex-shrink-0 bg-opacity-50 backdrop-blur-md border-t border-[var(--card-border)]">
<form id="chat-form" class="max-w-4xl mx-auto bg-[var(--card)] rounded-full p-2 flex items-center shadow-lg border border-[var(--card-border)] focus-within:ring-2 focus-within:ring-[var(--primary)] transition-all">
<input type="text" id="chat-input" placeholder="Ask a question about your documents..." class="flex-grow bg-transparent focus:outline-none px-4 text-sm" autocomplete="off">
<button type="submit" id="chat-submit-btn" class="bg-[var(--primary)] hover:bg-[var(--primary-hover)] text-white p-2.5 rounded-full transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" title="Send">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z" clip-rule="evenodd"></path></svg>
</button>
</form>
</div>
</div>
<div id="upload-container" class="flex-1 flex flex-col items-center justify-center p-8 transition-opacity duration-300">
<div class="text-center max-w-xl w-full">
<h1 class="text-5xl font-bold mb-3 tracking-tight">CogniChat ✨</h1>
<p class="text-lg text-gray-400 mb-8">Upload your documents to start a conversation.</p>
<div class="mb-8 p-5 bg-[var(--card)] rounded-2xl border border-[var(--card-border)] shadow-lg">
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="w-full sm:w-1/2">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" /></svg>
<label for="model-select" class="block text-sm font-medium text-gray-300">Model</label>
</div>
<div class="select-wrapper">
<select id="model-select" name="model_name">
<option value="moonshotai/kimi-k2-instruct" selected>Kimi Instruct</option>
<option value="openai/gpt-oss-20b">GPT OSS 20b</option>
<option value="llama-3.3-70b-versatile">Llama 3.3 70b</option>
<option value="llama-3.1-8b-instant">Llama 3.1 8b Instant</option>
</select>
</div>
</div>
<div class="w-full sm:w-1/2">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.5 16a3.5 3.5 0 100-7 3.5 3.5 0 000 7zM12 5.5a3.5 3.5 0 11-7 0 3.5 3.5 0 017 0zM14.5 16a3.5 3.5 0 100-7 3.5 3.5 0 000 7z" clip-rule="evenodd" /></svg>
<label for="temperature-select" class="block text-sm font-medium text-gray-300">Mode</label>
</div>
<div class="select-wrapper">
<select id="temperature-select" name="temperature">
<option value="0.2" selected>0.2 - Precise</option>
<option value="0.4">0.4 - Confident</option>
<option value="0.6">0.6 - Balanced</option>
<option value="0.8">0.8 - Flexible</option>
<option value="1.0">1.0 - Creative</option>
</select>
</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-4 text-center">Higher creativity modes may reduce factual accuracy.</p>
</div>
<div id="drop-zone" class="w-full text-center border-2 border-dashed border-[var(--card-border)] rounded-2xl p-10 transition-all duration-300 cursor-pointer hover:bg-[var(--card)] hover:border-[var(--primary)]">
<div class="flex flex-col items-center justify-center pointer-events-none">
<svg class="mx-auto h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16.5V9.75m0 0l3-3m-3 3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"></path></svg>
<p class="mt-4 text-sm font-medium text-gray-400">Drag & drop files or <span class="text-[var(--primary)] font-semibold">click to upload</span></p>
<p class="text-xs text-gray-400 mt-1">Supports PDF, DOCX, TXT</p>
<p id="file-name" class="mt-2 text-xs text-gray-500"></p>
</div>
<input id="file-upload" type="file" class="hidden" accept=".pdf,.txt,.docx" multiple>
</div>
</div>
</div>
<div id="loading-overlay" class="hidden fixed inset-0 bg-[var(--background)] bg-opacity-80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
<div class="loader"></div>
<p id="loading-text" class="mt-6 text-sm font-medium"></p>
<p id="loading-subtext" class="mt-2 text-xs text-gray-400"></p>
</div>
</main>
<script>
document.addEventListener('DOMContentLoaded', () => {
const uploadContainer = document.getElementById('upload-container');
const chatContainer = document.getElementById('chat-container');
const dropZone = document.getElementById('drop-zone');
const fileUploadInput = document.getElementById('file-upload');
const fileNameSpan = document.getElementById('file-name');
const loadingOverlay = document.getElementById('loading-overlay');
const loadingText = document.getElementById('loading-text');
const loadingSubtext = document.getElementById('loading-subtext');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatSubmitBtn = document.getElementById('chat-submit-btn');
const chatWindow = document.getElementById('chat-window');
const chatContent = document.getElementById('chat-content');
const modelSelect = document.getElementById('model-select');
const temperatureSelect = document.getElementById('temperature-select');
const chatFilename = document.getElementById('chat-filename');
const chatSessionInfo = document.getElementById('chat-session-info');
let sessionId = sessionStorage.getItem('cognichat_session_id');
let currentModelInfo = JSON.parse(sessionStorage.getItem('cognichat_model_info'));
marked.setOptions({
breaks: true,
gfm: true,
});
if (sessionId && currentModelInfo) {
console.log("Restoring session:", sessionId);
uploadContainer.classList.add('hidden');
chatContainer.classList.remove('hidden');
chatFilename.innerHTML = `Chatting with: <strong class="font-semibold">${sessionStorage.getItem('cognichat_filename') || 'documents'}</strong>`;
chatFilename.title = sessionStorage.getItem('cognichat_filename') || 'documents';
chatSessionInfo.innerHTML = `
<p>Model: ${currentModelInfo.simpleModelName}</p>
<p>Mode: ${currentModelInfo.mode}</p>
<button class="mt-1 text-xs text-blue-400 hover:text-blue-300 focus:outline-none" onclick="sessionStorage.clear(); location.reload();">New Chat</button>`;
}
// --- File Upload Logic ---
dropZone.addEventListener('click', () => fileUploadInput.click());
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, e => {e.preventDefault(); e.stopPropagation();}, false);
document.body.addEventListener(eventName, e => {e.preventDefault(); e.stopPropagation();}, false);
});
['dragenter', 'dragover'].forEach(eventName => dropZone.addEventListener(eventName, () => dropZone.classList.add('drop-zone--over')));
['dragleave', 'drop'].forEach(eventName => dropZone.addEventListener(eventName, () => dropZone.classList.remove('drop-zone--over')));
dropZone.addEventListener('drop', (e) => {
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
});
fileUploadInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFiles(e.target.files);
});
async function handleFiles(files) {
const formData = new FormData();
let fileNames = Array.from(files).map(f => f.name);
for (const file of files) { formData.append('file', file); }
formData.append('model_name', modelSelect.value);
formData.append('temperature', temperatureSelect.value);
fileNameSpan.textContent = `Selected: ${fileNames.join(', ')}`;
await uploadAndProcessFiles(formData);
}
async function uploadAndProcessFiles(formData) {
loadingOverlay.classList.remove('hidden');
loadingText.textContent = `Processing document(s)...`;
loadingSubtext.textContent = "Creating a knowledge base... this might take a minute 🧠";
chatContent.innerHTML = ''; // Clear previous chat content on new upload
try {
const response = await fetch('/upload', { method: 'POST', body: formData });
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Unknown error occurred during upload.');
sessionId = result.session_id;
sessionStorage.setItem('cognichat_session_id', sessionId);
const modelOption = modelSelect.querySelector(`option[value="${result.model_name}"]`);
const simpleModelName = modelOption ? modelOption.textContent : result.model_name; // Adjust if needed
currentModelInfo = {
model: result.model_name,
mode: result.mode,
simpleModelName: simpleModelName // Use the derived simpler name
};
sessionStorage.setItem('cognichat_model_info', JSON.stringify(currentModelInfo)); // Store model info
sessionStorage.setItem('cognichat_filename', result.filename); // Store filename
chatFilename.innerHTML = `Chatting with: <strong class="font-semibold">${result.filename}</strong>`;
chatFilename.title = result.filename;
chatSessionInfo.innerHTML = `
<p>Model: ${currentModelInfo.simpleModelName}</p>
<p>Mode: ${currentModelInfo.mode}</p>
<button class="mt-1 text-xs text-blue-400 hover:text-blue-300 focus:outline-none" onclick="sessionStorage.clear(); location.reload();">New Chat</button>`;
uploadContainer.classList.add('hidden');
chatContainer.classList.remove('hidden');
appendMessage("Hello! 👋 I've analyzed your documents. What would you like to know?", "bot", currentModelInfo);
} catch (error) {
console.error('Upload error:', error);
alert(`Error processing files: ${error.message}`);
sessionStorage.clear(); // Clear session if upload fails
} finally {
loadingOverlay.classList.add('hidden');
fileNameSpan.textContent = '';
fileUploadInput.value = '';
}
}
// --- Chat Logic (Using Server-Sent Events - UPDATED FOR STREAMING & INDICATOR) ---
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const question = chatInput.value.trim();
if (!question || !sessionId) {
console.warn("Submit ignored: No question or session ID.");
return;
}
appendMessage(question, 'user');
chatInput.value = '';
chatInput.disabled = true;
chatSubmitBtn.disabled = true;
let botMessageContainer;
let contentDiv;
let fullResponse = '';
let eventSource = null;
let inactivityTimeout = null;
let streamClosedCleanly = false; // Flag to check if stream ended normally vs error
let typingIndicatorElement = null; // Store indicator element
// Function to finalize chat (called on error, timeout, or successful completion)
function finalizeChat(isError = false) {
console.log(`Finalizing chat. Was error: ${isError}, Stream ended cleanly: ${streamClosedCleanly}`);
if (eventSource) {
eventSource.close();
eventSource = null;
console.log("SSE connection explicitly closed in finalizeChat.");
}
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
inactivityTimeout = null;
}
// Remove indicator if it's still there
if (typingIndicatorElement && typingIndicatorElement.parentNode) {
typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
typingIndicatorElement = null;
}
if (botMessageContainer && contentDiv) {
const hasErrorMsg = contentDiv.innerHTML.includes('⚠️');
// Ensure final render, apply copy buttons and TTS ONLY if response wasn't an error
if (!hasErrorMsg && fullResponse) {
// Re-parse the complete response to ensure correct final Markdown
contentDiv.innerHTML = marked.parse(fullResponse);
// Apply final touches like copy buttons and TTS
contentDiv.querySelectorAll('pre').forEach(addCopyButton);
addTextToSpeechControls(botMessageContainer, fullResponse);
// Optional: Final highlighting if using highlight.js
// contentDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
}
scrollToBottom(true); // Ensure scrolled to the end
}
// Always re-enable input fields
chatInput.disabled = false;
chatSubmitBtn.disabled = false;
chatInput.focus();
}
try {
// Create the bot message container *before* starting the stream
botMessageContainer = appendMessage('', 'bot', currentModelInfo); // Append empty bot message
contentDiv = botMessageContainer.querySelector('.markdown-content');
// Show typing indicator *inside* the contentDiv
typingIndicatorElement = showTypingIndicator();
if (contentDiv) {
contentDiv.appendChild(typingIndicatorElement);
scrollToBottom(true); // Scroll to show indicator
} else {
console.error("Could not find contentDiv to append typing indicator.");
}
// Establish SSE connection via GET request
const chatUrl = `/chat?question=${encodeURIComponent(question)}&session_id=${encodeURIComponent(sessionId)}`;
console.log("Connecting to SSE:", chatUrl);
eventSource = new EventSource(chatUrl);
eventSource.onopen = () => {
console.log("SSE Connection opened.");
// Remove indicator when connection opens and stream is about to start
if (typingIndicatorElement && typingIndicatorElement.parentNode) {
typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
typingIndicatorElement = null;
}
streamClosedCleanly = false; // Reset flag on new connection
};
eventSource.onmessage = (event) => {
// Remove indicator on first message just in case onopen didn't fire reliably
if (typingIndicatorElement && typingIndicatorElement.parentNode) {
typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
typingIndicatorElement = null;
}
// Reset inactivity timeout on each message
if (inactivityTimeout) clearTimeout(inactivityTimeout);
inactivityTimeout = setTimeout(() => {
console.log("Inactivity timeout triggered after message.");
streamClosedCleanly = true; // Assume normal end
finalizeChat(false);
}, 5000); // 5 seconds of inactivity
let data;
try {
data = JSON.parse(event.data);
} catch (parseError){
console.error("Failed to parse SSE data:", event.data, parseError);
contentDiv.innerHTML += `<p class="text-red-400 text-sm">Error receiving data chunk.</p>`;
return;
}
if (data.error) {
console.error('SSE Error from server:', data.error);
contentDiv.innerHTML = `<p class="text-red-500 font-semibold">⚠️ Server Error: ${data.error}</p>`;
streamClosedCleanly = false;
finalizeChat(true); // Pass true for error
return;
}
if (data.token !== undefined && data.token !== null) {
fullResponse += data.token;
// Update content by parsing the accumulated response
contentDiv.innerHTML = marked.parse(fullResponse);
scrollToBottom(); // Scroll smoothly as content arrives
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error event:', error);
// Remove indicator on error
if (typingIndicatorElement && typingIndicatorElement.parentNode) {
typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
typingIndicatorElement = null;
}
// Don't show generic error if we received data and the stream likely just closed normally
if (!fullResponse && !streamClosedCleanly) { // Only show error if nothing received AND not already cleanly closed
const errorMsg = "⚠️ Connection error. Please try again.";
if (contentDiv) {
contentDiv.innerHTML = `<p class="text-red-500 font-semibold">${errorMsg}</p>`;
} else {
// Fallback if container wasn't created somehow
appendMessage(errorMsg, 'bot', currentModelInfo); // Pass model info here too
}
streamClosedCleanly = false;
} else if (!streamClosedCleanly) {
// If we received data, assume it's a normal closure misinterpreted as error
console.log("SSE connection closed (likely normal end detected by onerror).");
streamClosedCleanly = true; // Mark as clean closure NOW
} else {
console.log("SSE onerror event after stream already marked cleanly closed.")
}
finalizeChat(!streamClosedCleanly); // Finalize, indicate error if not clean
};
} catch (error) {
// For setup errors before SSE starts
console.error('Chat setup error:', error);
// Remove indicator on setup error
if (typingIndicatorElement && typingIndicatorElement.parentNode) {
typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
typingIndicatorElement = null;
}
if (botMessageContainer && contentDiv) {
contentDiv.innerHTML = `<p class="text-red-500 font-semibold">⚠️ Error starting chat: ${error.message}</p>`;
} else {
appendMessage(`Error starting chat: ${error.message}`, 'bot', currentModelInfo); // Pass model info
}
finalizeChat(true);
}
});
// --- UI Helper Functions ---
function appendMessage(text, sender, modelInfo = null) {
const messageWrapper = document.createElement('div');
const iconSVG = sender === 'user'
? `<div class="bg-blue-200 dark:bg-gray-700 p-2.5 rounded-full flex-shrink-0 mt-1 self-start"><svg class="w-5 h-5 text-blue-700 dark:text-blue-300" viewBox="0 0 24 24"><path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"></path></svg></div>`
: `<div class="bg-gray-200 dark:bg-gray-700 rounded-full flex-shrink-0 mt-1 self-start text-xl flex items-center justify-center w-10 h-10">✨</div>`;
let senderHTML;
if (sender === 'user') {
senderHTML = '<p class="font-medium text-sm mb-1">You</p>';
} else {
let modelInfoHTML = '';
const displayInfo = modelInfo || currentModelInfo;
if (displayInfo && displayInfo.simpleModelName) {
modelInfoHTML = `
<span class="ml-2 text-xs font-normal text-gray-400">
(Model: ${displayInfo.simpleModelName} | Mode: ${displayInfo.mode})
</span>
`;
}
senderHTML = `<div class="font-medium text-sm mb-1 flex items-center">CogniChat ${modelInfoHTML}</div>`;
}
messageWrapper.className = `flex items-start gap-3`;
// Ensure markdown-content div exists even if text is empty for the indicator
messageWrapper.innerHTML = `
${iconSVG}
<div class="flex-1 pt-1 min-w-0"> ${senderHTML}
<div class="text-base markdown-content prose dark:prose-invert max-w-none">${text ? marked.parse(text) : ''}</div>
<div class="tts-controls mt-2"></div>
</div>
`;
chatContent.appendChild(messageWrapper);
// Force scroll only when adding user message or initial bot message with content
if (sender === 'user' || text) {
scrollToBottom(true);
}
// Return the container that holds the sender name and content div
return messageWrapper.querySelector('.flex-1');
}
// --- UPDATED showTypingIndicator ---
function showTypingIndicator() {
const indicator = document.createElement('div');
indicator.className = 'typing-indicator'; // Use the main class
indicator.innerHTML = '<span></span><span></span><span></span>';
// Don't append here, just return the element
return indicator;
}
// --- End UPDATED showTypingIndicator ---
function scrollToBottom(force = false) {
const isNearBottom = chatWindow.scrollHeight - chatWindow.clientHeight <= chatWindow.scrollTop + 150; // Threshold
if (force || isNearBottom) {
requestAnimationFrame(() => { // Use rAF for smoother render loop
chatWindow.scrollTo({
top: chatWindow.scrollHeight,
behavior: 'smooth'
});
});
}
}
function addCopyButton(pre) {
if (pre.querySelector('.copy-code-btn')) return;
const button = document.createElement('button');
// Updated classes for better styling
button.className = 'copy-code-btn absolute top-2 right-2 p-1 rounded bg-[var(--copy-btn-bg)] text-[var(--copy-btn-text)] hover:bg-[var(--copy-btn-hover-bg)] transition-opacity duration-200 flex items-center gap-1 text-xs';
button.innerHTML = `<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> Copy`;
pre.style.position = 'relative'; // Ensure parent is relative for absolute positioning
pre.appendChild(button);
button.addEventListener('click', () => {
const code = pre.querySelector('code')?.innerText || '';
navigator.clipboard.writeText(code)
.then(() => {
button.textContent = 'Copied!';
setTimeout(() => button.innerHTML = `<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> Copy`, 1500);
})
.catch(err => {
console.error('Failed to copy code: ', err);
button.textContent = 'Error';
setTimeout(() => button.innerHTML = `<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> Copy`, 1500);
});
});
}
// --- TTS Functions (UPDATED FOR SPEED CYCLE) ---
let currentAudio = null;
let currentPlayingButton = null; // Stores the currently active *play/pause* button
const playIconSVG = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"></path></svg>`;
const pauseIconSVG = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M5.75 4.75a.75.75 0 00-.75.75v9.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75v-9.5a.75.75 0 00-.75-.75h-1.5zm6.5 0a.75.75 0 00-.75.75v9.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75v-9.5a.75.75 0 00-.75-.75h-1.5z"></path></svg>`;
const availableSpeeds = [1.0, 1.5, 0.75]; // Normal, Fast, Slow
// --- UPDATED: addTextToSpeechControls ---
function addTextToSpeechControls(messageBubble, text) {
if (!text || !text.trim()) return;
const ttsControls = messageBubble.querySelector('.tts-controls');
if (!ttsControls || ttsControls.querySelector('.speak-btn')) return; // Avoid adding duplicates
// Play/Pause Button
const speakButton = document.createElement('button');
speakButton.className = 'speak-btn mt-2 px-3 py-1.5 bg-blue-700 text-white rounded-full text-xs font-medium hover:bg-blue-800 transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed';
speakButton.title = 'Listen to this message';
speakButton.innerHTML = `${playIconSVG} <span>Listen</span>`;
speakButton.setAttribute('data-current-speed', '1.0'); // Store current speed
ttsControls.appendChild(speakButton);
speakButton.addEventListener('click', () => handleTTS(text, speakButton));
// Speed Cycle Button
const speedButton = document.createElement('button');
speedButton.className = 'speed-cycle-btn'; // Use new class for styling
speedButton.title = 'Cycle playback speed';
speedButton.textContent = 'Speed: 1x';
speedButton.setAttribute('data-speeds', JSON.stringify(availableSpeeds)); // Store speeds
ttsControls.appendChild(speedButton);
speedButton.addEventListener('click', () => cycleSpeed(speedButton, speakButton));
}
// --- NEW: cycleSpeed ---
function cycleSpeed(speedBtn, speakBtn) {
const speeds = JSON.parse(speedBtn.getAttribute('data-speeds'));
let currentSpeed = parseFloat(speakBtn.getAttribute('data-current-speed'));
let currentIndex = speeds.indexOf(currentSpeed);
// Find next speed index, looping back to 0
let nextIndex = (currentIndex + 1) % speeds.length;
let nextSpeed = speeds[nextIndex];
// Update speak button's data attribute and speed button's text
speakBtn.setAttribute('data-current-speed', nextSpeed.toString());
speedBtn.textContent = `Speed: ${nextSpeed}x`;
// If audio is currently playing and this is the active button, update playback rate
if (currentAudio && !currentAudio.paused && speakBtn === currentPlayingButton) {
currentAudio.playbackRate = nextSpeed;
}
}
// --- UPDATED: handleTTS ---
async function handleTTS(text, button) {
if (!text || !text.trim()) return;
// *** Get speed from the button's data attribute ***
const selectedSpeed = parseFloat(button.getAttribute('data-current-speed')) || 1.0;
if (button === currentPlayingButton) { // If clicking the currently active play/pause button
if (currentAudio && !currentAudio.paused) { // If playing, pause it
currentAudio.pause();
button.innerHTML = `${playIconSVG} <span>Listen</span>`;
} else if (currentAudio && currentAudio.paused) { // If paused, resume it
currentAudio.playbackRate = selectedSpeed; // Ensure speed is set on resume
currentAudio.play().catch(e => {console.error("Audio resume error:", e); resetAllSpeakButtons();});
button.innerHTML = `${pauseIconSVG} <span>Pause</span>`;
}
return;
}
// If clicking a new play button (or the first time)
resetAllSpeakButtons(); // Stop any other audio
currentPlayingButton = button; // Mark this button as active
button.innerHTML = `<div class="tts-button-loader mr-1"></div> <span>Loading...</span>`;
button.disabled = true;
// Disable the corresponding speed button while loading
const speedBtn = button.parentElement.querySelector('.speed-cycle-btn');
if(speedBtn) speedBtn.disabled = true;
try {
const response = await fetch('/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text })
});
if (!response.ok) throw new Error(`TTS generation failed (${response.status})`);
const blob = await response.blob();
if (!blob || blob.size === 0) throw new Error("Received empty audio blob.");
const audioUrl = URL.createObjectURL(blob);
currentAudio = new Audio(audioUrl);
// *** Set the playback speed HERE ***
currentAudio.playbackRate = selectedSpeed;
await currentAudio.play();
button.innerHTML = `${pauseIconSVG} <span>Pause</span>`;
button.disabled = false;
// Re-enable the speed button
if(speedBtn) speedBtn.disabled = false;
currentAudio.onended = () => {
// Only reset if this button was the one playing
if (button === currentPlayingButton) resetAllSpeakButtons();
};
currentAudio.onerror = (e) => {
console.error('Audio object error:', e);
alert('Error playing audio.');
resetAllSpeakButtons();
};
} catch (error) {
console.error('TTS Handling Error:', error);
alert(`Failed to play audio: ${error.message}`);
resetAllSpeakButtons(); // Reset on error
}
}
// --- UPDATED: resetAllSpeakButtons ---
function resetAllSpeakButtons() {
document.querySelectorAll('.speak-btn').forEach(btn => {
btn.innerHTML = `${playIconSVG} <span>Listen</span>`;
btn.disabled = false;
btn.setAttribute('data-current-speed', '1.0'); // Reset speed attribute
});
document.querySelectorAll('.speed-cycle-btn').forEach(btn => {
btn.textContent = 'Speed: 1x'; // Reset speed button text
btn.disabled = false;
});
if (currentAudio) {
currentAudio.pause();
currentAudio.onended = null; // Clean up listeners
currentAudio.onerror = null;
currentAudio = null;
}
currentPlayingButton = null;
}
// Remove the resetSpecificButton function if it exists, it's integrated now.
// --- End of TTS Functions ---
// ... (keep the optional highlight.js part if you have it)
});
</script>
</body>
</html>