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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }}'">
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user