f4b79a1004
- 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
285 lines
11 KiB
HTML
285 lines
11 KiB
HTML
<!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()">×</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, '"$1"');
|
||
}
|
||
|
||
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>
|