Add interactive Chat with LLM interface to Web UI

Features:
- Real-time streaming chat interface (ChatGPT-like experience)
- Model selection: Text model (fast) or Vision model (image analysis)
- System prompt toggle: Chat with Miku's personality or raw LLM
- Mood selector: Choose from 14 different emotional states
- Full context integration: Uses complete miku_lore.txt, miku_prompt.txt, and miku_lyrics.txt
- Conversation memory: Maintains chat history throughout session
- Image upload support for vision model
- Horizontal scrolling tabs for responsive design
- Clear chat history functionality
- SSE (Server-Sent Events) for streaming responses
- Keyboard shortcuts (Ctrl+Enter to send)

Technical changes:
- Added POST /chat/stream endpoint in api.py with streaming support
- Updated ChatMessage model with mood, conversation_history, and image_data
- Integrated context_manager for proper Miku personality context
- Added Chat with LLM tab to index.html
- Implemented JavaScript streaming client with EventSource-like handling
- Added CSS for chat messages, typing indicators, and animations
- Made tab navigation horizontally scrollable for narrow viewports
This commit is contained in:
2025-12-13 00:23:03 +02:00
parent 65e6c3e7ea
commit bb82b7f146
23 changed files with 2926 additions and 8 deletions

View File

@@ -419,6 +419,28 @@
display: flex;
border-bottom: 2px solid #333;
margin-bottom: 1rem;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: thin;
scrollbar-color: #555 #222;
}
.tab-buttons::-webkit-scrollbar {
height: 8px;
}
.tab-buttons::-webkit-scrollbar-track {
background: #222;
}
.tab-buttons::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.tab-buttons::-webkit-scrollbar-thumb:hover {
background: #666;
}
.tab-button {
@@ -430,6 +452,8 @@
border-bottom: 3px solid transparent;
margin-right: 0.5rem;
transition: all 0.3s ease;
flex-shrink: 0;
white-space: nowrap;
}
.tab-button:hover {
@@ -450,6 +474,110 @@
.tab-content.active {
display: block;
}
/* Chat Interface Styles */
.chat-message {
margin-bottom: 1rem;
padding: 1rem;
border-radius: 8px;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.chat-message.user-message {
background: #2a3a4a;
border-left: 4px solid #4CAF50;
margin-left: 2rem;
}
.chat-message.assistant-message {
background: #3a2a3a;
border-left: 4px solid #61dafb;
margin-right: 2rem;
}
.chat-message.error-message {
background: #4a2a2a;
border-left: 4px solid #f44336;
}
.chat-message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.chat-message-sender {
font-weight: bold;
color: #61dafb;
}
.chat-message.user-message .chat-message-sender {
color: #4CAF50;
}
.chat-message-time {
color: #888;
font-size: 0.8rem;
}
.chat-message-content {
color: #ddd;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.chat-typing-indicator {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem;
}
.chat-typing-indicator span {
width: 8px;
height: 8px;
background: #61dafb;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.chat-typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.chat-typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.7; }
30% { transform: translateY(-10px); opacity: 1; }
}
#chat-messages::-webkit-scrollbar {
width: 8px;
}
#chat-messages::-webkit-scrollbar-track {
background: #1e1e1e;
}
#chat-messages::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
#chat-messages::-webkit-scrollbar-thumb:hover {
background: #666;
}
</style>
</head>
<body>
@@ -468,6 +596,7 @@
<button class="tab-button" onclick="switchTab('tab3')">Status</button>
<button class="tab-button" onclick="switchTab('tab4')">🎨 Image Generation</button>
<button class="tab-button" onclick="switchTab('tab5')">📊 Autonomous Stats</button>
<button class="tab-button" onclick="switchTab('tab6')">💬 Chat with LLM</button>
</div>
<!-- Tab 1 Content -->
@@ -904,6 +1033,132 @@
</div>
</div>
<!-- Chat with LLM Tab Content -->
<div id="tab6" class="tab-content">
<div class="section">
<h3>💬 Chat with LLM</h3>
<p>Direct chat interface with the language models. Test responses, experiment with prompts, or just chat with Miku!</p>
<!-- Configuration Options -->
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1.5rem;">
<h4 style="margin-top: 0; color: #61dafb;">⚙️ Chat Configuration</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.5rem; margin-bottom: 1rem;">
<!-- Model Selection -->
<div>
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🤖 Model Type:</label>
<div style="display: flex; gap: 1rem;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="chat-model-type" value="text" checked onchange="toggleChatImageUpload()">
<span style="margin-left: 0.5rem;">💬 Text Model (Fast)</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="chat-model-type" value="vision" onchange="toggleChatImageUpload()">
<span style="margin-left: 0.5rem;">👁️ Vision Model (Images)</span>
</label>
</div>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
Text model for conversations, Vision model for image analysis
</div>
</div>
<!-- System Prompt Toggle -->
<div>
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🎭 System Prompt:</label>
<div style="display: flex; gap: 1rem;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="chat-system-prompt" value="true" checked>
<span style="margin-left: 0.5rem;">✅ Use Miku Personality</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="radio" name="chat-system-prompt" value="false">
<span style="margin-left: 0.5rem;">❌ Raw LLM (No Prompt)</span>
</label>
</div>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
With prompt: Chat as Miku. Without: Direct LLM responses
</div>
</div>
<!-- Mood Selection -->
<div>
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">😊 Miku's Mood:</label>
<select id="chat-mood-select" style="width: 100%; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;">
<option value="neutral" selected>neutral</option>
<option value="angry">💢 angry</option>
<option value="asleep">💤 asleep</option>
<option value="bubbly">🫧 bubbly</option>
<option value="curious">👀 curious</option>
<option value="excited">✨ excited</option>
<option value="flirty">🫦 flirty</option>
<option value="irritated">😒 irritated</option>
<option value="melancholy">🍷 melancholy</option>
<option value="romantic">💌 romantic</option>
<option value="serious">👔 serious</option>
<option value="shy">👉👈 shy</option>
<option value="silly">🪿 silly</option>
<option value="sleepy">🌙 sleepy</option>
</select>
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
Choose Miku's emotional state for this conversation
</div>
</div>
</div>
<!-- Image Upload for Vision Model -->
<div id="chat-image-upload-section" style="display: none; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #444;">
<label style="display: block; margin-bottom: 0.5rem; font-weight: bold;">🖼️ Upload Image:</label>
<input type="file" id="chat-image-file" accept="image/*" style="margin-bottom: 0.5rem;">
<div style="font-size: 0.85rem; color: #aaa;">
Upload an image for the vision model to analyze
</div>
<div id="chat-image-preview" style="margin-top: 0.5rem; display: none;">
<img id="chat-image-preview-img" style="max-width: 200px; max-height: 200px; border: 1px solid #555; border-radius: 4px;">
</div>
</div>
<!-- Clear Chat Button -->
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #444;">
<button onclick="clearChatHistory()" style="background: #ff9800;">🗑️ Clear Chat History</button>
<span style="margin-left: 1rem; font-size: 0.85rem; color: #aaa;">Remove all messages from this session</span>
</div>
</div>
<!-- Chat Messages Container -->
<div id="chat-messages" style="background: #1e1e1e; border: 1px solid #444; border-radius: 8px; padding: 1rem; min-height: 400px; max-height: 500px; overflow-y: auto; margin-bottom: 1rem;">
<div style="text-align: center; color: #888; padding: 2rem;">
💬 Start chatting with the LLM! Your conversation will appear here.
</div>
</div>
<!-- Chat Input Area -->
<div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<div style="flex: 1;">
<textarea
id="chat-input"
placeholder="Type your message here..."
rows="3"
style="width: 100%; padding: 0.75rem; background: #2a2a2a; color: #fff; border: 1px solid #555; border-radius: 4px; font-family: inherit; resize: vertical;"
onkeydown="handleChatKeyPress(event)"
></textarea>
</div>
<div>
<button
id="chat-send-btn"
onclick="sendChatMessage()"
style="padding: 1rem 1.5rem; height: 100%; background: #4CAF50; font-size: 1rem; font-weight: bold;"
>
📤 Send
</button>
</div>
</div>
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: #aaa;">
💡 Tip: Press Ctrl+Enter to send your message quickly
</div>
</div>
</div>
</div>
</div>
@@ -3244,6 +3499,320 @@ function populateAutonomousServerDropdown() {
}
}
// ========== Chat Interface Functions ==========
// Store conversation history for context
let chatConversationHistory = [];
// Toggle image upload section based on model type
function toggleChatImageUpload() {
const modelType = document.querySelector('input[name="chat-model-type"]:checked').value;
const imageUploadSection = document.getElementById('chat-image-upload-section');
if (modelType === 'vision') {
imageUploadSection.style.display = 'block';
} else {
imageUploadSection.style.display = 'none';
}
}
// Preview uploaded image
document.addEventListener('DOMContentLoaded', function() {
const imageInput = document.getElementById('chat-image-file');
if (imageInput) {
imageInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(event) {
const preview = document.getElementById('chat-image-preview');
const previewImg = document.getElementById('chat-image-preview-img');
previewImg.src = event.target.result;
preview.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
}
});
// Handle Enter key in chat input
function handleChatKeyPress(event) {
if (event.ctrlKey && event.key === 'Enter') {
event.preventDefault();
sendChatMessage();
}
}
// Clear chat history
function clearChatHistory() {
if (confirm('Are you sure you want to clear all chat messages?')) {
const chatMessages = document.getElementById('chat-messages');
chatMessages.innerHTML = `
<div style="text-align: center; color: #888; padding: 2rem;">
💬 Start chatting with the LLM! Your conversation will appear here.
</div>
`;
// Clear conversation history array
chatConversationHistory = [];
showNotification('Chat history cleared');
}
}
// Add a message to the chat display
function addChatMessage(sender, content, isError = false) {
const chatMessages = document.getElementById('chat-messages');
// Remove welcome message if it exists
const welcomeMsg = chatMessages.querySelector('div[style*="text-align: center"]');
if (welcomeMsg) {
welcomeMsg.remove();
}
const messageDiv = document.createElement('div');
const messageClass = isError ? 'error-message' : (sender === 'You' ? 'user-message' : 'assistant-message');
messageDiv.className = `chat-message ${messageClass}`;
const timestamp = new Date().toLocaleTimeString();
messageDiv.innerHTML = `
<div class="chat-message-header">
<span class="chat-message-sender">${sender}</span>
<span class="chat-message-time">${timestamp}</span>
</div>
<div class="chat-message-content">${content}</div>
`;
chatMessages.appendChild(messageDiv);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
return messageDiv;
}
// Add typing indicator
function showTypingIndicator() {
const chatMessages = document.getElementById('chat-messages');
const typingDiv = document.createElement('div');
typingDiv.id = 'chat-typing-indicator';
typingDiv.className = 'chat-message assistant-message';
typingDiv.innerHTML = `
<div class="chat-message-header">
<span class="chat-message-sender">Miku</span>
<span class="chat-message-time">typing...</span>
</div>
<div class="chat-typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
`;
chatMessages.appendChild(typingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Remove typing indicator
function hideTypingIndicator() {
const typingIndicator = document.getElementById('chat-typing-indicator');
if (typingIndicator) {
typingIndicator.remove();
}
}
// Send chat message with streaming support
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const message = input.value.trim();
if (!message) {
showNotification('Please enter a message', 'error');
return;
}
// Get configuration
const modelType = document.querySelector('input[name="chat-model-type"]:checked').value;
const useSystemPrompt = document.querySelector('input[name="chat-system-prompt"]:checked').value === 'true';
const selectedMood = document.getElementById('chat-mood-select').value;
// Get image data if vision model
let imageData = null;
if (modelType === 'vision') {
const imageFile = document.getElementById('chat-image-file').files[0];
if (imageFile) {
try {
imageData = await readFileAsBase64(imageFile);
// Remove data URL prefix if present
if (imageData.includes(',')) {
imageData = imageData.split(',')[1];
}
} catch (error) {
showNotification('Failed to read image file', 'error');
return;
}
}
}
// Disable send button
const sendBtn = document.getElementById('chat-send-btn');
const originalBtnText = sendBtn.innerHTML;
sendBtn.disabled = true;
sendBtn.innerHTML = '⏳ Sending...';
// Add user message to display
addChatMessage('You', message);
// Clear input
input.value = '';
// Show typing indicator
showTypingIndicator();
try {
// Build user message for history
let userMessageContent;
if (modelType === 'vision' && imageData) {
// Vision model with image - store as multimodal content
userMessageContent = [
{
"type": "text",
"text": message
},
{
"type": "image_url",
"image_url": {
"url": `data:image/jpeg;base64,${imageData}`
}
}
];
} else {
// Text-only message
userMessageContent = message;
}
// Prepare request payload with conversation history
const payload = {
message: message,
model_type: modelType,
use_system_prompt: useSystemPrompt,
image_data: imageData,
conversation_history: chatConversationHistory,
mood: selectedMood
};
// Make streaming request
const response = await fetch('/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Hide typing indicator
hideTypingIndicator();
// Create message element for streaming response
const assistantName = useSystemPrompt ? 'Miku' : 'LLM';
const responseDiv = addChatMessage(assistantName, '');
const contentDiv = responseDiv.querySelector('.chat-message-content');
// Read stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullResponse = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
try {
const data = JSON.parse(dataStr);
if (data.error) {
contentDiv.textContent = `❌ Error: ${data.error}`;
responseDiv.classList.add('error-message');
break;
}
if (data.content) {
fullResponse += data.content;
contentDiv.textContent = fullResponse;
// Auto-scroll
const chatMessages = document.getElementById('chat-messages');
chatMessages.scrollTop = chatMessages.scrollHeight;
}
if (data.done) {
break;
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
// If no response was received, show error
if (!fullResponse) {
contentDiv.textContent = '❌ No response received from LLM';
responseDiv.classList.add('error-message');
} else {
// Add user message to conversation history
chatConversationHistory.push({
role: "user",
content: userMessageContent
});
// Add assistant response to conversation history
chatConversationHistory.push({
role: "assistant",
content: fullResponse
});
console.log('💬 Conversation history updated:', chatConversationHistory.length, 'messages');
}
} catch (error) {
console.error('Chat error:', error);
hideTypingIndicator();
addChatMessage('Error', `Failed to send message: ${error.message}`, true);
showNotification('Failed to send message', 'error');
} finally {
// Re-enable send button
sendBtn.disabled = false;
sendBtn.innerHTML = originalBtnText;
}
}
// Helper function to read file as base64
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
</script>
</body>