diff --git a/main.py b/main.py index 5731370..39ff87e 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,16 @@ #!/usr/bin/env python3 """ -NeonChat — Lokaler RP Chatbot mit Ollama -Character-basiert, mit Kurz- und Langzeitgedächtnis +NeonChat v2 — Lokaler RP Chatbot mit Ollama +Features: Streaming, Session-Cookies, Multi-Device, Langzeitgedächtnis """ -import json, os, time, asyncio, sqlite3, re +import json, os, time, asyncio, sqlite3, re, secrets from pathlib import Path from datetime import datetime -from contextlib import asynccontextmanager import httpx -from fastapi import FastAPI, Request, UploadFile, File, HTTPException -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Response, Cookie +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel @@ -22,7 +21,9 @@ CHARACTERS_DIR = BASE_DIR / "characters" DATA_DIR = BASE_DIR / "data" DB_PATH = DATA_DIR / "memory.db" OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") -DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1:8b") +DEFAULT_MODEL = os.environ.get("OLLAMA_MODEL", "gemma4:12b") +SESSION_COOKIE_NAME = "neonchat_session" +SESSION_DURATION = 60 * 60 * 24 * 365 # 1 Jahr # === Datenbank === def init_db(): @@ -31,6 +32,7 @@ def init_db(): conn.executescript(""" CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, character TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, @@ -39,21 +41,65 @@ def init_db(): ); CREATE TABLE IF NOT EXISTS summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, character TEXT NOT NULL, summary TEXT NOT NULL, timestamp REAL NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_messages_character ON messages(character, timestamp); - CREATE INDEX IF NOT EXISTS idx_summaries_character ON summaries(character, timestamp); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created REAL NOT NULL, + name TEXT DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, character, timestamp); + CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, character, timestamp); """) conn.commit() conn.close() init_db() +# === Session Management === +def get_or_create_session(session_id: str = None) -> str: + conn = sqlite3.connect(str(DB_PATH)) + if session_id: + cur = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)) + if cur.fetchone(): + conn.close() + return session_id + # Create new session + new_id = secrets.token_hex(16) + conn.execute("INSERT INTO sessions (id, created) VALUES (?, ?)", (new_id, time.time())) + conn.commit() + conn.close() + return new_id + +def migrate_old_messages(): + """Migriert alte Nachrichten ohne session_id zu einer default Session.""" + conn = sqlite3.connect(str(DB_PATH)) + cur = conn.execute("SELECT COUNT(*) FROM messages WHERE session_id = '' OR session_id IS NULL") + count = cur.fetchone()[0] + if count > 0: + # Create default session + default_session = "default_" + secrets.token_hex(8) + conn.execute("INSERT OR IGNORE INTO sessions (id, created, name) VALUES (?, ?, 'default')", (default_session, time.time())) + conn.execute("UPDATE messages SET session_id = ? WHERE session_id = '' OR session_id IS NULL", (default_session,)) + conn.execute("UPDATE summaries SET session_id = ? WHERE session_id = '' OR session_id IS NULL", (default_session,)) + conn.commit() + conn.close() + +migrate_old_messages() + # === Character Loading === def load_character(name: str) -> dict: path = CHARACTERS_DIR / f"{name}.json" + if not path.exists(): + path = CHARACTERS_DIR / f"{name.lower()}.json" + if not path.exists(): + for f in CHARACTERS_DIR.glob("*.json"): + if f.stem.lower() == name.lower(): + path = f + break if not path.exists(): raise HTTPException(status_code=404, detail=f"Character {name} not found") with open(path, "r", encoding="utf-8") as f: @@ -66,30 +112,40 @@ def list_characters() -> list: data = json.load(fh) chars.append({ "name": data.get("name", f.stem), + "filename": f.stem, "avatar": data.get("avatar", "👤"), "description": data.get("description", ""), "greeting": data.get("greeting", ""), }) return chars -# === Memory System === -def get_short_term_memory(character: str, limit: int = 20) -> list: - """Holt die letzten N Nachrichten als Kurzzeitgedächtnis.""" +# === Memory System (Session-basiert) === +def get_short_term_memory(session_id: str, character: str, limit: int = 20) -> list: conn = sqlite3.connect(str(DB_PATH)) cur = conn.execute( - "SELECT role, content FROM messages WHERE character = ? ORDER BY timestamp DESC LIMIT ?", - (character, limit) + "SELECT role, content FROM messages WHERE session_id = ? AND character = ? ORDER BY timestamp DESC LIMIT ?", + (session_id, character, limit) ) rows = cur.fetchall() conn.close() return [{"role": r[0], "content": r[1]} for r in reversed(rows)] -def get_long_term_memory(character: str) -> str: - """Holt die letzten Zusammenfassungen als Langzeitgedächtnis.""" +def get_full_history(session_id: str, character: str, limit: int = 200) -> list: + """Holt vollständigen Chat-Verlauf für die UI-Anzeige.""" conn = sqlite3.connect(str(DB_PATH)) cur = conn.execute( - "SELECT summary FROM summaries WHERE character = ? ORDER BY timestamp DESC LIMIT 3", - (character,) + "SELECT role, content, timestamp FROM messages WHERE session_id = ? AND character = ? ORDER BY timestamp ASC LIMIT ?", + (session_id, character, limit) + ) + rows = cur.fetchall() + conn.close() + return [{"role": r[0], "content": r[1], "timestamp": r[2]} for r in rows] + +def get_long_term_memory(session_id: str, character: str) -> str: + conn = sqlite3.connect(str(DB_PATH)) + cur = conn.execute( + "SELECT summary FROM summaries WHERE session_id = ? AND character = ? ORDER BY timestamp DESC LIMIT 3", + (session_id, character) ) rows = cur.fetchall() conn.close() @@ -97,32 +153,30 @@ def get_long_term_memory(character: str) -> str: return "" return "\n\n".join([r[0] for r in rows]) -def save_message(character: str, role: str, content: str): +def save_message(session_id: str, character: str, role: str, content: str): conn = sqlite3.connect(str(DB_PATH)) conn.execute( - "INSERT INTO messages (character, role, content, timestamp) VALUES (?, ?, ?, ?)", - (character, role, content, time.time()) + "INSERT INTO messages (session_id, character, role, content, timestamp) VALUES (?, ?, ?, ?, ?)", + (session_id, character, role, content, time.time()) ) conn.commit() conn.close() -def needs_summary(character: str) -> bool: - """Prüft ob eine neue Zusammenfassung nötig ist (alle 20 Nachrichten).""" +def needs_summary(session_id: str, character: str) -> bool: conn = sqlite3.connect(str(DB_PATH)) cur = conn.execute( - "SELECT COUNT(*) FROM messages WHERE character = ? AND summary IS NULL", - (character,) + "SELECT COUNT(*) FROM messages WHERE session_id = ? AND character = ? AND summary IS NULL", + (session_id, character) ) count = cur.fetchone()[0] conn.close() return count >= 20 -async def generate_summary(character: str): - """Erstellt eine Zusammenfassung der letzten 20 Nachrichten.""" +async def generate_summary(session_id: str, character: str): conn = sqlite3.connect(str(DB_PATH)) cur = conn.execute( - "SELECT id, role, content FROM messages WHERE character = ? AND summary IS NULL ORDER BY timestamp ASC LIMIT 20", - (character,) + "SELECT id, role, content FROM messages WHERE session_id = ? AND character = ? AND summary IS NULL ORDER BY timestamp ASC LIMIT 20", + (session_id, character) ) rows = cur.fetchall() if not rows: @@ -143,35 +197,39 @@ Gespräch: Zusammenfassung (auf Deutsch, kurz):""" try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(f"{OLLAMA_URL}/api/generate", json={ "model": DEFAULT_MODEL, "prompt": prompt, "stream": False, + "think": False, "options": {"temperature": 0.3, "num_predict": 300} }) summary = resp.json().get("response", "").strip() except: summary = "Zusammenfassung nicht verfügbar." + if not summary: + summary = "Zusammenfassung nicht verfügbar." + conn.execute( - "INSERT INTO summaries (character, summary, timestamp) VALUES (?, ?, ?)", - (character, summary, time.time()) + "INSERT INTO summaries (session_id, character, summary, timestamp) VALUES (?, ?, ?, ?)", + (session_id, character, summary, time.time()) ) for r in rows: conn.execute("UPDATE messages SET summary = ? WHERE id = ?", (summary, r[0])) conn.commit() conn.close() -def clear_memory(character: str): +def clear_memory(session_id: str, character: str): conn = sqlite3.connect(str(DB_PATH)) - conn.execute("DELETE FROM messages WHERE character = ?", (character,)) - conn.execute("DELETE FROM summaries WHERE character = ?", (character,)) + conn.execute("DELETE FROM messages WHERE session_id = ? AND character = ?", (session_id, character)) + conn.execute("DELETE FROM summaries WHERE session_id = ? AND character = ?", (session_id, character)) conn.commit() conn.close() # === Prompt Builder === -def build_prompt(character: dict, short_term: list, long_term: str, user_message: str) -> str: +def build_prompt(character: dict, short_term: list, long_term: str, user_message: str) -> list: name = character.get("name", "AI") personality = character.get("personality", "") background = character.get("background", "") @@ -200,7 +258,9 @@ REGELN: - Verwende keine Formulierungen wie "als KI" oder "als Sprachmodell" - Reagiere emotional angemessen - Sei nicht übermäßig freundlich — bleibe authentisch -- Verwende deutsche Umgangssprache wo passend""" +- Verwende deutsche Umgangssprache wo passend +- Wiederhole dich nicht — variiere deine Ausdrücke +- Wenn der Nutzer etwas sagt das du schon weißt (aus Erinnerungen), beziehe dich darauf""" if long_term: system += f"\n\nERINNERUNGEN (Langzeitgedächtnis):\n{long_term}" @@ -221,71 +281,127 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") @app.get("/", response_class=HTMLResponse) -async def index(request: Request): +async def index(request: Request, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): + sid = get_or_create_session(session_id) chars = list_characters() - return templates.TemplateResponse(request, "index.html", {"characters": chars}) + response = templates.TemplateResponse(request, "index.html", {"characters": chars}) + if not session_id or session_id != sid: + response.set_cookie(SESSION_COOKIE_NAME, sid, max_age=SESSION_DURATION, httponly=False, samesite="lax") + return response @app.get("/create", response_class=HTMLResponse) async def create_page(request: Request): return templates.TemplateResponse(request, "create.html", {}) @app.get("/chat/{character_name}", response_class=HTMLResponse) -async def chat_page(request: Request, character_name: str): +async def chat_page(request: Request, character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): + sid = get_or_create_session(session_id) char = load_character(character_name) - history = get_short_term_memory(character_name, limit=50) - long_term = get_long_term_memory(character_name) - return templates.TemplateResponse(request, "chat.html", { + # Fix: Load FULL history for display, not just short-term + history = get_full_history(sid, character_name, limit=200) + long_term = get_long_term_memory(sid, character_name) + response = templates.TemplateResponse(request, "chat.html", { "character": char, "history": history, "long_term": long_term, + "session_id": sid, }) + if not session_id or session_id != sid: + response.set_cookie(SESSION_COOKIE_NAME, sid, max_age=SESSION_DURATION, httponly=False, samesite="lax") + return response @app.post("/api/chat/{character_name}") -async def chat_api(character_name: str, request: Request): +async def chat_api(character_name: str, request: Request, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): + sid = get_or_create_session(session_id) char = load_character(character_name) body = await request.json() user_message = body.get("message", "") + stream = body.get("stream", True) if not user_message.strip(): return JSONResponse({"error": "Leere Nachricht"}, status_code=400) - save_message(character_name, "user", user_message) + save_message(sid, character_name, "user", user_message) - short_term = get_short_term_memory(character_name, limit=20) - long_term = get_long_term_memory(character_name) + short_term = get_short_term_memory(sid, character_name, limit=20) + long_term = get_long_term_memory(sid, character_name) messages = build_prompt(char, short_term, long_term, user_message) model = char.get("model") or DEFAULT_MODEL - try: - async with httpx.AsyncClient(timeout=300) as client: - resp = await client.post(f"{OLLAMA_URL}/api/chat", json={ - "model": model, - "messages": messages, - "stream": False, - "options": { - "temperature": char.get("temperature", 0.8), - "top_p": 0.9, - "num_predict": 800, - "repeat_penalty": 1.1, - } - }) - data = resp.json() - response_text = data.get("message", {}).get("content", "").strip() - # Some models (gemma4) put output in "thinking" field - if not response_text: - thinking = data.get("message", {}).get("thinking", "") - if thinking: - response_text = thinking.strip() - except Exception as e: - response_text = f"*(Fehler: {e})*" - - save_message(character_name, "assistant", response_text) - - if needs_summary(character_name): - asyncio.create_task(generate_summary(character_name)) - - return JSONResponse({"response": response_text, "character": character_name}) + if stream: + # Streaming via Server-Sent Events + async def stream_response(): + full_response = "" + try: + async with httpx.AsyncClient(timeout=300) as client: + async with client.stream("POST", f"{OLLAMA_URL}/api/chat", json={ + "model": model, + "messages": messages, + "stream": True, + "think": False, + "options": { + "temperature": char.get("temperature", 0.8), + "top_p": 0.9, + "num_predict": 800, + "repeat_penalty": 1.1, + } + }) as resp: + async for line in resp.aiter_lines(): + if not line.strip(): + continue + try: + chunk = json.loads(line) + content = chunk.get("message", {}).get("content", "") + if content: + full_response += content + yield f"data: {json.dumps({'token': content})}\n\n" + if chunk.get("done"): + break + except json.JSONDecodeError: + continue + except Exception as e: + full_response = f"*(Fehler: {e})*" + yield f"data: {json.dumps({'token': full_response})}\n\n" + + # Save complete response + if full_response.strip(): + save_message(sid, character_name, "assistant", full_response.strip()) + + # Check if summary needed + if needs_summary(sid, character_name): + asyncio.create_task(generate_summary(sid, character_name)) + + yield f"data: {json.dumps({'done': True})}\n\n" + + return StreamingResponse(stream_response(), media_type="text/event-stream") + else: + # Non-streaming fallback + try: + async with httpx.AsyncClient(timeout=300) as client: + resp = await client.post(f"{OLLAMA_URL}/api/chat", json={ + "model": model, + "messages": messages, + "stream": False, + "think": False, + "options": { + "temperature": char.get("temperature", 0.8), + "top_p": 0.9, + "num_predict": 800, + "repeat_penalty": 1.1, + } + }) + data = resp.json() + response_text = data.get("message", {}).get("content", "").strip() + except Exception as e: + response_text = f"*(Fehler: {e})*" + + save_message(sid, character_name, "assistant", response_text) + + if needs_summary(sid, character_name): + asyncio.create_task(generate_summary(sid, character_name)) + + return JSONResponse({"response": response_text, "character": character_name}) @app.get("/api/characters") async def api_list_characters(): @@ -309,7 +425,7 @@ async def api_create_character(request: Request): "scenario": body.get("scenario", ""), "nsfw": body.get("nsfw", ""), "greeting": body.get("greeting", ""), - "model": body.get("model", DEFAULT_MODEL), + "model": body.get("model", ""), "temperature": body.get("temperature", 0.8), } @@ -324,16 +440,25 @@ async def api_get_character(name: str): return JSONResponse(char) @app.post("/api/memory/clear/{character_name}") -async def api_clear_memory(character_name: str): - clear_memory(character_name) +async def api_clear_memory(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): + sid = get_or_create_session(session_id) + clear_memory(sid, character_name) return JSONResponse({"status": "ok"}) @app.get("/api/memory/{character_name}") -async def api_get_memory(character_name: str): - short = get_short_term_memory(character_name, limit=100) - long = get_long_term_memory(character_name) +async def api_get_memory(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): + sid = get_or_create_session(session_id) + short = get_short_term_memory(sid, character_name, limit=100) + long = get_long_term_memory(sid, character_name) return JSONResponse({"short_term": short, "long_term": long}) +@app.get("/api/history/{character_name}") +async def api_get_history(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): + """API endpoint to get full chat history — for reconnecting devices.""" + sid = get_or_create_session(session_id) + history = get_full_history(sid, character_name, limit=200) + return JSONResponse({"history": history, "session_id": sid}) + @app.get("/api/models") async def api_list_models(): try: diff --git a/templates/chat.html b/templates/chat.html index a2cabb9..3ba7f09 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -13,7 +13,6 @@ } body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; } - /* Header */ header { display: flex; align-items: center; gap: 14px; padding: 14px 20px; background: var(--surface); border-bottom: 1px solid var(--border); } .avatar { font-size: 2rem; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: var(--surface2); border-radius: 50%; } .char-name { font-size: 1.2rem; font-weight: 600; } @@ -23,18 +22,18 @@ .btn:hover { border-color: var(--accent); } .btn-danger:hover { border-color: var(--accent2); color: var(--accent2); } - /* Chat */ - .chat-container { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; } + .chat-container { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; scroll-behavior: smooth; } .msg { max-width: 75%; padding: 12px 16px; border-radius: 16px; line-height: 1.6; font-size: 0.95rem; white-space: pre-wrap; word-break: break-word; } .msg-user { align-self: flex-end; background: var(--user); color: white; border-bottom-right-radius: 4px; } .msg-bot { align-self: flex-start; background: var(--surface); border: 1px solid var(--border); border-bottom-left-radius: 4px; } .msg-system { align-self: center; background: transparent; color: var(--muted); font-size: 0.85rem; font-style: italic; } .msg-greeting { align-self: flex-start; background: var(--surface); border: 1px solid var(--accent); border-bottom-left-radius: 4px; } - .typing { align-self: flex-start; color: var(--muted); font-size: 0.9rem; padding: 8px 16px; } - .typing::after { content: '●●●'; animation: blink 1.2s infinite; letter-spacing: 3px; } + .typing { align-self: flex-start; color: var(--muted); font-size: 0.9rem; padding: 8px 16px; display: flex; gap: 4px; } + .typing span { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: blink 1.2s infinite; } + .typing span:nth-child(2) { animation-delay: 0.2s; } + .typing span:nth-child(3) { animation-delay: 0.4s; } @keyframes blink { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } } - /* Input */ .input-area { padding: 16px 20px; background: var(--surface); border-top: 1px solid var(--border); } .input-row { display: flex; gap: 10px; max-width: 1200px; margin: 0 auto; } textarea { flex: 1; background: var(--surface2); border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; color: var(--text); font-size: 0.95rem; font-family: inherit; resize: none; outline: none; transition: border 0.15s; max-height: 120px; } @@ -44,7 +43,6 @@ .send-btn:hover { opacity: 0.9; } .send-btn:disabled { opacity: 0.4; cursor: not-allowed; } - /* Memory Panel */ .memory-panel { position: fixed; top: 0; right: -400px; width: 380px; height: 100vh; background: var(--surface); border-left: 1px solid var(--border); padding: 20px; overflow-y: auto; transition: right 0.3s; z-index: 100; } .memory-panel.open { right: 0; } .memory-panel h3 { margin-bottom: 12px; font-size: 1rem; color: var(--accent); } @@ -54,6 +52,8 @@ .memory-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.5); z-index: 99; display: none; } .memory-overlay.show { display: block; } + .session-info { font-size: 0.75rem; color: var(--muted); padding: 4px 8px; background: var(--surface2); border-radius: 6px; } + @media (max-width: 768px) { .msg { max-width: 90%; } } @@ -68,6 +68,7 @@