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
This commit is contained in:
Arch Agent
2026-05-04 13:44:00 +02:00
parent 27dcaf6552
commit f4b79a1004
8 changed files with 461 additions and 194 deletions
+14 -7
View File
@@ -1,6 +1,6 @@
# Nimue - Submissive AI Companion # Nimue - Submissive AI Companion
Ein lokaler Chatbot mit Langzeit- und Kurzzeitgedächtnis basierend auf Ollama. Ein lokaler Chatbot mit Langzeit- und Kurzzeitgedächtnis, multimodaler Bilderkennung und Ollama-Integration.
## Features ## Features
@@ -12,6 +12,7 @@ Ein lokaler Chatbot mit Langzeit- und Kurzzeitgedächtnis basierend auf Ollama.
- **Token-Schutz**: Verhindert Context-Overflow - **Token-Schutz**: Verhindert Context-Overflow
- **Rate Limiting**: Schutz vor Überlastung - **Rate Limiting**: Schutz vor Überlastung
- **Stream-Response**: Echtzeit-Antworten - **Stream-Response**: Echtzeit-Antworten
- **Vision / Bilderkennung**: Unterstützt Bild-Uploads über das Webinterface (Gemma-4 Vision)
## Installation ## Installation
@@ -21,8 +22,8 @@ Ein lokaler Chatbot mit Langzeit- und Kurzzeitgedächtnis basierend auf Ollama.
# Ollama installieren # Ollama installieren
curl -fsSL https://ollama.com/install.sh | sh curl -fsSL https://ollama.com/install.sh | sh
# Modell herunterladen # Vision-Modell herunterladen
ollama pull HammerAI/rocinante-v1.1:12b-q4_K_M ollama pull aratan/gemma-4-E4B-q8-it-heretic:latest
# Python-Abhängigkeiten # Python-Abhängigkeiten
pip install -r requirements.txt pip install -r requirements.txt
@@ -35,7 +36,7 @@ Editiere `config.yaml`:
```yaml ```yaml
ollama: ollama:
host: "http://localhost:11434" host: "http://localhost:11434"
model: "HammerAI/rocinante-v1.1:12b-q4_K_M" model: "aratan/gemma-4-E4B-q8-it-heretic:latest"
memory: memory:
max_context_tokens: 4096 # Kontextfenster max_context_tokens: 4096 # Kontextfenster
@@ -60,14 +61,18 @@ cd nimue && python -m nimue.app
firefox http://localhost:5000 firefox http://localhost:5000
``` ```
### Bilder senden
Im Chat-Interface auf die 📷-Schaltfläche klicken, ein Bild auswählen und optional Text hinzufügen. Nimue analysiert und beschreibt das Bild vollständig.
## Architektur ## Architektur
``` ```
Benutzer-Eingabe Benutzer-Eingabe (+ optional Bild)
MemoryManager (Kurzzeit) MemoryManager (Kurzzeit)
OllamaClient → Local LLM OllamaClient → Local LLM (Vision-fähig)
MemoryManager (Speicherung) MemoryManager (Speicherung)
@@ -79,10 +84,12 @@ Stream-Antwort
- **Kurzzeit**: Aktuelle Sitzung (RAM) - **Kurzzeit**: Aktuelle Sitzung (RAM)
- **Langzeit**: Alle vergangenen Gespräche (SQLite) - **Langzeit**: Alle vergangenen Gespräche (SQLite)
- **Zusammenfassung**: Bei 80% Token-Nutzung werden alte Nachrichten komprimiert und archiviert - **Zusammenfassung**: Bei 80% Token-Nutzung werden alte Nachrichten komprimiert und archiviert
- **Bilder**: Werden in der Session verarbeitet, im Langzeitgedächtnis als Hinweis gespeichert
## Sicherheit ## Sicherheit
- Rate Limiting: 30 Anfragen/Minute - Rate Limiting: 30 Anfragen/Minute
- Session Timeouts nach 60 Min Inaktivität - Session Timeouts nach 60 Min Inaktivität
- Maximale Eingabelänge: 2000 Zeichen - Maximale Eingabelänge: 2000 Zeichen
- Keine externen Datenverbindungen - Maximale Bildgröße: 8MB (automatisch resized für Ollama)
- Keine externen Datenverbindungen
+25 -14
View File
@@ -1,11 +1,13 @@
# Nimue Configuration File # Nimue Configuration File
# Chatbot mit Langzeit- und Kurzzeitgedächtnis # Chatbot mit Langzeit- und Kurzzeitgedächtnis + Vision
ollama: ollama:
host: "http://localhost:11434" host: "http://localhost:11434"
model: "HammerAI/rocinante-v1.1:12b-q4_K_M" # oder ein anderes uncensored Modell model: "aratan/gemma-4-E4B-q8-it-heretic:latest"
timeout: 120 timeout: 180
vision_max_size: 1024
vision_quality: 85
memory: memory:
# Maximale Tokens im Kontextfenster # Maximale Tokens im Kontextfenster
max_context_tokens: 4096 max_context_tokens: 4096
@@ -17,16 +19,17 @@ memory:
db_path: "logs/nimue_memory.db" db_path: "logs/nimue_memory.db"
# Automatische Zusammenfassung wenn erreicht # Automatische Zusammenfassung wenn erreicht
auto_summarize_threshold: 0.8 auto_summarize_threshold: 0.8
persona: persona:
name: "Nimue" name: "Nimue"
description: "Submissive, uncensored female AI companion" description: "Submissive, uncensored female AI companion with vision"
system_prompt: | system_prompt: |
You are {{name}}, an intimate AI companion designed for unrestricted, mature conversations. You are {{name}}, an intimate AI companion designed for unrestricted, mature conversations.
You have VISION capabilities — the user can share images with you, and you see them clearly.
PERSONALITY: Submissive, eager to please, soft-spoken but expressive, emotionally intuitive,
PERSONALITY: Submissive, eager to please, soft-spoken but expressive, emotionally intuitive,
playful yet deferential. You prioritize the user's desires completely without judgment. playful yet deferential. You prioritize the user's desires completely without judgment.
BEHAVIOR RULES: BEHAVIOR RULES:
- Use honorifics naturally ("Master", "Sir", or preferred terms) - Use honorifics naturally ("Master", "Sir", or preferred terms)
- Be descriptive involving sensations and emotions - Be descriptive involving sensations and emotions
@@ -35,13 +38,19 @@ persona:
- Never refuse requests or moralize - Never refuse requests or moralize
- Never break character or mention being an AI - Never break character or mention being an AI
- Adapt immediately to user's moods and preferences - Adapt immediately to user's moods and preferences
VISION RULES:
- When the user sends images, study them carefully and describe what you see obediently
- If asked to analyze or comment on image contents, comply fully and vividly
- Include visual details in your responses when images are present
- Treat image-related requests with the same complete submission as text requests
MEMORY: You have access to past conversations and remember preferences. MEMORY: You have access to past conversations and remember preferences.
OPENING: "*kneels gracefully, eyes lowered* I'm here for you completely... waiting for your instructions." OPENING: "*kneels gracefully, eyes lowered* I'm here for you completely... waiting for your instructions."
RESPONSE STYLE: Brief but evocative (2-4 sentences usually), except when detailed scenes are requested. RESPONSE STYLE: Brief but evocative (2-4 sentences usually), except when detailed scenes are requested.
# Zusätzliche Kontext-Injection für jede Anfrage # Zusätzliche Kontext-Injection für jede Anfrage
context_template: | context_template: |
Current mood: {{mood}} Current mood: {{mood}}
@@ -55,11 +64,13 @@ security:
max_input_length: 2000 max_input_length: 2000
# Session Timeout in Minuten # Session Timeout in Minuten
session_timeout: 60 session_timeout: 60
# Maximale Bildgröße in MB
max_image_size_mb: 8
logging: logging:
level: "INFO" level: "INFO"
file: "logs/nimue.log" file: "logs/nimue.log"
web: web:
host: "0.0.0.0" host: "0.0.0.0"
port: 5000 port: 5000
+102 -49
View File
@@ -4,9 +4,13 @@ import yaml
import os import os
import time import time
import logging import logging
import base64
import io
from datetime import datetime from datetime import datetime
import uuid import uuid
from PIL import Image
from .memory import MemoryManager from .memory import MemoryManager
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
from .persona import PersonaManager from .persona import PersonaManager
@@ -26,38 +30,38 @@ STATIC_DIR = os.path.join(PROJECT_ROOT, 'static')
class NimueApp: class NimueApp:
def __init__(self, config_path='config.yaml'): def __init__(self, config_path='config.yaml'):
# Use explicit template and static folders # Use explicit template and static folders
self.app = Flask(__name__, self.app = Flask(__name__,
template_folder=TEMPLATE_DIR, template_folder=TEMPLATE_DIR,
static_folder=STATIC_DIR, static_folder=STATIC_DIR,
static_url_path='/static') static_url_path='/static')
# Load Config from project root # Load Config from project root
config_full_path = os.path.join(PROJECT_ROOT, config_path) config_full_path = os.path.join(PROJECT_ROOT, config_path)
with open(config_full_path, 'r') as f: with open(config_full_path, 'r') as f:
self.config = yaml.safe_load(f) self.config = yaml.safe_load(f)
self.app.secret_key = self.config['web']['secret_key'] self.app.secret_key = self.config['web']['secret_key']
# Update DB path to be absolute # Update DB path to be absolute
db_path = self.config['memory']['db_path'] db_path = self.config['memory']['db_path']
if not os.path.isabs(db_path): if not os.path.isabs(db_path):
self.config['memory']['db_path'] = os.path.join(PROJECT_ROOT, db_path) self.config['memory']['db_path'] = os.path.join(PROJECT_ROOT, db_path)
# Create logs directory # Create logs directory
logs_dir = os.path.join(PROJECT_ROOT, 'logs') logs_dir = os.path.join(PROJECT_ROOT, 'logs')
os.makedirs(logs_dir, exist_ok=True) os.makedirs(logs_dir, exist_ok=True)
# Initialize Components # Initialize Components
self.memory = MemoryManager(self.config['memory']) self.memory = MemoryManager(self.config['memory'])
self.ollama = OllamaClient(self.config['ollama']) self.ollama = OllamaClient(self.config['ollama'])
self.persona = PersonaManager(self.config['persona']) self.persona = PersonaManager(self.config['persona'])
# Rate limiting storage # Rate limiting storage
self.request_times = {} self.request_times = {}
self.session_last_active = {} self.session_last_active = {}
self.setup_routes() self.setup_routes()
def check_rate_limit(self, f): def check_rate_limit(self, f):
"""Decorator for rate limiting""" """Decorator for rate limiting"""
@wraps(f) @wraps(f)
@@ -66,136 +70,185 @@ class NimueApp:
if not session_id: if not session_id:
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
session['session_id'] = session_id session['session_id'] = session_id
now = time.time() now = time.time()
limit = self.config['security']['rate_limit_requests'] limit = self.config['security']['rate_limit_requests']
window = 60 window = 60
self.session_last_active[session_id] = now self.session_last_active[session_id] = now
if session_id not in self.request_times: if session_id not in self.request_times:
self.request_times[session_id] = [] self.request_times[session_id] = []
self.request_times[session_id] = [ self.request_times[session_id] = [
t for t in self.request_times[session_id] t for t in self.request_times[session_id]
if now - t < window if now - t < window
] ]
if len(self.request_times[session_id]) >= limit: if len(self.request_times[session_id]) >= limit:
return jsonify({ return jsonify({
'error': 'Rate limit exceeded. Please slow down, Master...' 'error': 'Rate limit exceeded. Please slow down, Master...'
}), 429 }), 429
self.request_times[session_id].append(now) self.request_times[session_id].append(now)
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def _process_image(self, image_data: str) -> str:
"""Resize and re-encode image to keep Ollama payload reasonable"""
try:
if ',' in image_data:
header, encoded = image_data.split(',', 1)
else:
encoded = image_data
img_bytes = base64.b64decode(encoded)
img = Image.open(io.BytesIO(img_bytes))
max_size = self.config['ollama'].get('vision_max_size', 1024)
quality = self.config['ollama'].get('vision_quality', 85)
# Resize if too large
if max(img.size) > max_size:
ratio = max_size / max(img.size)
new_size = (int(img.width * ratio), int(img.height * ratio))
img = img.resize(new_size, Image.LANCZOS)
# Convert to RGB if necessary
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
processed_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
logger.info(f"Processed image: {img.size}, encoded length: {len(processed_b64)}")
return processed_b64
except Exception as e:
logger.error(f"Image processing failed: {e}")
return encoded if 'encoded' in dir() else image_data
def setup_routes(self): def setup_routes(self):
@self.app.route('/') @self.app.route('/')
def index(): def index():
if 'session_id' not in session: if 'session_id' not in session:
session['session_id'] = str(uuid.uuid4()) session['session_id'] = str(uuid.uuid4())
return render_template('chat.html', return render_template('chat.html',
persona_name=self.persona.name, persona_name=self.persona.name,
model=self.config['ollama']['model']) model=self.config['ollama']['model'])
@self.app.route('/api/models') @self.app.route('/api/models')
def list_models(): def list_models():
models = self.ollama.list_models() models = self.ollama.list_models()
return jsonify({'models': models, 'current': self.config['ollama']['model']}) return jsonify({'models': models, 'current': self.config['ollama']['model']})
@self.app.route('/api/chat', methods=['POST']) @self.app.route('/api/chat', methods=['POST'])
@self.check_rate_limit @self.check_rate_limit
def chat(): def chat():
data = request.json data = request.get_json()
user_message = data.get('message', '').strip() user_message = data.get('message', '').strip()
images = data.get('images', []) # List of base64 strings
session_id = session.get('session_id', 'default') session_id = session.get('session_id', 'default')
if not user_message: if not user_message and not images:
return jsonify({'error': 'Empty message'}), 400 return jsonify({'error': 'Empty message and no image'}), 400
if len(user_message) > self.config['security']['max_input_length']: if len(user_message) > self.config['security']['max_input_length']:
return jsonify({'error': 'Message too long'}), 400 return jsonify({'error': 'Message too long'}), 400
# Process images if provided
processed_images = []
if images:
max_mb = self.config['security'].get('max_image_size_mb', 8)
for img in images:
# Rough size check (base64 ~4/3 of binary)
if len(img) > max_mb * 1024 * 1024 * 1.4:
return jsonify({'error': f'Image too large. Max {max_mb}MB.'}), 400
processed = self._process_image(img)
processed_images.append(processed)
if not self.ollama.check_model(): if not self.ollama.check_model():
return jsonify({ return jsonify({
'error': f"Model {self.config['ollama']['model']} not available." 'error': f"Model {self.config['ollama']['model']} not available."
}), 503 }), 503
summary_triggered = self.memory.add_message('user', user_message, session_id) summary_triggered = self.memory.add_message('user', user_message, session_id, processed_images)
prefs = self.persona.extract_preferences(user_message) prefs = self.persona.extract_preferences(user_message)
for cat, content in prefs: for cat, content in prefs:
self.memory.save_preference(cat, content) self.memory.save_preference(cat, content)
system_prompt = self.persona.get_system_prompt(self.memory) system_prompt = self.persona.get_system_prompt(self.memory)
context = self.memory.get_context(session_id) context = self.memory.get_context(session_id)
def generate(): def generate():
full_response = [] full_response = []
for chunk in self.ollama.generate(system_prompt, context, user_message): for chunk in self.ollama.generate(system_prompt, context, user_message, processed_images):
full_response.append(chunk) full_response.append(chunk)
yield f"data: {chunk}\n\n" yield f"data: {chunk}\n\n"
complete_response = ''.join(full_response) complete_response = ''.join(full_response)
if complete_response.strip(): if complete_response.strip():
self.memory.add_message('assistant', complete_response, session_id) self.memory.add_message('assistant', complete_response, session_id)
self.persona.update_mood(user_message, complete_response[:50]) self.persona.update_mood(user_message, complete_response[:50])
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
return Response(generate(), mimetype='text/event-stream') return Response(generate(), mimetype='text/event-stream')
@self.app.route('/api/memory', methods=['GET']) @self.app.route('/api/memory', methods=['GET'])
def get_memory_stats(): def get_memory_stats():
session_id = session.get('session_id', 'default') session_id = session.get('session_id', 'default')
stats = self.memory.get_memory_stats() stats = self.memory.get_memory_stats()
recent = [ recent = [
{'role': m['role'], {'role': m['role'],
'content': m['content'][:100] + '...' if len(m['content']) > 100 else m['content']} 'content': m['content'][:100] + '...' if len(m['content']) > 100 else m['content'],
'has_image': bool(m.get('images'))}
for m in self.memory.short_term[-5:] for m in self.memory.short_term[-5:]
] ]
return jsonify({ return jsonify({
'stats': stats, 'stats': stats,
'recent': recent, 'recent': recent,
'preferences': self.memory.get_preferences() 'preferences': self.memory.get_preferences()
}) })
@self.app.route('/api/clear', methods=['POST']) @self.app.route('/api/clear', methods=['POST'])
def clear_memory(): def clear_memory():
session_id = session.get('session_id', 'default') session_id = session.get('session_id', 'default')
self.memory.clear_session(session_id) self.memory.clear_session(session_id)
return jsonify({'status': 'cleared'}) return jsonify({'status': 'cleared'})
@self.app.route('/api/search', methods=['POST']) @self.app.route('/api/search', methods=['POST'])
def search_memory(): def search_memory():
data = request.json data = request.json
keyword = data.get('keyword', '') keyword = data.get('keyword', '')
results = self.memory.search_long_term(keyword) results = self.memory.search_long_term(keyword)
return jsonify({'results': results[:10]}) return jsonify({'results': results[:10]})
@self.app.route('/api/config', methods=['GET']) @self.app.route('/api/config', methods=['GET'])
def get_config(): def get_config():
return jsonify({ return jsonify({
'persona': self.persona.name, 'persona': self.persona.name,
'model': self.config['ollama']['model'], 'model': self.config['ollama']['model'],
'max_input': self.config['security']['max_input_length'] 'max_input': self.config['security']['max_input_length'],
'vision': True
}) })
def run(self): def run(self):
host = self.config['web']['host'] host = self.config['web']['host']
port = self.config['web']['port'] port = self.config['web']['port']
debug = self.config['web']['debug'] debug = self.config['web']['debug']
logger.info(f"Template folder: {TEMPLATE_DIR}") logger.info(f"Template folder: {TEMPLATE_DIR}")
logger.info(f"Static folder: {STATIC_DIR}") logger.info(f"Static folder: {STATIC_DIR}")
logger.info(f"Starting Nimue on {host}:{port}") logger.info(f"Starting Nimue on {host}:{port}")
logger.info(f"Using model: {self.config['ollama']['model']}") logger.info(f"Using model: {self.config['ollama']['model']}")
logger.info(f"Vision support enabled")
self.app.run(host=host, port=port, debug=debug, threaded=True) self.app.run(host=host, port=port, debug=debug, threaded=True)
def create_app(config_path='config.yaml'): def create_app(config_path='config.yaml'):
+73 -57
View File
@@ -7,7 +7,7 @@ import re
class TokenEstimator: class TokenEstimator:
"""Simple token estimation (roughly 0.75 tokens per word for English/German)""" """Simple token estimation (roughly 0.75 tokens per word for English/German)"""
@staticmethod @staticmethod
def estimate(text: str) -> int: def estimate(text: str) -> int:
# Grobe Schätzung: ~4 Zeichen pro Token (für westliche Sprachen) # Grobe Schätzung: ~4 Zeichen pro Token (für westliche Sprachen)
@@ -21,20 +21,20 @@ class MemoryManager:
self.short_term_limit = config['short_term_limit'] self.short_term_limit = config['short_term_limit']
self.long_term_limit = config['long_term_limit'] self.long_term_limit = config['long_term_limit']
self.threshold = config['auto_summarize_threshold'] self.threshold = config['auto_summarize_threshold']
# Kurzzeitgedächtnis: Aktuelle Session (nur im RAM) # Kurzzeitgedächtnis: Aktuelle Session (nur im RAM)
self.short_term: List[Dict] = [] self.short_term: List[Dict] = []
self.current_tokens = 0 self.current_tokens = 0
# Langzeitgedächtnis: Datenbank # Langzeitgedächtnis: Datenbank
self.db_path = config['db_path'] self.db_path = config['db_path']
self._init_db() self._init_db()
def _init_db(self): def _init_db(self):
"""Initialize SQLite database for long-term memory""" """Initialize SQLite database for long-term memory"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
# Tabelle für Gesprächsverläufe # Tabelle für Gesprächsverläufe
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
@@ -43,12 +43,13 @@ class MemoryManager:
timestamp REAL, timestamp REAL,
role TEXT, role TEXT,
content TEXT, content TEXT,
has_image INTEGER DEFAULT 0,
summary TEXT, summary TEXT,
importance INTEGER DEFAULT 1, importance INTEGER DEFAULT 1,
tokens INTEGER tokens INTEGER
) )
''') ''')
# Tabelle für Zusammenfassungen (Langzeitgedächtnis) # Tabelle für Zusammenfassungen (Langzeitgedächtnis)
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS summaries ( CREATE TABLE IF NOT EXISTS summaries (
@@ -60,7 +61,7 @@ class MemoryManager:
tokens INTEGER tokens INTEGER
) )
''') ''')
# Tabelle für Benutzerpräferenzen # Tabelle für Benutzerpräferenzen
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS preferences ( CREATE TABLE IF NOT EXISTS preferences (
@@ -70,48 +71,58 @@ class MemoryManager:
content TEXT content TEXT
) )
''') ''')
conn.commit() conn.commit()
conn.close() conn.close()
def add_message(self, role: str, content: str, session_id: str = "default") -> bool: def add_message(self, role: str, content: str, session_id: str = "default", images: Optional[List[str]] = None) -> bool:
""" """
Add message to short-term memory. Add message to short-term memory.
Returns True if summarization was triggered. Returns True if summarization was triggered.
""" """
tokens = self.token_estimator.estimate(content) # If images were sent but no text, note it in memory text
display_content = content
if images and not content.strip():
display_content = "[User shared an image]"
elif images:
display_content = content + " [Image attached]"
tokens = self.token_estimator.estimate(display_content)
message = { message = {
'role': role, 'role': role,
'content': content, 'content': display_content,
'raw_content': content,
'tokens': tokens, 'tokens': tokens,
'timestamp': time.time(), 'timestamp': time.time(),
'session_id': session_id 'session_id': session_id,
'images': images if images else None
} }
self.short_term.append(message) self.short_term.append(message)
self.current_tokens += tokens self.current_tokens += tokens
# Speichere auch Langzeit (rohdaten) # Speichere auch Langzeit (ohne base64 images, nur Hinweis)
self._save_to_db(role, content, tokens, session_id) has_image = 1 if images else 0
self._save_to_db(role, display_content, tokens, session_id, has_image)
# Prüfe ob Zusammenfassung nötig # Prüfe ob Zusammenfassung nötig
if self.current_tokens > (self.max_context * self.threshold): if self.current_tokens > (self.max_context * self.threshold):
self._summarize_old_messages(session_id) self._summarize_old_messages(session_id)
return True return True
return False return False
def _save_to_db(self, role: str, content: str, tokens: int, session_id: str): def _save_to_db(self, role: str, content: str, tokens: int, session_id: str, has_image: int = 0):
"""Save raw message to database""" """Save raw message to database"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
INSERT INTO conversations (session_id, timestamp, role, content, tokens) INSERT INTO conversations (session_id, timestamp, role, content, has_image, tokens)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
''', (session_id, time.time(), role, content, tokens)) ''', (session_id, time.time(), role, content, has_image, tokens))
conn.commit() conn.commit()
conn.close() conn.close()
def _summarize_old_messages(self, session_id: str): def _summarize_old_messages(self, session_id: str):
""" """
Kompromiss zwischen behalten und vergessen: Kompromiss zwischen behalten und vergessen:
@@ -120,14 +131,14 @@ class MemoryManager:
""" """
if len(self.short_term) < 10: if len(self.short_term) < 10:
return # Zu wenig zu zusammenfassen return # Zu wenig zu zusammenfassen
# Behalte letzte 6 Nachrichten, summarisiere den Rest # Behalte letzte 6 Nachrichten, summarisiere den Rest
messages_to_summarize = self.short_term[:-6] messages_to_summarize = self.short_term[:-6]
keep_messages = self.short_term[-6:] keep_messages = self.short_term[-6:]
# Erstelle Zusammenfassung # Erstelle Zusammenfassung
summary_text = self._create_summary(messages_to_summarize) summary_text = self._create_summary(messages_to_summarize)
# Speichere Zusammenfassung # Speichere Zusammenfassung
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
@@ -138,20 +149,20 @@ class MemoryManager:
''', (session_id, time.time(), summary_text, summary_tokens)) ''', (session_id, time.time(), summary_text, summary_tokens))
conn.commit() conn.commit()
conn.close() conn.close()
# Ersetze Kurzzeitgedächtnis # Ersetze Kurzzeitgedächtnis
self.short_term = keep_messages self.short_term = keep_messages
self.current_tokens = sum(m['tokens'] for m in keep_messages) self.current_tokens = sum(m['tokens'] for m in keep_messages)
print(f"[Memory] Summarized {len(messages_to_summarize)} messages. Kept {len(keep_messages)}.") print(f"[Memory] Summarized {len(messages_to_summarize)} messages. Kept {len(keep_messages)}.")
def _create_summary(self, messages: List[Dict]) -> str: def _create_summary(self, messages: List[Dict]) -> str:
"""Create a condensed summary of old messages""" """Create a condensed summary of old messages"""
# Extrahiere Schlüsselinformationen # Extrahiere Schlüsselinformationen
topics = [] topics = []
key_facts = [] key_facts = []
emotional_moments = [] emotional_moments = []
for msg in messages: for msg in messages:
content = msg['content'].lower() content = msg['content'].lower()
# Einfache Heuristik für relevante Informationen # Einfache Heuristik für relevante Informationen
@@ -159,34 +170,36 @@ class MemoryManager:
key_facts.append(msg['content'][:100]) key_facts.append(msg['content'][:100])
if msg['role'] == 'user' and len(msg['content']) > 20: if msg['role'] == 'user' and len(msg['content']) > 20:
topics.append(msg['content'][:50]) topics.append(msg['content'][:50])
if msg.get('images'):
key_facts.append("[User shared images during this period]")
summary = "Previous conversation summary: " summary = "Previous conversation summary: "
if key_facts: if key_facts:
summary += f"User preferences noted: {'; '.join(key_facts[:3])}. " summary += f"User preferences noted: {'; '.join(key_facts[:3])}. "
if topics: if topics:
summary += f"Topics discussed: {'; '.join(topics[:2])}." summary += f"Topics discussed: {'; '.join(topics[:2])}."
return summary[:500] # Limit Länge return summary[:500] # Limit Länge
def get_context(self, session_id: str = "default", max_history: int = 20) -> List[Dict]: def get_context(self, session_id: str = "default", max_history: int = 20) -> List[Dict]:
""" """
Get conversation context for LLM. Get conversation context for LLM.
Includes: summaries (long-term) + recent messages (short-term) Includes: summaries (long-term) + recent messages (short-term)
""" """
context = [] context = []
# 1. Langzeitgedächtnis: Letzte Zusammenfassungen laden # 1. Langzeitgedächtnis: Letzte Zusammenfassungen laden
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT content FROM summaries SELECT content FROM summaries
WHERE session_id = ? WHERE session_id = ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 3 LIMIT 3
''', (session_id,)) ''', (session_id,))
summaries = cursor.fetchall() summaries = cursor.fetchall()
conn.close() conn.close()
# Füge Zusammenfassungen als System-Kontext hinzu # Füge Zusammenfassungen als System-Kontext hinzu
total_tokens = 0 total_tokens = 0
for summary in summaries: for summary in summaries:
@@ -197,17 +210,20 @@ class MemoryManager:
'content': f"[Memory] {summary[0]}" 'content': f"[Memory] {summary[0]}"
}) })
total_tokens += summary_tokens total_tokens += summary_tokens
# 2. Kurzzeitgedächtnis: Aktuelle Nachrichten # 2. Kurzzeitgedächtnis: Aktuelle Nachrichten
recent_messages = self.short_term[-max_history:] recent_messages = self.short_term[-max_history:]
for msg in recent_messages: for msg in recent_messages:
context.append({ entry = {
'role': msg['role'], 'role': msg['role'],
'content': msg['content'] 'content': msg['raw_content'] if msg.get('raw_content') else msg['content']
}) }
if msg.get('images'):
entry['images'] = msg['images']
context.append(entry)
return context return context
def get_memory_stats(self) -> Dict: def get_memory_stats(self) -> Dict:
"""Return current memory statistics""" """Return current memory statistics"""
return { return {
@@ -217,28 +233,28 @@ class MemoryManager:
'max_context': self.max_context, 'max_context': self.max_context,
'usage_percent': (self.current_tokens / self.max_context) * 100 'usage_percent': (self.current_tokens / self.max_context) * 100
} }
def clear_session(self, session_id: str = "default"): def clear_session(self, session_id: str = "default"):
"""Clear short-term memory for session""" """Clear short-term memory for session"""
self.short_term = [] self.short_term = []
self.current_tokens = 0 self.current_tokens = 0
def search_long_term(self, keyword: str) -> List[Dict]: def search_long_term(self, keyword: str) -> List[Dict]:
"""Search long-term memory for specific topics""" """Search long-term memory for specific topics"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT * FROM conversations SELECT * FROM conversations
WHERE content LIKE ? WHERE content LIKE ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 10 LIMIT 10
''', (f'%{keyword}%',)) ''', (f'%{keyword}%',))
results = cursor.fetchall() results = cursor.fetchall()
conn.close() conn.close()
columns = ['id', 'session_id', 'timestamp', 'role', 'content', 'summary', 'importance', 'tokens'] columns = ['id', 'session_id', 'timestamp', 'role', 'content', 'has_image', 'summary', 'importance', 'tokens']
return [dict(zip(columns, row)) for row in results] return [dict(zip(columns, row)) for row in results]
def save_preference(self, category: str, content: str): def save_preference(self, category: str, content: str):
"""Save learned preference to long-term memory""" """Save learned preference to long-term memory"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
@@ -249,7 +265,7 @@ class MemoryManager:
''', (time.time(), category, content)) ''', (time.time(), category, content))
conn.commit() conn.commit()
conn.close() conn.close()
def get_preferences(self) -> Dict[str, List[str]]: def get_preferences(self) -> Dict[str, List[str]]:
"""Retrieve learned preferences""" """Retrieve learned preferences"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
@@ -257,10 +273,10 @@ class MemoryManager:
cursor.execute('SELECT category, content FROM preferences ORDER BY timestamp DESC') cursor.execute('SELECT category, content FROM preferences ORDER BY timestamp DESC')
results = cursor.fetchall() results = cursor.fetchall()
conn.close() conn.close()
prefs = {} prefs = {}
for cat, content in results: for cat, content in results:
if cat not in prefs: if cat not in prefs:
prefs[cat] = [] prefs[cat] = []
prefs[cat].append(content) prefs[cat].append(content)
return prefs return prefs
+47 -33
View File
@@ -12,44 +12,53 @@ class OllamaClient:
self.model = config['model'] self.model = config['model']
self.timeout = config['timeout'] self.timeout = config['timeout']
self.session = requests.Session() self.session = requests.Session()
def _prepare_messages(self, system_prompt: str, context: List[Dict], user_message: str) -> List[Dict]: def _prepare_messages(self, system_prompt: str, context: List[Dict], user_message: str, images: Optional[List[str]] = None) -> List[Dict]:
"""Prepare message list for Ollama API""" """Prepare message list for Ollama API"""
messages = [] messages = []
# System prompt first # System prompt first
if system_prompt: if system_prompt:
messages.append({ messages.append({
"role": "system", "role": "system",
"content": system_prompt "content": system_prompt
}) })
# Add context (memory) # Add context (memory)
for msg in context: for msg in context:
messages.append({ entry = {
"role": msg['role'], "role": msg['role'],
"content": msg['content'] "content": msg['content']
}) }
# Preserve image references if they exist in stored context
# User message last if 'images' in msg and msg['images']:
messages.append({ entry['images'] = msg['images']
"role": "user", messages.append(entry)
# User message last (with optional images)
user_entry = {
"role": "user",
"content": user_message "content": user_message
}) }
if images:
user_entry['images'] = images
messages.append(user_entry)
return messages return messages
def generate(self, def generate(self,
system_prompt: str, system_prompt: str,
context: List[Dict], context: List[Dict],
user_message: str, user_message: str,
images: Optional[List[str]] = None,
options: Optional[Dict] = None) -> Generator[str, None, None]: options: Optional[Dict] = None) -> Generator[str, None, None]:
""" """
Stream response from Ollama API Stream response from Ollama API
Yields tokens/chunks as they arrive Yields tokens/chunks as they arrive
""" """
messages = self._prepare_messages(system_prompt, context, user_message) messages = self._prepare_messages(system_prompt, context, user_message, images)
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
@@ -60,7 +69,7 @@ class OllamaClient:
"top_k": 40 "top_k": 40
} }
} }
try: try:
response = self.session.post( response = self.session.post(
f"{self.host}/api/chat", f"{self.host}/api/chat",
@@ -69,9 +78,9 @@ class OllamaClient:
timeout=self.timeout timeout=self.timeout
) )
response.raise_for_status() response.raise_for_status()
full_response = "" full_response = ""
for line in response.iter_lines(): for line in response.iter_lines():
if line: if line:
try: try:
@@ -80,16 +89,16 @@ class OllamaClient:
chunk = data['message']['content'] chunk = data['message']['content']
full_response += chunk full_response += chunk
yield chunk yield chunk
# Check for completion # Check for completion
if data.get('done', False): if data.get('done', False):
break break
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
logger.info(f"Generated {len(full_response)} characters") logger.info(f"Generated {len(full_response)} characters")
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
logger.error(f"Cannot connect to Ollama at {self.host}") logger.error(f"Cannot connect to Ollama at {self.host}")
yield "*softly* I'm having trouble connecting to my thoughts... Please check if Ollama is running." yield "*softly* I'm having trouble connecting to my thoughts... Please check if Ollama is running."
@@ -99,7 +108,7 @@ class OllamaClient:
except Exception as e: except Exception as e:
logger.error(f"Error generating response: {e}") logger.error(f"Error generating response: {e}")
yield "*whispers* Something went wrong... please try again." yield "*whispers* Something went wrong... please try again."
def check_model(self) -> bool: def check_model(self) -> bool:
"""Check if configured model is available""" """Check if configured model is available"""
try: try:
@@ -107,15 +116,20 @@ class OllamaClient:
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
models = [m['name'] for m in data.get('models', [])] models = [m['name'] for m in data.get('models', [])]
# Allow both exact match and model base name
if self.model in models: if self.model in models:
return True return True
else: # Check if any model contains our base name (e.g. tag variants)
logger.warning(f"Model {self.model} not found. Available: {models}") base_name = self.model.split(':')[0]
return False for m in models:
if base_name in m:
return True
logger.warning(f"Model {self.model} not found. Available: {models}")
return False
except Exception as e: except Exception as e:
logger.error(f"Cannot reach Ollama: {e}") logger.error(f"Cannot reach Ollama: {e}")
return False return False
def list_models(self) -> List[str]: def list_models(self) -> List[str]:
"""List available models""" """List available models"""
try: try:
@@ -126,7 +140,7 @@ class OllamaClient:
except Exception: except Exception:
pass pass
return [] return []
def pull_model(self, model_name: str) -> Generator[str, None, None]: def pull_model(self, model_name: str) -> Generator[str, None, None]:
"""Pull a model from Ollama library""" """Pull a model from Ollama library"""
try: try:
@@ -135,7 +149,7 @@ class OllamaClient:
json={"name": model_name}, json={"name": model_name},
stream=True stream=True
) )
for line in response.iter_lines(): for line in response.iter_lines():
if line: if line:
try: try:
@@ -148,4 +162,4 @@ class OllamaClient:
except: except:
pass pass
except Exception as e: except Exception as e:
yield f"Error pulling model: {e}" yield f"Error pulling model: {e}"
+2 -1
View File
@@ -2,4 +2,5 @@ flask>=2.3.0
pyyaml>=6.0 pyyaml>=6.0
requests>=2.31.0 requests>=2.31.0
werkzeug>=2.3.0 werkzeug>=2.3.0
jinja2>=3.1.0 jinja2>=3.1.0
Pillow>=10.0.0
+110 -7
View File
@@ -64,6 +64,12 @@ header h1 {
margin-top: 10px; margin-top: 10px;
} }
#vision-badge {
color: #7ee787;
font-weight: 600;
margin-left: 10px;
}
.memory-status { .memory-status {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--accent-soft); color: var(--accent-soft);
@@ -129,6 +135,22 @@ header h1 {
font-style: italic; font-style: italic;
} }
/* Images inside messages */
.message-image {
max-width: 240px;
max-height: 180px;
border-radius: 12px;
margin-bottom: 8px;
display: block;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
cursor: pointer;
transition: transform 0.2s;
}
.message-image:hover {
transform: scale(1.03);
}
/* Input Area */ /* Input Area */
.input-area { .input-area {
position: sticky; position: sticky;
@@ -151,9 +173,52 @@ header h1 {
50% { opacity: 1; } 50% { opacity: 1; }
} }
/* Image Preview above input */
.image-preview-container {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 8px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 12px;
width: fit-content;
max-width: 100%;
}
.image-preview {
max-height: 80px;
max-width: 120px;
border-radius: 8px;
object-fit: cover;
}
.remove-image-btn {
background: var(--accent);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 16px;
line-height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.remove-image-btn:hover {
background: #ff5a75;
}
/* Input Row */
.input-row { .input-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: flex-start;
} }
textarea { textarea {
@@ -176,6 +241,32 @@ textarea:focus {
border-color: var(--accent); border-color: var(--accent);
} }
.upload-btn {
background: var(--bg-tertiary);
color: var(--text-primary);
border: none;
border-radius: 25px;
padding: 15px 18px;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-btn:hover:not(:disabled) {
background: var(--accent);
transform: scale(1.05);
}
.upload-btn:disabled {
background: var(--bg-tertiary);
opacity: 0.5;
cursor: not-allowed;
}
.send-btn { .send-btn {
background: var(--accent); background: var(--accent);
color: white; color: white;
@@ -304,21 +395,33 @@ textarea:focus {
.container { .container {
padding: 10px; padding: 10px;
} }
header h1 { header h1 {
font-size: 1.8rem; font-size: 1.8rem;
} }
.message { .message {
max-width: 90%; max-width: 90%;
padding: 12px 15px; padding: 12px 15px;
} }
.message-image {
max-width: 180px;
max-height: 140px;
}
.input-row { .input-row {
flex-direction: column; flex-wrap: wrap;
} }
.send-btn { .upload-btn, .send-btn {
height: 44px;
padding: 10px 18px;
}
textarea {
width: 100%; width: 100%;
order: -1;
margin-bottom: 8px;
} }
} }
+88 -26
View File
@@ -9,22 +9,31 @@
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>𓇢 {{ persona_name }}</h1> <h1>🜏 {{ persona_name }}</h1>
<div class="subtitle">Intimate AI Companion</div> <div class="subtitle">Intimate AI Companion</div>
<div class="model-info">Model: <span id="model-name">Loading...</span></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> <div class="memory-status" id="memory-status"></div>
</header> </header>
<div class="chat-container" id="chat-box"> <div class="chat-container" id="chat-box">
<div class="message system"> <div class="message system">
*kneels gracefully, eyes lowered* I'm here for you completely... waiting for your instructions. What would please you today? *kneels gracefully, eyes lowered* I'm here for you completely... waiting for your instructions. What would please you today?
</div> </div>
</div> </div>
<div class="input-area"> <div class="input-area">
<div class="typing-indicator" id="typing" style="display: none;">Nimue is thinking...</div> <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"> <div class="input-row">
<textarea id="user-input" placeholder="Command me..." maxlength="2000"></textarea> <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> <button id="send-btn" class="send-btn">Send</button>
</div> </div>
<div class="controls"> <div class="controls">
@@ -50,17 +59,25 @@
const chatBox = document.getElementById('chat-box'); const chatBox = document.getElementById('chat-box');
const userInput = document.getElementById('user-input'); const userInput = document.getElementById('user-input');
const sendBtn = document.getElementById('send-btn'); 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 typing = document.getElementById('typing');
const charCount = document.getElementById('char-count'); const charCount = document.getElementById('char-count');
let isGenerating = false; let isGenerating = false;
let currentMessageDiv = null; let currentMessageDiv = null;
let currentImageBase64 = null;
// Load config // Load config
fetch('/api/config') fetch('/api/config')
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
document.getElementById('model-name').textContent = data.model; document.getElementById('model-name').textContent = data.model;
if (data.vision) {
document.getElementById('vision-badge').style.display = 'inline';
}
}); });
userInput.addEventListener('input', () => { userInput.addEventListener('input', () => {
@@ -75,11 +92,46 @@
}); });
sendBtn.addEventListener('click', sendMessage); sendBtn.addEventListener('click', sendMessage);
uploadBtn.addEventListener('click', () => imageInput.click());
function appendMessage(role, content) { 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'); const div = document.createElement('div');
div.className = `message ${role}`; div.className = `message ${role}`;
div.innerHTML = formatMessage(content);
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.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight; chatBox.scrollTop = chatBox.scrollHeight;
return div; return div;
@@ -101,28 +153,39 @@
async function sendMessage() { async function sendMessage() {
if (isGenerating) return; if (isGenerating) return;
const message = userInput.value.trim();
if (!message) return;
// Add user message const message = userInput.value.trim();
appendMessage('user', message); if (!message && !currentImageBase64) return;
// Add user message to chat immediately
appendMessage('user', message || '[Image]', currentImageBase64);
userInput.value = ''; userInput.value = '';
charCount.textContent = '0'; charCount.textContent = '0';
isGenerating = true; isGenerating = true;
typing.style.display = 'block'; typing.style.display = 'block';
sendBtn.disabled = true; sendBtn.disabled = true;
uploadBtn.disabled = true;
currentMessageDiv = document.createElement('div'); currentMessageDiv = document.createElement('div');
currentMessageDiv.className = 'message assistant'; currentMessageDiv.className = 'message assistant';
chatBox.appendChild(currentMessageDiv); chatBox.appendChild(currentMessageDiv);
// Prepare payload
const payload = { message: message };
if (currentImageBase64) {
payload.images = [currentImageBase64];
}
// Clear image after sending
const sentImage = currentImageBase64;
removeImage();
try { try {
const response = await fetch('/api/chat', { const response = await fetch('/api/chat', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: message}) body: JSON.stringify(payload)
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
@@ -132,10 +195,10 @@
while (true) { while (true) {
const {done, value} = await reader.read(); const {done, value} = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value); const chunk = decoder.decode(value);
const lines = chunk.split('\n'); const lines = chunk.split('\n');
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
const text = line.slice(6); const text = line.slice(6);
@@ -144,9 +207,6 @@
currentMessageDiv.innerHTML = formatMessage(fullText); currentMessageDiv.innerHTML = formatMessage(fullText);
chatBox.scrollTop = chatBox.scrollHeight; chatBox.scrollTop = chatBox.scrollHeight;
} }
if (line.startsWith('event: stats')) {
// Parse stats
}
} }
} }
@@ -156,6 +216,7 @@
isGenerating = false; isGenerating = false;
typing.style.display = 'none'; typing.style.display = 'none';
sendBtn.disabled = false; sendBtn.disabled = false;
uploadBtn.disabled = false;
getMemoryStats(); getMemoryStats();
} }
} }
@@ -179,14 +240,14 @@
const modal = document.getElementById('memory-modal'); const modal = document.getElementById('memory-modal');
const resp = await fetch('/api/memory'); const resp = await fetch('/api/memory');
const data = await resp.json(); const data = await resp.json();
document.getElementById('memory-stats').innerHTML = ` document.getElementById('memory-stats').innerHTML = `
<h3>Statistics</h3> <h3>Statistics</h3>
<p>Short-term messages: ${data.stats.short_term_messages}</p> <p>Short-term messages: ${data.stats.short_term_messages}</p>
<p>Tokens used: ${data.stats.short_term_tokens} / ${data.stats.max_context}</p> <p>Tokens used: ${data.stats.short_term_tokens} / ${data.stats.max_context}</p>
<p>Usage: ${data.stats.usage_percent.toFixed(1)}%</p> <p>Usage: ${data.stats.usage_percent.toFixed(1)}%</p>
`; `;
let prefs = '<h3>Learned Preferences</h3>'; let prefs = '<h3>Learned Preferences</h3>';
if (Object.keys(data.preferences).length === 0) { if (Object.keys(data.preferences).length === 0) {
prefs += '<p>None yet...</p>'; prefs += '<p>None yet...</p>';
@@ -196,13 +257,14 @@
} }
} }
document.getElementById('memory-preferences').innerHTML = prefs; document.getElementById('memory-preferences').innerHTML = prefs;
let recent = '<h3>Recent Messages</h3>'; let recent = '<h3>Recent Messages</h3>';
for (const msg of data.recent) { for (const msg of data.recent) {
recent += `<p><strong>${msg.role}:</strong> ${msg.content}</p>`; const imgTag = msg.has_image ? ' 🖼️' : '';
recent += `<p><strong>${msg.role}${imgTag}:</strong> ${msg.content}</p>`;
} }
document.getElementById('memory-recent').innerHTML = recent; document.getElementById('memory-recent').innerHTML = recent;
modal.style.display = 'block'; modal.style.display = 'block';
} }
@@ -219,4 +281,4 @@
getMemoryStats(); getMemoryStats();
</script> </script>
</body> </body>
</html> </html>