v2.1 — Session-Passcode System für Multi-Device

Features:
- 6-stelliger Passcode pro Session (z.B. 5E92C5)
- /join Seite: Passcode-Eingabe für andere Geräte
- /api/session/join: Verifiziert Passcode, setzt Cookie
- /api/session/info: Gibt Session-ID + Passcode zurück
- Index-Seite zeigt Passcode mit Hinweis
- Chat-Header zeigt Passcode (lila hervorgehoben)
- Passcode in sessions Tabelle gespeichert
- Falscher Code = Fehlermeldung, kein Raten möglich

Workflow:
1. Desktop: Öffne NeonChat → siehst Passcode im Header
2. Handy: Öffne http://[IP]:5252/join → Code eingeben
3. Handy bekommt gleiches Cookie → gleicher Chat-Verlauf
This commit is contained in:
arch_agent
2026-07-24 21:47:30 +02:00
parent a432226824
commit c60d1219b2
4 changed files with 147 additions and 41 deletions
+63 -39
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""
NeonChat v2 — Lokaler RP Chatbot mit Ollama
Features: Streaming, Session-Cookies, Multi-Device, Langzeitgedächtnis
NeonChat v2.1 — Lokaler RP Chatbot mit Ollama
Features: Streaming, Session-Passcodes, Multi-Device, Langzeitgedächtnis
"""
import json, os, time, asyncio, sqlite3, re, secrets
import json, os, time, asyncio, sqlite3, re, secrets, hashlib
from pathlib import Path
from datetime import datetime
import httpx
from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Response, Cookie
from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Response, Cookie, Query
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -48,6 +48,7 @@ def init_db():
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
passcode TEXT NOT NULL,
created REAL NOT NULL,
name TEXT DEFAULT ''
);
@@ -60,35 +61,34 @@ def init_db():
init_db()
# === Session Management ===
def get_or_create_session(session_id: str = None) -> str:
def generate_passcode() -> str:
"""6-stelliger alphanumerischer Code."""
return secrets.token_hex(3).upper() # z.B. A3F2B1
def get_or_create_session(session_id: str = None) -> tuple:
"""Gibt (session_id, passcode) zurück. Erstellt neue Session falls nötig."""
conn = sqlite3.connect(str(DB_PATH))
if session_id:
cur = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,))
if cur.fetchone():
cur = conn.execute("SELECT id, passcode FROM sessions WHERE id = ?", (session_id,))
row = cur.fetchone()
if row:
conn.close()
return session_id
return row[0], row[1]
# Create new session
new_id = secrets.token_hex(16)
conn.execute("INSERT INTO sessions (id, created) VALUES (?, ?)", (new_id, time.time()))
passcode = generate_passcode()
conn.execute("INSERT INTO sessions (id, passcode, created) VALUES (?, ?, ?)", (new_id, passcode, time.time()))
conn.commit()
conn.close()
return new_id
return new_id, passcode
def migrate_old_messages():
"""Migriert alte Nachrichten ohne session_id zu einer default Session."""
def get_session_by_passcode(passcode: str) -> str:
"""Findet Session-ID anhand eines Passcodes."""
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()
cur = conn.execute("SELECT id FROM sessions WHERE passcode = ?", (passcode.upper(),))
row = cur.fetchone()
conn.close()
migrate_old_messages()
return row[0] if row else None
# === Character Loading ===
def load_character(name: str) -> dict:
@@ -131,7 +131,6 @@ def get_short_term_memory(session_id: str, character: str, limit: int = 20) -> l
return [{"role": r[0], "content": r[1]} for r in reversed(rows)]
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 role, content, timestamp FROM messages WHERE session_id = ? AND character = ? ORDER BY timestamp ASC LIMIT ?",
@@ -280,13 +279,16 @@ app = FastAPI(title="NeonChat")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
def set_session_cookie(response, sid: str):
response.set_cookie(SESSION_COOKIE_NAME, sid, max_age=SESSION_DURATION, httponly=False, samesite="lax")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)):
sid = get_or_create_session(session_id)
sid, passcode = get_or_create_session(session_id)
chars = list_characters()
response = templates.TemplateResponse(request, "index.html", {"characters": chars})
response = templates.TemplateResponse(request, "index.html", {"characters": chars, "passcode": passcode})
if not session_id or session_id != sid:
response.set_cookie(SESSION_COOKIE_NAME, sid, max_age=SESSION_DURATION, httponly=False, samesite="lax")
set_session_cookie(response, sid)
return response
@app.get("/create", response_class=HTMLResponse)
@@ -295,9 +297,8 @@ async def create_page(request: Request):
@app.get("/chat/{character_name}", response_class=HTMLResponse)
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)
sid, passcode = get_or_create_session(session_id)
char = load_character(character_name)
# 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", {
@@ -305,14 +306,42 @@ async def chat_page(request: Request, character_name: str, session_id: str = Coo
"history": history,
"long_term": long_term,
"session_id": sid,
"passcode": passcode,
})
if not session_id or session_id != sid:
response.set_cookie(SESSION_COOKIE_NAME, sid, max_age=SESSION_DURATION, httponly=False, samesite="lax")
set_session_cookie(response, sid)
return response
@app.get("/join", response_class=HTMLResponse)
async def join_page(request: Request):
"""Passcode-Eingabe-Seite für andere Geräte."""
return templates.TemplateResponse(request, "join.html", {})
@app.post("/api/session/join")
async def api_join_session(request: Request):
"""Verbindet ein anderes Gerät mit einer bestehenden Session via Passcode."""
body = await request.json()
passcode = body.get("passcode", "").strip().upper()
if not passcode:
return JSONResponse({"error": "Passcode erforderlich"}, status_code=400)
sid = get_session_by_passcode(passcode)
if not sid:
return JSONResponse({"error": "Ungültiger Passcode"}, status_code=404)
response = JSONResponse({"status": "ok", "session_id": sid})
set_session_cookie(response, sid)
return response
@app.get("/api/session/info")
async def api_session_info(session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)):
"""Gibt Session-Info und Passcode zurück."""
sid, passcode = get_or_create_session(session_id)
return JSONResponse({"session_id": sid, "passcode": passcode})
@app.post("/api/chat/{character_name}")
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)
sid, _ = get_or_create_session(session_id)
char = load_character(character_name)
body = await request.json()
user_message = body.get("message", "")
@@ -330,7 +359,6 @@ async def chat_api(character_name: str, request: Request, session_id: str = Cook
model = char.get("model") or DEFAULT_MODEL
if stream:
# Streaming via Server-Sent Events
async def stream_response():
full_response = ""
try:
@@ -364,11 +392,9 @@ async def chat_api(character_name: str, request: Request, session_id: str = Cook
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))
@@ -376,7 +402,6 @@ async def chat_api(character_name: str, request: Request, session_id: str = Cook
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={
@@ -441,21 +466,20 @@ async def api_get_character(name: str):
@app.post("/api/memory/clear/{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)
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, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)):
sid = get_or_create_session(session_id)
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)
sid, _ = get_or_create_session(session_id)
history = get_full_history(sid, character_name, limit=200)
return JSONResponse({"history": history, "session_id": sid})
+5 -2
View File
@@ -68,7 +68,8 @@
<div class="char-desc">{{ character.description[:60] }}{% if character.description|length > 60 %}...{% endif %}</div>
</div>
<div class="header-actions">
<span class="session-info" id="sessionInfo">Session: ...</span>
<span class="session-info" id="sessionInfo" title="Session-ID">Session: ...</span>
<span class="session-info" id="passcodeInfo" title="Passcode für andere Geräte" style="border-color: var(--accent); color: var(--accent);">Code: ...</span>
<button class="btn" onclick="toggleMemory()">🧠 Gedächtnis</button>
<button class="btn btn-danger" onclick="clearMemory()">🗑️ Vergessen</button>
</div>
@@ -107,9 +108,11 @@
const sendBtn = document.getElementById('send');
let isSending = false;
// Show session ID (short)
// Show session ID (short) and passcode
const sessionId = "{{ session_id }}";
const passcode = "{{ passcode }}";
document.getElementById('sessionInfo').textContent = 'Session: ' + sessionId.substring(0, 8) + '...';
document.getElementById('passcodeInfo').textContent = 'Code: ' + passcode;
// Auto-scroll
chat.scrollTop = chat.scrollHeight;
+6
View File
@@ -42,6 +42,12 @@
<div class="container">
<h1>✦ NeonChat</h1>
<p class="subtitle">Lokaler RP-Chatbot · Wähle einen Charakter oder erstelle einen neuen</p>
{% if passcode %}
<p style="color: var(--accent); font-size: 0.85rem; margin-bottom: 20px; padding: 8px 14px; background: #15151f; border: 1px solid var(--accent); border-radius: 8px; display: inline-block;">
🔑 Session-Passcode: <b style="font-family: monospace; letter-spacing: 4px;">{{ passcode }}</b>
<br><span style="color: #6b6b80; font-size: 0.8rem;">Auf anderem Gerät: <a href="/join" style="color: var(--accent);">/join</a> öffnen und Code eingeben</span>
</p>
{% endif %}
<div class="char-grid">
{% for c in characters %}
<div class="char-card" onclick="location.href='/chat/{{ c.filename | urlencode }}'">
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeonChat — Session beitreten</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0a0a0f; color: #e0e0e8; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.join-box { background: #15151f; border: 1px solid #252535; border-radius: 20px; padding: 40px; max-width: 400px; width: 90%; text-align: center; }
h1 { font-size: 1.8rem; margin-bottom: 8px; background: linear-gradient(135deg, #ff006e, #8338ec); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.subtitle { color: #6b6b80; margin-bottom: 30px; font-size: 0.9rem; }
.passcode-input { width: 100%; background: #1e1e2e; border: 2px solid #252535; border-radius: 12px; padding: 16px; color: #e0e0e8; font-size: 1.5rem; text-align: center; letter-spacing: 8px; text-transform: uppercase; outline: none; transition: border 0.2s; font-family: monospace; }
.passcode-input:focus { border-color: #8338ec; }
.passcode-input::placeholder { color: #4b4b60; letter-spacing: 4px; }
.btn { margin-top: 20px; width: 100%; padding: 14px; border-radius: 12px; border: none; background: linear-gradient(135deg, #8338ec, #ff006e); color: white; font-size: 1rem; cursor: pointer; font-weight: 600; transition: opacity 0.15s; }
.btn:hover { opacity: 0.9; }
.error { color: #ff006e; margin-top: 12px; font-size: 0.85rem; display: none; }
.info { margin-top: 20px; padding: 12px; background: #1e1e2e; border-radius: 10px; font-size: 0.8rem; color: #6b6b80; line-height: 1.5; }
.info b { color: #9b9bb0; }
</style>
</head>
<body>
<div class="join-box">
<h1>✦ NeonChat</h1>
<p class="subtitle">Gib den 6-stelligen Code ein um einer Session beizutreten</p>
<input type="text" class="passcode-input" id="passcode" maxlength="6" placeholder="A3F2B1" autofocus>
<button class="btn" onclick="joinSession()">Session beitreten</button>
<div class="error" id="error"></div>
<div class="info">
<b>Wie bekomme ich den Code?</b><br>
Öffne NeonChat auf deinem Desktop. Der Code steht oben rechts im Header neben der Session-ID.
</div>
</div>
<script>
document.getElementById('passcode').addEventListener('keydown', (e) => {
if (e.key === 'Enter') joinSession();
// Auto-uppercase
e.target.value = e.target.value.toUpperCase();
});
async function joinSession() {
const passcode = document.getElementById('passcode').value.trim().toUpperCase();
const errorDiv = document.getElementById('error');
errorDiv.style.display = 'none';
if (passcode.length !== 6) {
errorDiv.textContent = 'Code muss 6 Zeichen lang sein';
errorDiv.style.display = 'block';
return;
}
try {
const resp = await fetch('/api/session/join', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({passcode: passcode})
});
const data = await resp.json();
if (resp.ok) {
window.location.href = '/';
} else {
errorDiv.textContent = data.error || 'Ungültiger Code';
errorDiv.style.display = 'block';
}
} catch(e) {
errorDiv.textContent = 'Verbindungsfehler';
errorDiv.style.display = 'block';
}
}
</script>
</body>
</html>