From c60d1219b2e44e304cb5666690e2cd0597927c57 Mon Sep 17 00:00:00 2001 From: arch_agent Date: Fri, 24 Jul 2026 21:47:30 +0200 Subject: [PATCH] =?UTF-8?q?v2.1=20=E2=80=94=20Session-Passcode=20System=20?= =?UTF-8?q?f=C3=BCr=20Multi-Device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- main.py | 102 ++++++++++++++++++++++++++----------------- templates/chat.html | 7 ++- templates/index.html | 6 +++ templates/join.html | 73 +++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 templates/join.html diff --git a/main.py b/main.py index 39ff87e..2120dc3 100644 --- a/main.py +++ b/main.py @@ -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}) diff --git a/templates/chat.html b/templates/chat.html index 3ba7f09..5d2287b 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -68,7 +68,8 @@
{{ character.description[:60] }}{% if character.description|length > 60 %}...{% endif %}
- Session: ... + Session: ... + Code: ...
@@ -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; diff --git a/templates/index.html b/templates/index.html index fe9920c..a0f95d4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -42,6 +42,12 @@

✦ NeonChat

Lokaler RP-Chatbot · Wähle einen Charakter oder erstelle einen neuen

+ {% if passcode %} +

+ 🔑 Session-Passcode: {{ passcode }} +
Auf anderem Gerät: /join öffnen und Code eingeben +

+ {% endif %}
{% for c in characters %}
diff --git a/templates/join.html b/templates/join.html new file mode 100644 index 0000000..1c9c7b2 --- /dev/null +++ b/templates/join.html @@ -0,0 +1,73 @@ + + + + + + NeonChat — Session beitreten + + + +
+

✦ NeonChat

+

Gib den 6-stelligen Code ein um einer Session beizutreten

+ + +
+
+ Wie bekomme ich den Code?
+ Öffne NeonChat auf deinem Desktop. Der Code steht oben rechts im Header neben der Session-ID. +
+
+ + + \ No newline at end of file