| <!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; |
| --foreground: #e5e7eb; |
| --primary: #3b82f6; |
| --primary-hover: #60a5fa; |
| --card: #1f2937; |
| --card-border: #4b5563; |
| --input-bg: #374151; |
| --user-bubble: #374151; |
| --bot-bubble: #374151; |
| --select-bg: #374151; |
| --select-border: #6b7280; |
| --select-text: #f3f4f6; |
| --code-bg: #2d2d2d; |
| --code-text: #d4d4d4; |
| --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; |
| } |
| |
| #chat-window { |
| scroll-behavior: smooth; |
| } |
| #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); } |
| } |
| |
| |
| .typing-indicator { |
| display: inline-flex; |
| align-items: center; |
| padding: 8px 0; |
| } |
| .typing-indicator span { |
| height: 8px; |
| width: 8px; |
| margin: 0 2px; |
| background-color: #9E9E9E; |
| border-radius: 50%; |
| opacity: 0; |
| 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); } |
| } |
| |
| |
| |
| .markdown-content { |
| 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; } |
| .markdown-content blockquote { |
| border-left: 4px solid var(--card-border); |
| padding-left: 1rem; |
| margin-left: 0; |
| margin-bottom: 1rem; |
| color: #a0aec0; |
| } |
| |
| .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; |
| } |
| .markdown-content pre code { |
| display: block; |
| padding: 1rem; |
| overflow-x: auto; |
| background: none !important; |
| font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; |
| white-space: pre; |
| } |
| |
| .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; |
| transition: opacity 0.2s, background-color 0.2s; |
| font-size: 0.8em; |
| display: flex; |
| 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; |
| } |
| |
| .markdown-content code:not(pre code) { |
| background-color: rgba(110, 118, 129, 0.4); |
| padding: 0.2em 0.4em; |
| margin: 0 0.1em; |
| font-size: 85%; |
| border-radius: 6px; |
| font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .tts-controls { |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| margin-top: 0.5rem; |
| } |
| |
| |
| .speed-cycle-btn { |
| padding: 0.25rem 0.6rem; |
| font-size: 0.75rem; |
| background-color: #4b5563; |
| color: #e5e7eb; |
| border-radius: 9999px; |
| border: none; |
| cursor: pointer; |
| transition: background-color 0.2s; |
| white-space: nowrap; |
| margin-top: 0.5rem; |
| } |
| .speed-cycle-btn:hover { |
| background-color: #1f0bb8e6; |
| } |
| .speed-cycle-btn:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| } |
| |
| |
| .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>`; |
| } |
| |
| |
| |
| 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 = ''; |
| |
| 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; |
| |
| currentModelInfo = { |
| model: result.model_name, |
| mode: result.mode, |
| simpleModelName: simpleModelName |
| }; |
| sessionStorage.setItem('cognichat_model_info', JSON.stringify(currentModelInfo)); |
| sessionStorage.setItem('cognichat_filename', result.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(); |
| } finally { |
| loadingOverlay.classList.add('hidden'); |
| fileNameSpan.textContent = ''; |
| fileUploadInput.value = ''; |
| } |
| } |
| |
| |
| 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; |
| let typingIndicatorElement = null; |
| |
| |
| 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; |
| } |
| |
| if (typingIndicatorElement && typingIndicatorElement.parentNode) { |
| typingIndicatorElement.parentNode.removeChild(typingIndicatorElement); |
| typingIndicatorElement = null; |
| } |
| |
| |
| if (botMessageContainer && contentDiv) { |
| const hasErrorMsg = contentDiv.innerHTML.includes('⚠️'); |
| |
| if (!hasErrorMsg && fullResponse) { |
| |
| contentDiv.innerHTML = marked.parse(fullResponse); |
| |
| contentDiv.querySelectorAll('pre').forEach(addCopyButton); |
| addTextToSpeechControls(botMessageContainer, fullResponse); |
| |
| |
| } |
| scrollToBottom(true); |
| } |
| |
| |
| chatInput.disabled = false; |
| chatSubmitBtn.disabled = false; |
| chatInput.focus(); |
| } |
| |
| try { |
| |
| botMessageContainer = appendMessage('', 'bot', currentModelInfo); |
| contentDiv = botMessageContainer.querySelector('.markdown-content'); |
| |
| |
| typingIndicatorElement = showTypingIndicator(); |
| if (contentDiv) { |
| contentDiv.appendChild(typingIndicatorElement); |
| scrollToBottom(true); |
| } else { |
| console.error("Could not find contentDiv to append typing indicator."); |
| } |
| |
| |
| |
| 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."); |
| |
| if (typingIndicatorElement && typingIndicatorElement.parentNode) { |
| typingIndicatorElement.parentNode.removeChild(typingIndicatorElement); |
| typingIndicatorElement = null; |
| } |
| streamClosedCleanly = false; |
| }; |
| |
| eventSource.onmessage = (event) => { |
| |
| if (typingIndicatorElement && typingIndicatorElement.parentNode) { |
| typingIndicatorElement.parentNode.removeChild(typingIndicatorElement); |
| typingIndicatorElement = null; |
| } |
| |
| |
| if (inactivityTimeout) clearTimeout(inactivityTimeout); |
| inactivityTimeout = setTimeout(() => { |
| console.log("Inactivity timeout triggered after message."); |
| streamClosedCleanly = true; |
| finalizeChat(false); |
| }, 5000); |
| |
| 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); |
| return; |
| } |
| |
| if (data.token !== undefined && data.token !== null) { |
| fullResponse += data.token; |
| |
| contentDiv.innerHTML = marked.parse(fullResponse); |
| scrollToBottom(); |
| } |
| }; |
| |
| eventSource.onerror = (error) => { |
| console.error('SSE connection error event:', error); |
| |
| if (typingIndicatorElement && typingIndicatorElement.parentNode) { |
| typingIndicatorElement.parentNode.removeChild(typingIndicatorElement); |
| typingIndicatorElement = null; |
| } |
| |
| if (!fullResponse && !streamClosedCleanly) { |
| const errorMsg = "⚠️ Connection error. Please try again."; |
| if (contentDiv) { |
| contentDiv.innerHTML = `<p class="text-red-500 font-semibold">${errorMsg}</p>`; |
| } else { |
| |
| appendMessage(errorMsg, 'bot', currentModelInfo); |
| } |
| streamClosedCleanly = false; |
| } else if (!streamClosedCleanly) { |
| |
| console.log("SSE connection closed (likely normal end detected by onerror)."); |
| streamClosedCleanly = true; |
| } else { |
| console.log("SSE onerror event after stream already marked cleanly closed.") |
| } |
| finalizeChat(!streamClosedCleanly); |
| }; |
| |
| } catch (error) { |
| |
| console.error('Chat setup error:', 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); |
| } |
| finalizeChat(true); |
| } |
| }); |
| |
| |
| |
| |
| 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`; |
| |
| 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); |
| |
| if (sender === 'user' || text) { |
| scrollToBottom(true); |
| } |
| |
| return messageWrapper.querySelector('.flex-1'); |
| } |
| |
| |
| function showTypingIndicator() { |
| const indicator = document.createElement('div'); |
| indicator.className = 'typing-indicator'; |
| indicator.innerHTML = '<span></span><span></span><span></span>'; |
| |
| return indicator; |
| } |
| |
| |
| |
| function scrollToBottom(force = false) { |
| const isNearBottom = chatWindow.scrollHeight - chatWindow.clientHeight <= chatWindow.scrollTop + 150; |
| |
| if (force || isNearBottom) { |
| requestAnimationFrame(() => { |
| chatWindow.scrollTo({ |
| top: chatWindow.scrollHeight, |
| behavior: 'smooth' |
| }); |
| }); |
| } |
| } |
| |
| function addCopyButton(pre) { |
| if (pre.querySelector('.copy-code-btn')) return; |
| |
| const button = document.createElement('button'); |
| |
| 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'; |
| 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); |
| }); |
| }); |
| } |
| |
| |
| let currentAudio = null; |
| let currentPlayingButton = null; |
| 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]; |
| |
| |
| function addTextToSpeechControls(messageBubble, text) { |
| if (!text || !text.trim()) return; |
| const ttsControls = messageBubble.querySelector('.tts-controls'); |
| if (!ttsControls || ttsControls.querySelector('.speak-btn')) return; |
| |
| |
| 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'); |
| ttsControls.appendChild(speakButton); |
| speakButton.addEventListener('click', () => handleTTS(text, speakButton)); |
| |
| |
| const speedButton = document.createElement('button'); |
| speedButton.className = 'speed-cycle-btn'; |
| speedButton.title = 'Cycle playback speed'; |
| speedButton.textContent = 'Speed: 1x'; |
| speedButton.setAttribute('data-speeds', JSON.stringify(availableSpeeds)); |
| ttsControls.appendChild(speedButton); |
| speedButton.addEventListener('click', () => cycleSpeed(speedButton, speakButton)); |
| } |
| |
| |
| 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); |
| |
| |
| let nextIndex = (currentIndex + 1) % speeds.length; |
| let nextSpeed = speeds[nextIndex]; |
| |
| |
| speakBtn.setAttribute('data-current-speed', nextSpeed.toString()); |
| speedBtn.textContent = `Speed: ${nextSpeed}x`; |
| |
| |
| if (currentAudio && !currentAudio.paused && speakBtn === currentPlayingButton) { |
| currentAudio.playbackRate = nextSpeed; |
| } |
| } |
| |
| |
| async function handleTTS(text, button) { |
| if (!text || !text.trim()) return; |
| |
| |
| const selectedSpeed = parseFloat(button.getAttribute('data-current-speed')) || 1.0; |
| |
| if (button === currentPlayingButton) { |
| if (currentAudio && !currentAudio.paused) { |
| currentAudio.pause(); |
| button.innerHTML = `${playIconSVG} <span>Listen</span>`; |
| } else if (currentAudio && currentAudio.paused) { |
| currentAudio.playbackRate = selectedSpeed; |
| currentAudio.play().catch(e => {console.error("Audio resume error:", e); resetAllSpeakButtons();}); |
| button.innerHTML = `${pauseIconSVG} <span>Pause</span>`; |
| } |
| return; |
| } |
| |
| |
| resetAllSpeakButtons(); |
| currentPlayingButton = button; |
| button.innerHTML = `<div class="tts-button-loader mr-1"></div> <span>Loading...</span>`; |
| button.disabled = true; |
| |
| 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); |
| |
| |
| currentAudio.playbackRate = selectedSpeed; |
| |
| await currentAudio.play(); |
| button.innerHTML = `${pauseIconSVG} <span>Pause</span>`; |
| button.disabled = false; |
| |
| if(speedBtn) speedBtn.disabled = false; |
| |
| |
| currentAudio.onended = () => { |
| |
| 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(); |
| } |
| } |
| |
| |
| function resetAllSpeakButtons() { |
| document.querySelectorAll('.speak-btn').forEach(btn => { |
| btn.innerHTML = `${playIconSVG} <span>Listen</span>`; |
| btn.disabled = false; |
| btn.setAttribute('data-current-speed', '1.0'); |
| }); |
| document.querySelectorAll('.speed-cycle-btn').forEach(btn => { |
| btn.textContent = 'Speed: 1x'; |
| btn.disabled = false; |
| }); |
| |
| if (currentAudio) { |
| currentAudio.pause(); |
| currentAudio.onended = null; |
| currentAudio.onerror = null; |
| currentAudio = null; |
| } |
| currentPlayingButton = null; |
| } |
| |
| |
| |
| |
| |
| |
| }); |
| </script> |
| </body> |
| </html> |