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 #!/usr/bin/env python3
""" """
NeonChat v2 — Lokaler RP Chatbot mit Ollama NeonChat v2.1 — Lokaler RP Chatbot mit Ollama
Features: Streaming, Session-Cookies, Multi-Device, Langzeitgedächtnis 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 pathlib import Path
from datetime import datetime from datetime import datetime
import httpx 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.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
@@ -48,6 +48,7 @@ def init_db():
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
passcode TEXT NOT NULL,
created REAL NOT NULL, created REAL NOT NULL,
name TEXT DEFAULT '' name TEXT DEFAULT ''
); );
@@ -60,35 +61,34 @@ def init_db():
init_db() init_db()
# === Session Management === # === 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)) conn = sqlite3.connect(str(DB_PATH))
if session_id: if session_id:
cur = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)) cur = conn.execute("SELECT id, passcode FROM sessions WHERE id = ?", (session_id,))
if cur.fetchone(): row = cur.fetchone()
if row:
conn.close() conn.close()
return session_id return row[0], row[1]
# Create new session # Create new session
new_id = secrets.token_hex(16) 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.commit()
conn.close() conn.close()
return new_id return new_id, passcode
def migrate_old_messages(): def get_session_by_passcode(passcode: str) -> str:
"""Migriert alte Nachrichten ohne session_id zu einer default Session.""" """Findet Session-ID anhand eines Passcodes."""
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute("SELECT COUNT(*) FROM messages WHERE session_id = '' OR session_id IS NULL") cur = conn.execute("SELECT id FROM sessions WHERE passcode = ?", (passcode.upper(),))
count = cur.fetchone()[0] row = cur.fetchone()
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() conn.close()
return row[0] if row else None
migrate_old_messages()
# === Character Loading === # === Character Loading ===
def load_character(name: str) -> dict: 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)] 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: 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)) conn = sqlite3.connect(str(DB_PATH))
cur = conn.execute( cur = conn.execute(
"SELECT role, content, timestamp FROM messages WHERE session_id = ? AND character = ? ORDER BY timestamp ASC LIMIT ?", "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")) 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")
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) @app.get("/", response_class=HTMLResponse)
async def index(request: Request, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): 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() 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: 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 return response
@app.get("/create", response_class=HTMLResponse) @app.get("/create", response_class=HTMLResponse)
@@ -295,9 +297,8 @@ async def create_page(request: Request):
@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, session_id: str = Cookie(None, alias=SESSION_COOKIE_NAME)): 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) char = load_character(character_name)
# Fix: Load FULL history for display, not just short-term
history = get_full_history(sid, character_name, limit=200) history = get_full_history(sid, character_name, limit=200)
long_term = get_long_term_memory(sid, character_name) long_term = get_long_term_memory(sid, character_name)
response = templates.TemplateResponse(request, "chat.html", { 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, "history": history,
"long_term": long_term, "long_term": long_term,
"session_id": sid, "session_id": sid,
"passcode": passcode,
}) })
if not session_id or 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") set_session_cookie(response, sid)
return response 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}") @app.post("/api/chat/{character_name}")
async def chat_api(character_name: str, request: Request, session_id: str = Cookie(None, alias=SESSION_COOKIE_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) char = load_character(character_name)
body = await request.json() body = await request.json()
user_message = body.get("message", "") 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 model = char.get("model") or DEFAULT_MODEL
if stream: if stream:
# Streaming via Server-Sent Events
async def stream_response(): async def stream_response():
full_response = "" full_response = ""
try: try:
@@ -364,11 +392,9 @@ async def chat_api(character_name: str, request: Request, session_id: str = Cook
full_response = f"*(Fehler: {e})*" full_response = f"*(Fehler: {e})*"
yield f"data: {json.dumps({'token': full_response})}\n\n" yield f"data: {json.dumps({'token': full_response})}\n\n"
# Save complete response
if full_response.strip(): if full_response.strip():
save_message(sid, character_name, "assistant", full_response.strip()) save_message(sid, character_name, "assistant", full_response.strip())
# Check if summary needed
if needs_summary(sid, character_name): if needs_summary(sid, character_name):
asyncio.create_task(generate_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") return StreamingResponse(stream_response(), media_type="text/event-stream")
else: else:
# Non-streaming fallback
try: try:
async with httpx.AsyncClient(timeout=300) as client: async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(f"{OLLAMA_URL}/api/chat", json={ 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}") @app.post("/api/memory/clear/{character_name}")
async def api_clear_memory(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_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) 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, session_id: str = Cookie(None, alias=SESSION_COOKIE_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) short = get_short_term_memory(sid, character_name, limit=100)
long = get_long_term_memory(sid, character_name) 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}") @app.get("/api/history/{character_name}")
async def api_get_history(character_name: str, session_id: str = Cookie(None, alias=SESSION_COOKIE_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) history = get_full_history(sid, character_name, limit=200)
return JSONResponse({"history": history, "session_id": sid}) 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 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> <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" 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>
@@ -107,9 +108,11 @@
const sendBtn = document.getElementById('send'); const sendBtn = document.getElementById('send');
let isSending = false; let isSending = false;
// Show session ID (short) // Show session ID (short) and passcode
const sessionId = "{{ session_id }}"; const sessionId = "{{ session_id }}";
const passcode = "{{ passcode }}";
document.getElementById('sessionInfo').textContent = 'Session: ' + sessionId.substring(0, 8) + '...'; document.getElementById('sessionInfo').textContent = 'Session: ' + sessionId.substring(0, 8) + '...';
document.getElementById('passcodeInfo').textContent = 'Code: ' + passcode;
// Auto-scroll // Auto-scroll
chat.scrollTop = chat.scrollHeight; chat.scrollTop = chat.scrollHeight;
+6
View File
@@ -42,6 +42,12 @@
<div class="container"> <div class="container">
<h1>✦ NeonChat</h1> <h1>✦ NeonChat</h1>
<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>
{% 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"> <div class="char-grid">
{% for c in characters %} {% for c in characters %}
<div class="char-card" onclick="location.href='/chat/{{ c.filename | urlencode }}'"> <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>