Files
nimue/templates/chat.html
T
Arch Agent f4b79a1004 feat: Vision-Support für Gemma-4, Bilderkennung im Web UI
- Modell gewechselt zu aratan/gemma-4-E4B-q8-it-heretic:latest
- Multimodale Anfragen (Text + Bild) über Ollama API
- Bild-Upload im Chat-Interface mit Vorschau
- Automatisches Image-Resizing und JPEG-Kompression
- Vision-Regeln im Persona-Prompt integriert
- Memory-System erweitert für Bildhinweise
- Frontend: Bildvorschau, Upload-Button, responsive Styling
- README aktualisiert
2026-05-04 13:44:00 +02:00

285 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nimue - {{ persona_name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<header>
<h1>🜏 {{ persona_name }}</h1>
<div class="subtitle">Intimate AI Companion</div>
<div class="model-info">Model: <span id="model-name">Loading...</span> | <span id="vision-badge" style="display:none;">👁 Vision Enabled</span></div>
<div class="memory-status" id="memory-status"></div>
</header>
<div class="chat-container" id="chat-box">
<div class="message system">
*kneels gracefully, eyes lowered* I'm here for you completely... waiting for your instructions. What would please you today?
</div>
</div>
<div class="input-area">
<div class="typing-indicator" id="typing" style="display: none;">Nimue is thinking...</div>
<!-- Image Preview Area -->
<div id="image-preview-container" class="image-preview-container" style="display: none;">
<img id="image-preview" class="image-preview" src="" alt="Preview">
<button onclick="removeImage()" class="remove-image-btn" title="Remove image">×</button>
</div>
<div class="input-row">
<textarea id="user-input" placeholder="Command me..." maxlength="2000"></textarea>
<input type="file" id="image-input" accept="image/*" style="display: none;">
<button id="upload-btn" class="upload-btn" title="Send image">📷</button>
<button id="send-btn" class="send-btn">Send</button>
</div>
<div class="controls">
<button onclick="clearMemory()" class="control-btn">Clear Memory</button>
<button onclick="showMemory()" class="control-btn">View Memory</button>
<span class="char-count"><span id="char-count">0</span>/2000</span>
</div>
</div>
</div>
<!-- Memory Modal -->
<div id="memory-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeMemory()">&times;</span>
<h2>Memory Status</h2>
<div id="memory-stats"></div>
<div id="memory-preferences"></div>
<div id="memory-recent"></div>
</div>
</div>
<script>
const chatBox = document.getElementById('chat-box');
const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn');
const uploadBtn = document.getElementById('upload-btn');
const imageInput = document.getElementById('image-input');
const imagePreviewContainer = document.getElementById('image-preview-container');
const imagePreview = document.getElementById('image-preview');
const typing = document.getElementById('typing');
const charCount = document.getElementById('char-count');
let isGenerating = false;
let currentMessageDiv = null;
let currentImageBase64 = null;
// Load config
fetch('/api/config')
.then(r => r.json())
.then(data => {
document.getElementById('model-name').textContent = data.model;
if (data.vision) {
document.getElementById('vision-badge').style.display = 'inline';
}
});
userInput.addEventListener('input', () => {
charCount.textContent = userInput.value.length;
});
userInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
sendBtn.addEventListener('click', sendMessage);
uploadBtn.addEventListener('click', () => imageInput.click());
imageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate size (rough check, backend enforces strict limit)
if (file.size > 8 * 1024 * 1024) {
alert('Image too large. Maximum 8MB.');
imageInput.value = '';
return;
}
const reader = new FileReader();
reader.onload = (evt) => {
currentImageBase64 = evt.target.result; // data:image/...;base64,...
imagePreview.src = currentImageBase64;
imagePreviewContainer.style.display = 'flex';
};
reader.readAsDataURL(file);
});
function removeImage() {
currentImageBase64 = null;
imagePreview.src = '';
imagePreviewContainer.style.display = 'none';
imageInput.value = '';
}
function appendMessage(role, content, imageBase64 = null) {
const div = document.createElement('div');
div.className = `message ${role}`;
let html = '';
if (imageBase64) {
html += `<img src="${imageBase64}" class="message-image" alt="Shared image"><br>`;
}
html += formatMessage(content);
div.innerHTML = html;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
return div;
}
function formatMessage(text) {
return text
.replace(/\n/g, '<br>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/"([^"]+)"/g, '&quot;$1&quot;');
}
function updateMemoryStatus(tokens, max) {
const percent = (tokens / max * 100).toFixed(1);
const status = document.getElementById('memory-status');
status.innerHTML = `Context: ${percent}% (${tokens} tokens)`;
status.className = percent > 80 ? 'memory-status warning' : 'memory-status';
}
async function sendMessage() {
if (isGenerating) return;
const message = userInput.value.trim();
if (!message && !currentImageBase64) return;
// Add user message to chat immediately
appendMessage('user', message || '[Image]', currentImageBase64);
userInput.value = '';
charCount.textContent = '0';
isGenerating = true;
typing.style.display = 'block';
sendBtn.disabled = true;
uploadBtn.disabled = true;
currentMessageDiv = document.createElement('div');
currentMessageDiv.className = 'message assistant';
chatBox.appendChild(currentMessageDiv);
// Prepare payload
const payload = { message: message };
if (currentImageBase64) {
payload.images = [currentImageBase64];
}
// Clear image after sending
const sentImage = currentImageBase64;
removeImage();
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
while (true) {
const {done, value} = 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: ')) {
const text = line.slice(6);
if (text === '[DONE]') continue;
fullText += text;
currentMessageDiv.innerHTML = formatMessage(fullText);
chatBox.scrollTop = chatBox.scrollHeight;
}
}
}
} catch (error) {
currentMessageDiv.innerHTML = '<em>*system error* ' + error.message + '</em>';
} finally {
isGenerating = false;
typing.style.display = 'none';
sendBtn.disabled = false;
uploadBtn.disabled = false;
getMemoryStats();
}
}
async function getMemoryStats() {
try {
const resp = await fetch('/api/memory');
const data = await resp.json();
updateMemoryStatus(data.stats.short_term_tokens, data.stats.max_context);
} catch (e) {}
}
async function clearMemory() {
if (!confirm('Clear all memory?')) return;
await fetch('/api/clear', {method: 'POST'});
chatBox.innerHTML = '<div class="message system">Memory cleared. *kneels again* How may I serve you?</div>';
getMemoryStats();
}
async function showMemory() {
const modal = document.getElementById('memory-modal');
const resp = await fetch('/api/memory');
const data = await resp.json();
document.getElementById('memory-stats').innerHTML = `
<h3>Statistics</h3>
<p>Short-term messages: ${data.stats.short_term_messages}</p>
<p>Tokens used: ${data.stats.short_term_tokens} / ${data.stats.max_context}</p>
<p>Usage: ${data.stats.usage_percent.toFixed(1)}%</p>
`;
let prefs = '<h3>Learned Preferences</h3>';
if (Object.keys(data.preferences).length === 0) {
prefs += '<p>None yet...</p>';
} else {
for (const [cat, items] of Object.entries(data.preferences)) {
prefs += `<p><strong>${cat}:</strong> ${items.join(', ')}</p>`;
}
}
document.getElementById('memory-preferences').innerHTML = prefs;
let recent = '<h3>Recent Messages</h3>';
for (const msg of data.recent) {
const imgTag = msg.has_image ? ' 🖼️' : '';
recent += `<p><strong>${msg.role}${imgTag}:</strong> ${msg.content}</p>`;
}
document.getElementById('memory-recent').innerHTML = recent;
modal.style.display = 'block';
}
function closeMemory() {
document.getElementById('memory-modal').style.display = 'none';
}
window.onclick = (e) => {
const modal = document.getElementById('memory-modal');
if (e.target === modal) modal.style.display = 'none';
};
// Initial load
getMemoryStats();
</script>
</body>
</html>