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