v2.0 — Streaming, Session-Cookies, Gedächtnis-Fix, Multi-Device

Features:
- Streaming Responses: Token-für-Token via Server-Sent Events
- Session-Cookies: Cookie-basierte Session, läuft 1 Jahr
- Multi-Device: Anderes Gerät = gleiche Session = gleicher Chat
- /api/history endpoint: Lädt vollen Chat-Verlauf beim Reconnect
- Gedächtnis-Fix: Chat-Verlauf beim Neuladen korrekt aus DB
- Session-basiertes Gedächtnis (session_id in jeder Nachricht)
- UI: Animated typing indicator (3 dots)
- UI: Session-ID Anzeige im Header
- Auto-History-Load beim Seitenaufruf (falls nicht server-seitig gerendert)
- Prompt: Anti-Repetition Rule hinzugefügt
- Prompt: Erinnerungen-Referenz-Regel hinzugefügt
- DB Migration: Alte Nachrichten werden zu Default-Session migriert
- DB Schema: session_id Spalte in messages und summaries
This commit is contained in:
arch_agent
2026-07-24 21:43:48 +02:00
parent e1b54181dc
commit a432226824
3 changed files with 302 additions and 103 deletions
+207 -82
View File
@@ -1,17 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
NeonChat — Lokaler RP Chatbot mit Ollama NeonChat v2 — Lokaler RP Chatbot mit Ollama
Character-basiert, mit Kurz- und Langzeitgedächtnis 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 pathlib import Path
from datetime import datetime from datetime import datetime
from contextlib import asynccontextmanager
import httpx import httpx
from fastapi import FastAPI, Request, UploadFile, File, HTTPException from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Response, Cookie
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
@@ -22,7 +21,9 @@ CHARACTERS_DIR = BASE_DIR / "characters"
DATA_DIR = BASE_DIR / "data" DATA_DIR = BASE_DIR / "data"
DB_PATH = DATA_DIR / "memory.db" DB_PATH = DATA_DIR / "memory.db"
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") 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 === # === Datenbank ===
def init_db(): def init_db():
@@ -31,6 +32,7 @@ def init_db():
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
character TEXT NOT NULL, character TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
@@ -39,21 +41,65 @@ def init_db():
); );
CREATE TABLE IF NOT EXISTS summaries ( CREATE TABLE IF NOT EXISTS summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
character TEXT NOT NULL, character TEXT NOT NULL,
summary TEXT NOT NULL, summary TEXT NOT NULL,
timestamp REAL NOT NULL timestamp REAL NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_messages_character ON messages(character, timestamp); CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS idx_summaries_character ON summaries(character, timestamp); 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.commit()
conn.close() conn.close()
init_db() 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 === # === Character Loading ===
def load_character(name: str) -> dict: def load_character(name: str) -> dict:
path = CHARACTERS_DIR / f"{name}.json" 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(): if not path.exists():
raise HTTPException(status_code=404, detail=f"Character {name} not found") raise HTTPException(status_code=404, detail=f"Character {name} not found")
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
@@ -66,30 +112,40 @@ def list_characters() -> list:
data = json.load(fh) data = json.load(fh)
chars.append({ chars.append({
"name": data.get("name", f.stem), "name": data.get("name", f.stem),
"filename": f.stem,
"avatar": data.get("avatar", "👤"), "avatar": data.get("avatar", "👤"),
"description": data.get("description", ""), "description": data.get("description", ""),
"greeting": data.get("greeting", ""), "greeting": data.get("greeting", ""),
}) })
return chars return chars
# === Memory System === # === Memory System (Session-basiert) ===
def get_short_term_memory(character: str, limit: int = 20) -> list: def get_short_term_memory(session_id: str, character: str, limit: int = 20) -> list:
"""Holt die letzten N Nachrichten als Kurzzeitgedächtnis."""
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute( cur = conn.execute(
"SELECT role, content FROM messages WHERE character = ? ORDER BY timestamp DESC LIMIT ?", "SELECT role, content FROM messages WHERE session_id = ? AND character = ? ORDER BY timestamp DESC LIMIT ?",
(character, limit) (session_id, character, limit)
) )
rows = cur.fetchall() rows = cur.fetchall()
conn.close() conn.close()
return [{"role": r[0], "content": r[1]} for r in reversed(rows)] return [{"role": r[0], "content": r[1]} for r in reversed(rows)]
def get_long_term_memory(character: str) -> str: def get_full_history(session_id: str, character: str, limit: int = 200) -> list:
"""Holt die letzten Zusammenfassungen als Langzeitgedächtnis.""" """Holt vollständigen Chat-Verlauf für die UI-Anzeige."""
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute( cur = conn.execute(
"SELECT summary FROM summaries WHERE character = ? ORDER BY timestamp DESC LIMIT 3", "SELECT role, content, timestamp FROM messages WHERE session_id = ? AND character = ? ORDER BY timestamp ASC LIMIT ?",
(character,) (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() rows = cur.fetchall()
conn.close() conn.close()
@@ -97,32 +153,30 @@ def get_long_term_memory(character: str) -> str:
return "" return ""
return "\n\n".join([r[0] for r in rows]) 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 = sqlite3.connect(str(DB_PATH))
conn.execute( conn.execute(
"INSERT INTO messages (character, role, content, timestamp) VALUES (?, ?, ?, ?)", "INSERT INTO messages (session_id, character, role, content, timestamp) VALUES (?, ?, ?, ?, ?)",
(character, role, content, time.time()) (session_id, character, role, content, time.time())
) )
conn.commit() conn.commit()
conn.close() conn.close()
def needs_summary(character: str) -> bool: def needs_summary(session_id: str, character: str) -> bool:
"""Prüft ob eine neue Zusammenfassung nötig ist (alle 20 Nachrichten)."""
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute( cur = conn.execute(
"SELECT COUNT(*) FROM messages WHERE character = ? AND summary IS NULL", "SELECT COUNT(*) FROM messages WHERE session_id = ? AND character = ? AND summary IS NULL",
(character,) (session_id, character)
) )
count = cur.fetchone()[0] count = cur.fetchone()[0]
conn.close() conn.close()
return count >= 20 return count >= 20
async def generate_summary(character: str): async def generate_summary(session_id: str, character: str):
"""Erstellt eine Zusammenfassung der letzten 20 Nachrichten."""
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute( cur = conn.execute(
"SELECT id, role, content FROM messages WHERE character = ? AND summary IS NULL ORDER BY timestamp ASC LIMIT 20", "SELECT id, role, content FROM messages WHERE session_id = ? AND character = ? AND summary IS NULL ORDER BY timestamp ASC LIMIT 20",
(character,) (session_id, character)
) )
rows = cur.fetchall() rows = cur.fetchall()
if not rows: if not rows:
@@ -143,35 +197,39 @@ Gespräch:
Zusammenfassung (auf Deutsch, kurz):""" Zusammenfassung (auf Deutsch, kurz):"""
try: 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={ resp = await client.post(f"{OLLAMA_URL}/api/generate", json={
"model": DEFAULT_MODEL, "model": DEFAULT_MODEL,
"prompt": prompt, "prompt": prompt,
"stream": False, "stream": False,
"think": False,
"options": {"temperature": 0.3, "num_predict": 300} "options": {"temperature": 0.3, "num_predict": 300}
}) })
summary = resp.json().get("response", "").strip() summary = resp.json().get("response", "").strip()
except: except:
summary = "Zusammenfassung nicht verfügbar." summary = "Zusammenfassung nicht verfügbar."
if not summary:
summary = "Zusammenfassung nicht verfügbar."
conn.execute( conn.execute(
"INSERT INTO summaries (character, summary, timestamp) VALUES (?, ?, ?)", "INSERT INTO summaries (session_id, character, summary, timestamp) VALUES (?, ?, ?, ?)",
(character, summary, time.time()) (session_id, character, summary, time.time())
) )
for r in rows: for r in rows:
conn.execute("UPDATE messages SET summary = ? WHERE id = ?", (summary, r[0])) conn.execute("UPDATE messages SET summary = ? WHERE id = ?", (summary, r[0]))
conn.commit() conn.commit()
conn.close() conn.close()
def clear_memory(character: str): def clear_memory(session_id: str, character: str):
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
conn.execute("DELETE FROM messages WHERE character = ?", (character,)) conn.execute("DELETE FROM messages WHERE session_id = ? AND character = ?", (session_id, character))
conn.execute("DELETE FROM summaries WHERE character = ?", (character,)) conn.execute("DELETE FROM summaries WHERE session_id = ? AND character = ?", (session_id, character))
conn.commit() conn.commit()
conn.close() conn.close()
# === Prompt Builder === # === 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") name = character.get("name", "AI")
personality = character.get("personality", "") personality = character.get("personality", "")
background = character.get("background", "") background = character.get("background", "")
@@ -200,7 +258,9 @@ REGELN:
- Verwende keine Formulierungen wie "als KI" oder "als Sprachmodell" - Verwende keine Formulierungen wie "als KI" oder "als Sprachmodell"
- Reagiere emotional angemessen - Reagiere emotional angemessen
- Sei nicht übermäßig freundlich — bleibe authentisch - 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: if long_term:
system += f"\n\nERINNERUNGEN (Langzeitgedächtnis):\n{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.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
@app.get("/", response_class=HTMLResponse) @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() 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) @app.get("/create", response_class=HTMLResponse)
async def create_page(request: Request): async def create_page(request: Request):
return templates.TemplateResponse(request, "create.html", {}) return templates.TemplateResponse(request, "create.html", {})
@app.get("/chat/{character_name}", response_class=HTMLResponse) @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) char = load_character(character_name)
history = get_short_term_memory(character_name, limit=50) # Fix: Load FULL history for display, not just short-term
long_term = get_long_term_memory(character_name) history = get_full_history(sid, character_name, limit=200)
return templates.TemplateResponse(request, "chat.html", { long_term = get_long_term_memory(sid, character_name)
response = templates.TemplateResponse(request, "chat.html", {
"character": char, "character": char,
"history": history, "history": history,
"long_term": long_term, "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}") @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) char = load_character(character_name)
body = await request.json() body = await request.json()
user_message = body.get("message", "") user_message = body.get("message", "")
stream = body.get("stream", True)
if not user_message.strip(): if not user_message.strip():
return JSONResponse({"error": "Leere Nachricht"}, status_code=400) 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) short_term = get_short_term_memory(sid, character_name, limit=20)
long_term = get_long_term_memory(character_name) long_term = get_long_term_memory(sid, character_name)
messages = build_prompt(char, short_term, long_term, user_message) messages = build_prompt(char, short_term, long_term, user_message)
model = char.get("model") or DEFAULT_MODEL model = char.get("model") or DEFAULT_MODEL
try: if stream:
async with httpx.AsyncClient(timeout=300) as client: # Streaming via Server-Sent Events
resp = await client.post(f"{OLLAMA_URL}/api/chat", json={ async def stream_response():
"model": model, full_response = ""
"messages": messages, try:
"stream": False, async with httpx.AsyncClient(timeout=300) as client:
"options": { async with client.stream("POST", f"{OLLAMA_URL}/api/chat", json={
"temperature": char.get("temperature", 0.8), "model": model,
"top_p": 0.9, "messages": messages,
"num_predict": 800, "stream": True,
"repeat_penalty": 1.1, "think": False,
} "options": {
}) "temperature": char.get("temperature", 0.8),
data = resp.json() "top_p": 0.9,
response_text = data.get("message", {}).get("content", "").strip() "num_predict": 800,
# Some models (gemma4) put output in "thinking" field "repeat_penalty": 1.1,
if not response_text: }
thinking = data.get("message", {}).get("thinking", "") }) as resp:
if thinking: async for line in resp.aiter_lines():
response_text = thinking.strip() if not line.strip():
except Exception as e: continue
response_text = f"*(Fehler: {e})*" try:
chunk = json.loads(line)
save_message(character_name, "assistant", response_text) content = chunk.get("message", {}).get("content", "")
if content:
if needs_summary(character_name): full_response += content
asyncio.create_task(generate_summary(character_name)) yield f"data: {json.dumps({'token': content})}\n\n"
if chunk.get("done"):
return JSONResponse({"response": response_text, "character": character_name}) 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") @app.get("/api/characters")
async def api_list_characters(): async def api_list_characters():
@@ -309,7 +425,7 @@ async def api_create_character(request: Request):
"scenario": body.get("scenario", ""), "scenario": body.get("scenario", ""),
"nsfw": body.get("nsfw", ""), "nsfw": body.get("nsfw", ""),
"greeting": body.get("greeting", ""), "greeting": body.get("greeting", ""),
"model": body.get("model", DEFAULT_MODEL), "model": body.get("model", ""),
"temperature": body.get("temperature", 0.8), "temperature": body.get("temperature", 0.8),
} }
@@ -324,16 +440,25 @@ async def api_get_character(name: str):
return JSONResponse(char) return JSONResponse(char)
@app.post("/api/memory/clear/{character_name}") @app.post("/api/memory/clear/{character_name}")
async def api_clear_memory(character_name: str): async def api_clear_memory(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)):
clear_memory(character_name) sid = get_or_create_session(session_id)
clear_memory(sid, character_name)
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@app.get("/api/memory/{character_name}") @app.get("/api/memory/{character_name}")
async def api_get_memory(character_name: str): async def api_get_memory(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)):
short = get_short_term_memory(character_name, limit=100) sid = get_or_create_session(session_id)
long = get_long_term_memory(character_name) 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}) 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") @app.get("/api/models")
async def api_list_models(): async def api_list_models():
try: try:
+94 -20
View File
@@ -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; } 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); } 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%; } .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; } .char-name { font-size: 1.2rem; font-weight: 600; }
@@ -23,18 +22,18 @@
.btn:hover { border-color: var(--accent); } .btn:hover { border-color: var(--accent); }
.btn-danger:hover { border-color: var(--accent2); color: var(--accent2); } .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; scroll-behavior: smooth; }
.chat-container { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.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 { 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-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-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-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; } .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 { align-self: flex-start; color: var(--muted); font-size: 0.9rem; padding: 8px 16px; display: flex; gap: 4px; }
.typing::after { content: '●●●'; animation: blink 1.2s infinite; letter-spacing: 3px; } .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; } } @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-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; } .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; } 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:hover { opacity: 0.9; }
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; } .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 { 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.open { right: 0; }
.memory-panel h3 { margin-bottom: 12px; font-size: 1rem; color: var(--accent); } .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 { 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; } .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) { @media (max-width: 768px) {
.msg { max-width: 90%; } .msg { max-width: 90%; }
} }
@@ -68,6 +68,7 @@
<div class="char-desc">{{ character.description[:60] }}{% if character.description|length > 60 %}...{% endif %}</div> <div class="char-desc">{{ character.description[:60] }}{% if character.description|length > 60 %}...{% endif %}</div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<span class="session-info" id="sessionInfo">Session: ...</span>
<button class="btn" onclick="toggleMemory()">🧠 Gedächtnis</button> <button class="btn" onclick="toggleMemory()">🧠 Gedächtnis</button>
<button class="btn btn-danger" onclick="clearMemory()">🗑️ Vergessen</button> <button class="btn btn-danger" onclick="clearMemory()">🗑️ Vergessen</button>
</div> </div>
@@ -89,7 +90,6 @@
</div> </div>
</div> </div>
<!-- Memory Panel -->
<div class="memory-overlay" id="overlay" onclick="toggleMemory()"></div> <div class="memory-overlay" id="overlay" onclick="toggleMemory()"></div>
<div class="memory-panel" id="memoryPanel"> <div class="memory-panel" id="memoryPanel">
<h3>🧠 Gedächtnis — {{ character.name }}</h3> <h3>🧠 Gedächtnis — {{ character.name }}</h3>
@@ -101,11 +101,16 @@
<script> <script>
const charName = "{{ character.name }}"; const charName = "{{ character.name }}";
const charFilename = "{{ character.filename if character.filename else character_name }}";
const chat = document.getElementById('chat'); const chat = document.getElementById('chat');
const input = document.getElementById('input'); const input = document.getElementById('input');
const sendBtn = document.getElementById('send'); const sendBtn = document.getElementById('send');
let isSending = false; let isSending = false;
// Show session ID (short)
const sessionId = "{{ session_id }}";
document.getElementById('sessionInfo').textContent = 'Session: ' + sessionId.substring(0, 8) + '...';
// Auto-scroll // Auto-scroll
chat.scrollTop = chat.scrollHeight; chat.scrollTop = chat.scrollHeight;
@@ -140,27 +145,60 @@
// Typing indicator // Typing indicator
const typing = document.createElement('div'); const typing = document.createElement('div');
typing.className = 'typing'; typing.className = 'typing';
typing.textContent = ''; typing.innerHTML = '<span></span><span></span><span></span>';
chat.appendChild(typing); chat.appendChild(typing);
chat.scrollTop = chat.scrollHeight;
// Streaming via EventSource (fetch with streaming)
const botDiv = document.createElement('div');
botDiv.className = 'msg msg-bot';
chat.appendChild(botDiv);
try { try {
const resp = await fetch(`/api/chat/${encodeURIComponent(charName)}`, { const resp = await fetch(`/api/chat/${encodeURIComponent(charFilename)}`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: msg}) body: JSON.stringify({message: msg, stream: true})
}); });
const data = await resp.json();
const reader = resp.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});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.token) {
fullResponse += data.token;
botDiv.textContent = fullResponse;
chat.scrollTop = chat.scrollHeight;
}
if (data.done) {
// Streaming complete
}
} catch(e) {}
}
}
typing.remove(); typing.remove();
const botDiv = document.createElement('div'); if (!fullResponse.trim()) {
botDiv.className = 'msg msg-bot'; botDiv.textContent = '*(Keine Antwort)*';
botDiv.textContent = data.response; }
chat.appendChild(botDiv);
chat.scrollTop = chat.scrollHeight;
} catch(e) { } catch(e) {
typing.remove(); typing.remove();
botDiv.remove();
const errDiv = document.createElement('div'); const errDiv = document.createElement('div');
errDiv.className = 'msg msg-system'; errDiv.className = 'msg msg-system';
errDiv.textContent = '⚠️ Verbindungsfehler'; errDiv.textContent = '⚠️ Verbindungsfehler: ' + e.message;
chat.appendChild(errDiv); chat.appendChild(errDiv);
} }
@@ -184,7 +222,7 @@
async function loadMemory() { async function loadMemory() {
try { try {
const resp = await fetch(`/api/memory/${encodeURIComponent(charName)}`); const resp = await fetch(`/api/memory/${encodeURIComponent(charFilename)}`);
const data = await resp.json(); const data = await resp.json();
const shortDiv = document.getElementById('shortTerm'); const shortDiv = document.getElementById('shortTerm');
shortDiv.innerHTML = ''; shortDiv.innerHTML = '';
@@ -205,7 +243,7 @@
async function clearMemory() { async function clearMemory() {
if (!confirm(`Gedächtnis von ${charName} wirklich löschen?`)) return; if (!confirm(`Gedächtnis von ${charName} wirklich löschen?`)) return;
await fetch(`/api/memory/clear/${encodeURIComponent(charName)}`, {method: 'POST'}); await fetch(`/api/memory/clear/${encodeURIComponent(charFilename)}`, {method: 'POST'});
chat.innerHTML = ''; chat.innerHTML = '';
if ("{{ character.greeting }}") { if ("{{ character.greeting }}") {
const g = document.createElement('div'); const g = document.createElement('div');
@@ -214,6 +252,42 @@
chat.appendChild(g); chat.appendChild(g);
} }
} }
// Load history on reconnect (fixes the "conversation is somewhere else" bug)
async function loadHistory() {
try {
const resp = await fetch(`/api/history/${encodeURIComponent(charFilename)}`);
const data = await resp.json();
if (data.history && data.history.length > 0) {
// Only reload if we have more messages than currently shown
const currentMsgs = chat.querySelectorAll('.msg').length;
if (data.history.length > currentMsgs || currentMsgs === 0) {
chat.innerHTML = '';
if ("{{ character.greeting }}") {
const g = document.createElement('div');
g.className = 'msg msg-greeting';
g.textContent = "{{ character.greeting }}";
chat.appendChild(g);
}
data.history.forEach(h => {
const div = document.createElement('div');
div.className = `msg ${h.role === 'user' ? 'msg-user' : 'msg-bot'}`;
div.textContent = h.content;
chat.appendChild(div);
});
chat.scrollTop = chat.scrollHeight;
}
}
} catch(e) {
console.error('History load failed:', e);
}
}
// Load history on page load (fixes reconnect issue)
// Only if we don't already have history from server-side rendering
{% if not history %}
loadHistory();
{% endif %}
</script> </script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -44,7 +44,7 @@
<p class="subtitle">Lokaler RP-Chatbot · Wähle einen Charakter oder erstelle einen neuen</p> <p class="subtitle">Lokaler RP-Chatbot · Wähle einen Charakter oder erstelle einen neuen</p>
<div class="char-grid"> <div class="char-grid">
{% for c in characters %} {% for c in characters %}
<div class="char-card" onclick="location.href='/chat/{{ c.name | urlencode }}'"> <div class="char-card" onclick="location.href='/chat/{{ c.filename | urlencode }}'">
<div class="char-avatar">{{ c.avatar }}</div> <div class="char-avatar">{{ c.avatar }}</div>
<div class="char-info"> <div class="char-info">
<h3>{{ c.name }}</h3> <h3>{{ c.name }}</h3>