Piper-TTS-Ollama / index.html
HirCoir's picture
Update index.html
a762856 verified
<!DOCTYPE html>
<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/tailwindcss@2.2.19/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 !important;
}
.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">&times;</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 class="flex-grow overflow-y-auto px-4 sidebar-content">
<h3 class="text-sm font-semibold text-gray-400 mb-2">Saved Chats</h3>
<div id="chatsList" class="space-y-2">
{% for chat in chat_files %}
<div class="chat-file hover:bg-gray-700 p-2 rounded-lg cursor-pointer transition" data-file="{{ chat }}">
<i class="fas fa-comment-alt mr-2"></i>{{ chat }}
</div>
{% endfor %}
</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 currentChatFile = null;
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 loadChat(chatFile) {
fetch(`/api/load_chat/${chatFile}`)
.then(response => response.json())
.then(data => {
currentChatFile = chatFile;
conversationHistory = data.messages;
document.getElementById('chatArea').innerHTML = '';
conversationHistory.forEach((message, index) => {
addMessage(message.content, message.role === 'user', index);
});
});
}
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';
try {
const response = await fetch('/api/update_message', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
chat_file: currentChatFile,
message_index: editingMessageIndex,
content: newContent,
is_user: isUserMessage
})
});
const data = await response.json();
if (data.success) {
// Update conversation history and UI
conversationHistory = data.messages;
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);
}
}
} catch (error) {
console.error('Error saving edited message:', error);
alert('Failed to save the edited message');
}
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('chatsList').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('chatsList').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,
chat_file: currentChatFile || ''
}),
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', () => {
currentChatFile = null;
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;
}
});
document.getElementById('chatsList').addEventListener('click', (e) => {
const chatFile = e.target.dataset.file;
if (chatFile) {
loadChat(chatFile);
}
});
// 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';
document.querySelectorAll('.chat-file span').forEach(span => span.style.display = 'none');
} else {
sidebar.classList.remove('sidebar-collapsed');
sidebar.classList.add('sidebar-expanded');
sidebarTitle.style.display = 'block';
document.querySelectorAll('.chat-file span').forEach(span => span.style.display = 'inline');
}
});
// 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';
document.querySelectorAll('.chat-file span').forEach(span => span.style.display = 'none');
}
}
window.addEventListener('resize', handleResize);
handleResize(); // Initial check
</script>
</body>
</html>