Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AI Chat Interface</title> | |
| <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/tailwind.min.css" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| --primary-color: #2563eb; | |
| --secondary-color: #3b82f6; | |
| --background-dark: #111827; | |
| --sidebar-dark: #1f2937; | |
| --chat-area-dark: #1a1f2b; | |
| } | |
| /* Custom scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: var(--background-dark); | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background-color: #4b5563; | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background-color: #6b7280; | |
| } | |
| /* Chat styles */ | |
| .chat-message { | |
| max-width: 85%; | |
| word-wrap: break-word; | |
| animation: fadeIn 0.3s ease-in-out; | |
| } | |
| .user-message { | |
| background-color: var(--primary-color); | |
| color: white; | |
| margin-left: auto; | |
| } | |
| .assistant-message { | |
| background-color: var(--chat-area-dark); | |
| color: #e5e7eb; | |
| margin-right: auto; | |
| border: 1px solid #374151; | |
| } | |
| /* Code blocks */ | |
| .markdown-content pre { | |
| background-color: #1a1f2b; | |
| padding: 1rem; | |
| border-radius: 0.5rem; | |
| overflow-x: auto; | |
| margin: 1rem 0; | |
| border: 1px solid #374151; | |
| } | |
| .markdown-content code { | |
| font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; | |
| font-size: 0.9em; | |
| padding: 0.2rem 0.4rem; | |
| border-radius: 0.25rem; | |
| background-color: rgba(45, 55, 72, 0.5); | |
| } | |
| /* Context Menu Styles */ | |
| .context-menu { | |
| position: fixed; | |
| background: var(--sidebar-dark); | |
| border: 1px solid #374151; | |
| border-radius: 0.5rem; | |
| padding: 0.5rem 0; | |
| min-width: 160px; | |
| box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | |
| z-index: 1000; | |
| display: none; | |
| } | |
| .context-menu-item { | |
| padding: 0.5rem 1rem; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| transition: background-color 0.2s; | |
| } | |
| .context-menu-item:hover { | |
| background-color: var(--primary-color); | |
| } | |
| .context-menu-item i { | |
| margin-right: 0.5rem; | |
| width: 20px; | |
| } | |
| /* Edit Modal Styles */ | |
| .modal { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0, 0, 0, 0.5); | |
| z-index: 1001; | |
| } | |
| .modal-content { | |
| position: relative; | |
| background-color: var(--sidebar-dark); | |
| margin: 0; | |
| padding: 1.5rem; | |
| border-radius: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .close-modal { | |
| position: absolute; | |
| right: 1rem; | |
| top: 1rem; | |
| cursor: pointer; | |
| font-size: 1.5rem; | |
| color: #9ca3af; | |
| transition: color 0.2s; | |
| } | |
| .close-modal:hover { | |
| color: #f3f4f6; | |
| } | |
| .modal-textarea { | |
| flex-grow: 1; | |
| margin-bottom: 1rem; | |
| } | |
| /* Animations */ | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); } | |
| 50% { transform: scale(1.1); } | |
| 100% { transform: scale(1); } | |
| } | |
| .sidebar-collapsed { | |
| width: 4rem ; | |
| } | |
| .sidebar-expanded { | |
| width: 18rem; | |
| } | |
| .transition-width { | |
| transition: width 0.3s ease-in-out; | |
| } | |
| /* Glass effect */ | |
| .glass-effect { | |
| background: rgba(31, 41, 55, 0.7); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| /* Button effects */ | |
| .hover-shadow { | |
| transition: all 0.3s ease; | |
| } | |
| .hover-shadow:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| /* Input focus effects */ | |
| .input-focus { | |
| transition: all 0.3s ease; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .input-focus:focus { | |
| border-color: var(--primary-color); | |
| box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); | |
| outline: none; | |
| } | |
| /* Responsive design */ | |
| @media (max-width: 768px) { | |
| .chat-message { | |
| max-width: 95%; | |
| } | |
| } | |
| /* Hide sidebar content when collapsed */ | |
| .sidebar-collapsed .sidebar-content { | |
| display: none; | |
| } | |
| .sidebar-collapsed .sidebar-icon { | |
| display: block; | |
| } | |
| /* Disabled UI styles */ | |
| .disabled { | |
| pointer-events: none; | |
| opacity: 0.6; | |
| } | |
| /* Stop button styles */ | |
| .stop-button { | |
| display: none; | |
| animation: pulse 1s infinite; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gradient-to-br from-gray-900 to-gray-800 text-gray-100 min-h-screen"> | |
| <!-- Context Menu --> | |
| <div id="contextMenu" class="context-menu"> | |
| <div class="context-menu-item" id="editMenuItem"> | |
| <i class="fas fa-edit"></i> | |
| <span>Edit</span> | |
| </div> | |
| </div> | |
| <!-- Edit Modal --> | |
| <div id="editModal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="close-modal">×</span> | |
| <h2 class="text-xl font-bold mb-4">Edit Message</h2> | |
| <textarea id="editMessageInput" class="modal-textarea w-full bg-gray-700 input-focus rounded-lg px-3 py-2 text-sm"></textarea> | |
| <div class="flex justify-end space-x-2"> | |
| <button id="cancelEditBtn" class="bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg transition">Cancel</button> | |
| <button id="saveEditBtn" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex h-screen"> | |
| <!-- Collapsible Sidebar --> | |
| <div id="sidebar" class="transition-width sidebar-expanded bg-gray-800 glass-effect flex flex-col h-full"> | |
| <div class="p-4 flex items-center justify-between sidebar-icon"> | |
| <button id="toggleSidebar" class="text-gray-400 hover:text-white"> | |
| <i class="fas fa-bars text-xl"></i> | |
| </button> | |
| <span id="sidebarTitle" class="font-semibold text-lg ml-2 sidebar-content">AI Chat</span> | |
| </div> | |
| <button id="newChatBtn" class="flex items-center m-4 bg-gradient-to-r from-blue-600 to-blue-500 text-white px-4 py-2 rounded-lg hover-shadow sidebar-content"> | |
| <i class="fas fa-plus mr-2"></i> | |
| <span>New Chat</span> | |
| </button> | |
| <div class="p-4 sidebar-content"> | |
| <div class="space-y-3"> | |
| <input type="text" id="baseHost" placeholder="Ollama Base Host" value="http://localhost:11434" | |
| class="w-full bg-gray-700 input-focus rounded-lg px-3 py-2 text-sm"> | |
| <select id="ollamaModel" class="w-full bg-gray-700 input-focus rounded-lg px-3 py-2 text-sm"> | |
| <option value="">Select Model</option> | |
| </select> | |
| <button id="listModelsBtn" class="w-full bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg text-sm transition"> | |
| <i class="fas fa-sync-alt mr-2"></i>List Models | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="flex-grow flex flex-col bg-gray-900"> | |
| <!-- Chat Area --> | |
| <div id="chatArea" class="flex-grow overflow-y-auto p-4 space-y-4"> | |
| <!-- Messages will be inserted here --> | |
| </div> | |
| <!-- Input Area --> | |
| <div class="border-t border-gray-700 p-4 space-y-4"> | |
| <div class="flex space-x-2"> | |
| <textarea id="messageInput" rows="3" placeholder="Type your message here..." | |
| class="flex-grow bg-gray-800 input-focus rounded-lg px-4 py-2 resize-none"></textarea> | |
| <div class="flex flex-col space-y-2"> | |
| <button id="sendBtn" class="bg-gradient-to-r from-blue-600 to-blue-500 px-6 py-2 rounded-lg hover-shadow"> | |
| <i class="fas fa-paper-plane mr-2"></i>Send | |
| </button> | |
| <button id="stopBtn" class="stop-button bg-red-600 hover:bg-red-700 px-6 py-2 rounded-lg transition"> | |
| <i class="fas fa-stop mr-2"></i>Stop | |
| </button> | |
| </div> | |
| </div> | |
| <!-- TTS Controls --> | |
| <div class="flex items-center space-x-4 bg-gray-800 p-4 rounded-lg glass-effect"> | |
| <select id="ttsModel" class="bg-gray-700 input-focus rounded-lg px-3 py-2 text-sm"> | |
| <option value="">Select TTS Model</option> | |
| {% for model in tts_models %} | |
| <option value="{{ model }}">{{ model }}</option> | |
| {% endfor %} | |
| </select> | |
| <button id="playBtn" class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg transition"> | |
| <i class="fas fa-play mr-2"></i>Play | |
| </button> | |
| <button id="pauseBtn" class="bg-yellow-600 hover:bg-yellow-700 px-4 py-2 rounded-lg transition"> | |
| <i class="fas fa-pause mr-2"></i>Pause | |
| </button> | |
| <div class="flex items-center space-x-2"> | |
| <i class="fas fa-volume-up text-gray-400"></i> | |
| <input type="range" id="volumeSlider" min="0" max="100" value="100" | |
| class="w-24 accent-blue-500"> | |
| </div> | |
| <label class="flex items-center space-x-2 text-sm"> | |
| <input type="checkbox" id="removeMarkdown" class="form-checkbox text-blue-500"> | |
| <span>Remove Markdown</span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let conversationHistory = []; | |
| let currentAudio = null; | |
| let currentEventSource = null; | |
| let lastAssistantMessage = null; | |
| let abortController = null; | |
| let selectedOllamaModel = ''; | |
| let selectedTtsModel = ''; | |
| let editingMessageIndex = null; | |
| function escapeHtml(html) { | |
| const div = document.createElement('div'); | |
| div.textContent = html; | |
| return div.innerHTML; | |
| } | |
| function renderMarkdown(text) { | |
| let html = text | |
| .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>') | |
| .replace(/`([^`]+)`/g, '<code>$1</code>') | |
| .replace(/^### (.*$)/gm, '<h3>$1</h3>') | |
| .replace(/^## (.*$)/gm, '<h2>$1</h2>') | |
| .replace(/^# (.*$)/gm, '<h1>$1</h1>') | |
| .replace(/^\* (.*$)/gm, '<li>$1</li>') | |
| .replace(/^\d\. (.*$)/gm, '<li>$1</li>') | |
| .replace(/\n\n/g, '</p><p>') | |
| .replace(/\n/g, '<br>'); | |
| return `<p>${html}</p>`; | |
| } | |
| function addMessage(text, isUser = true, messageIndex = null) { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = `chat-message ${isUser ? 'user-message' : 'assistant-message'} p-4 rounded-lg`; | |
| messageDiv.dataset.index = messageIndex !== null ? messageIndex : conversationHistory.length; | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'markdown-content'; | |
| contentDiv.innerHTML = renderMarkdown(text); | |
| messageDiv.appendChild(contentDiv); | |
| document.getElementById('chatArea').appendChild(messageDiv); | |
| scrollToBottom(); | |
| // Add context menu event listener | |
| messageDiv.addEventListener('contextmenu', showContextMenu); | |
| return messageDiv; | |
| } | |
| function scrollToBottom() { | |
| const chatArea = document.getElementById('chatArea'); | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| } | |
| function showContextMenu(e) { | |
| e.preventDefault(); | |
| const contextMenu = document.getElementById('contextMenu'); | |
| const messageDiv = e.currentTarget; | |
| // Store the message index for editing | |
| editingMessageIndex = parseInt(messageDiv.dataset.index); | |
| // Position the context menu | |
| contextMenu.style.left = `${e.pageX}px`; | |
| contextMenu.style.top = `${e.pageY}px`; | |
| contextMenu.style.display = 'block'; | |
| } | |
| function hideContextMenu() { | |
| document.getElementById('contextMenu').style.display = 'none'; | |
| } | |
| function showEditModal(content) { | |
| const modal = document.getElementById('editModal'); | |
| const input = document.getElementById('editMessageInput'); | |
| input.value = content; | |
| modal.style.display = 'block'; | |
| } | |
| function hideEditModal() { | |
| document.getElementById('editModal').style.display = 'none'; | |
| editingMessageIndex = null; | |
| } | |
| async function saveEditedMessage() { | |
| const newContent = document.getElementById('editMessageInput').value; | |
| const isUserMessage = conversationHistory[editingMessageIndex].role === 'user'; | |
| // Update conversation history and UI | |
| conversationHistory[editingMessageIndex].content = newContent; | |
| document.getElementById('chatArea').innerHTML = ''; | |
| conversationHistory.forEach((message, index) => { | |
| addMessage(message.content, message.role === 'user', index); | |
| }); | |
| // If it was a user message, regenerate the response | |
| if (isUserMessage) { | |
| await sendMessage(false); | |
| } | |
| hideEditModal(); | |
| } | |
| function disableUI() { | |
| document.getElementById('sendBtn').disabled = true; | |
| document.getElementById('newChatBtn').disabled = true; | |
| document.getElementById('listModelsBtn').disabled = true; | |
| document.getElementById('messageInput').disabled = true; | |
| document.getElementById('sidebar').classList.add('disabled'); | |
| document.getElementById('stopBtn').style.display = 'block'; | |
| } | |
| function enableUI() { | |
| document.getElementById('sendBtn').disabled = false; | |
| document.getElementById('newChatBtn').disabled = false; | |
| document.getElementById('listModelsBtn').disabled = false; | |
| document.getElementById('messageInput').disabled = false; | |
| document.getElementById('sidebar').classList.remove('disabled'); | |
| document.getElementById('stopBtn').style.display = 'none'; | |
| } | |
| async function sendMessage(addUserMessage = true) { | |
| const messageInput = document.getElementById('messageInput'); | |
| const text = messageInput.value.trim(); | |
| if (!text && addUserMessage) return; | |
| const model = document.getElementById('ollamaModel').value; | |
| if (!model) { | |
| alert('Please select a model first'); | |
| return; | |
| } | |
| if (addUserMessage) { | |
| // Add user message | |
| addMessage(text, true); | |
| messageInput.value = ''; | |
| // Add to conversation history | |
| conversationHistory.push({ role: 'user', content: text }); | |
| } | |
| // Stop any existing EventSource | |
| if (currentEventSource) { | |
| currentEventSource.close(); | |
| } | |
| const baseHost = document.getElementById('baseHost').value; | |
| // Create a new AbortController | |
| abortController = new AbortController(); | |
| // Disable UI | |
| disableUI(); | |
| try { | |
| const response = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| base_host: baseHost, | |
| model: model, | |
| messages: conversationHistory | |
| }), | |
| signal: abortController.signal | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let assistantMessage = addMessage('', false); | |
| let completeResponse = ''; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value); | |
| const lines = chunk.split('\n'); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| try { | |
| const data = JSON.parse(line.slice(6)); | |
| if (data.error) { | |
| alert(data.error); | |
| break; | |
| } else if (data.chunk) { | |
| completeResponse = data.chunk; | |
| assistantMessage.querySelector('.markdown-content').innerHTML = renderMarkdown(completeResponse); | |
| scrollToBottom(); | |
| } else if (data.done) { | |
| lastAssistantMessage = completeResponse; | |
| conversationHistory.push({ role: 'assistant', content: completeResponse }); | |
| // Convert to speech | |
| const ttsModel = document.getElementById('ttsModel').value; | |
| if (ttsModel) { | |
| convertToSpeech(completeResponse, ttsModel); | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Error parsing SSE data:', e); | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| if (error.name === 'AbortError') { | |
| console.log('Fetch aborted'); | |
| } else { | |
| console.error('Error:', error); | |
| alert('Error sending message: ' + error.message); | |
| } | |
| } finally { | |
| // Enable UI | |
| enableUI(); | |
| } | |
| } | |
| function convertToSpeech(text, model) { | |
| const removeMarkdown = document.getElementById('removeMarkdown').checked; | |
| fetch('/api/tts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text, model, remove_markdown: removeMarkdown }) | |
| }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.error) { | |
| alert(data.error); | |
| } else { | |
| if (currentAudio) { | |
| currentAudio.pause(); | |
| } | |
| currentAudio = new Audio(`/audio/${data.audio_file}`); | |
| currentAudio.volume = document.getElementById('volumeSlider').value / 100; | |
| currentAudio.play(); | |
| } | |
| }); | |
| } | |
| // Event Listeners | |
| document.getElementById('sendBtn').addEventListener('click', () => sendMessage(true)); | |
| document.getElementById('messageInput').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(true); | |
| } | |
| }); | |
| document.getElementById('stopBtn').addEventListener('click', () => { | |
| if (abortController) { | |
| abortController.abort(); | |
| } | |
| }); | |
| document.getElementById('newChatBtn').addEventListener('click', () => { | |
| conversationHistory = []; | |
| document.getElementById('chatArea').innerHTML = ''; | |
| // Restore selected models | |
| document.getElementById('ollamaModel').value = selectedOllamaModel; | |
| document.getElementById('ttsModel').value = selectedTtsModel; | |
| }); | |
| document.getElementById('listModelsBtn').addEventListener('click', () => { | |
| const baseHost = document.getElementById('baseHost').value; | |
| fetch(`/api/list_ollama_models?base_host=${encodeURIComponent(baseHost)}`) | |
| .then(response => response.json()) | |
| .then(data => { | |
| const select = document.getElementById('ollamaModel'); | |
| select.innerHTML = '<option value="">Select Ollama Model</option>'; | |
| data.models.forEach(model => { | |
| const option = document.createElement('option'); | |
| option.value = model; | |
| option.textContent = model; | |
| select.appendChild(option); | |
| }); | |
| }); | |
| }); | |
| document.getElementById('playBtn').addEventListener('click', () => { | |
| if (currentAudio) { | |
| currentAudio.play(); | |
| } else if (lastAssistantMessage) { | |
| const ttsModel = document.getElementById('ttsModel').value; | |
| if (ttsModel) { | |
| convertToSpeech(lastAssistantMessage, ttsModel); | |
| } | |
| } | |
| }); | |
| document.getElementById('pauseBtn').addEventListener('click', () => { | |
| if (currentAudio) { | |
| currentAudio.pause(); | |
| } | |
| }); | |
| document.getElementById('volumeSlider').addEventListener('input', (e) => { | |
| if (currentAudio) { | |
| currentAudio.volume = e.target.value / 100; | |
| } | |
| }); | |
| // Store selected models | |
| document.getElementById('ollamaModel').addEventListener('change', (e) => { | |
| selectedOllamaModel = e.target.value; | |
| }); | |
| document.getElementById('ttsModel').addEventListener('change', (e) => { | |
| selectedTtsModel = e.target.value; | |
| }); | |
| // Context Menu Event Listeners | |
| document.addEventListener('click', hideContextMenu); | |
| document.getElementById('editMenuItem').addEventListener('click', () => { | |
| if (editingMessageIndex !== null) { | |
| const content = conversationHistory[editingMessageIndex].content; | |
| showEditModal(content); | |
| } | |
| hideContextMenu(); | |
| }); | |
| // Edit Modal Event Listeners | |
| document.querySelector('.close-modal').addEventListener('click', hideEditModal); | |
| document.getElementById('cancelEditBtn').addEventListener('click', hideEditModal); | |
| document.getElementById('saveEditBtn').addEventListener('click', saveEditedMessage); | |
| // Initial load of Ollama models | |
| document.getElementById('listModelsBtn').click(); | |
| // Add sidebar toggle functionality | |
| document.getElementById('toggleSidebar').addEventListener('click', () => { | |
| const sidebar = document.getElementById('sidebar'); | |
| const sidebarTitle = document.getElementById('sidebarTitle'); | |
| if (sidebar.classList.contains('sidebar-expanded')) { | |
| sidebar.classList.remove('sidebar-expanded'); | |
| sidebar.classList.add('sidebar-collapsed'); | |
| sidebarTitle.style.display = 'none'; | |
| } else { | |
| sidebar.classList.remove('sidebar-collapsed'); | |
| sidebar.classList.add('sidebar-expanded'); | |
| sidebarTitle.style.display = 'block'; | |
| } | |
| }); | |
| // Add responsive sidebar behavior | |
| function handleResize() { | |
| const sidebar = document.getElementById('sidebar'); | |
| if (window.innerWidth < 768 && !sidebar.classList.contains('sidebar-collapsed')) { | |
| sidebar.classList.remove('sidebar-expanded'); | |
| sidebar.classList.add('sidebar-collapsed'); | |
| document.getElementById('sidebarTitle').style.display = 'none'; | |
| } | |
| } | |
| window.addEventListener('resize', handleResize); | |
| handleResize(); // Initial check | |
| </script> | |
| </body> | |
| </html> |