diff --git a/src/database.py b/src/database.py index b9d7a01..cc99d12 100644 --- a/src/database.py +++ b/src/database.py @@ -464,6 +464,13 @@ async def init_db(): await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1") await db.commit() + # Migration: Tutorial-Fortschritt pro User + if "tutorial_step" not in user_columns: + await db.execute("ALTER TABLE users ADD COLUMN tutorial_step INTEGER DEFAULT NULL") + await db.execute("ALTER TABLE users ADD COLUMN tutorial_completed INTEGER DEFAULT 0") + await db.commit() + logger.info("Migration: tutorial_step + tutorial_completed zu users hinzugefuegt") + if "last_login_at" not in user_columns: await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP") await db.commit() diff --git a/src/main.py b/src/main.py index 503cec7..de19369 100644 --- a/src/main.py +++ b/src/main.py @@ -333,6 +333,7 @@ from routers.feedback import router as feedback_router from routers.public_api import router as public_api_router from routers.chat import router as chat_router from routers.network_analysis import router as network_analysis_router +from routers.tutorial import router as tutorial_router app.include_router(auth_router) app.include_router(incidents_router) @@ -342,6 +343,7 @@ app.include_router(feedback_router) app.include_router(public_api_router) app.include_router(chat_router, prefix="/api/chat") app.include_router(network_analysis_router) +app.include_router(tutorial_router) @app.websocket("/api/ws") diff --git a/src/routers/tutorial.py b/src/routers/tutorial.py new file mode 100644 index 0000000..fd48d54 --- /dev/null +++ b/src/routers/tutorial.py @@ -0,0 +1,77 @@ +"""Tutorial-Router: Fortschritt serverseitig pro User speichern.""" +import logging +from fastapi import APIRouter, Depends +from auth import get_current_user +from database import db_dependency +import aiosqlite + +logger = logging.getLogger("osint.tutorial") + +router = APIRouter(prefix="/api/tutorial", tags=["tutorial"]) + + +@router.get("/state") +async def get_tutorial_state( + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Tutorial-Fortschritt des aktuellen Nutzers abrufen.""" + cursor = await db.execute( + "SELECT tutorial_step, tutorial_completed FROM users WHERE id = ?", + (current_user["id"],), + ) + row = await cursor.fetchone() + if not row: + return {"current_step": None, "completed": False} + return { + "current_step": row["tutorial_step"], + "completed": bool(row["tutorial_completed"]), + } + + +@router.put("/state") +async def save_tutorial_state( + body: dict, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Tutorial-Fortschritt speichern (current_step und/oder completed).""" + updates = [] + params = [] + + if "current_step" in body: + step = body["current_step"] + if step is not None and (not isinstance(step, int) or step < 0 or step > 31): + from fastapi import HTTPException + raise HTTPException(status_code=422, detail="current_step muss 0-31 oder null sein") + updates.append("tutorial_step = ?") + params.append(step) + + if "completed" in body: + updates.append("tutorial_completed = ?") + params.append(1 if body["completed"] else 0) + + if not updates: + return {"ok": True} + + params.append(current_user["id"]) + await db.execute( + f"UPDATE users SET {', '.join(updates)} WHERE id = ?", + params, + ) + await db.commit() + return {"ok": True} + + +@router.delete("/state") +async def reset_tutorial_state( + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Tutorial-Fortschritt zuruecksetzen (fuer Neustart).""" + await db.execute( + "UPDATE users SET tutorial_step = NULL, tutorial_completed = 0 WHERE id = ?", + (current_user["id"],), + ) + await db.commit() + return {"ok": True} diff --git a/src/static/js/api.js b/src/static/js/api.js index 87690ad..ab10125 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -215,6 +215,19 @@ const API = { }, // Export + + // Tutorial-Fortschritt + getTutorialState() { + return this._request('GET', '/tutorial/state'); + }, + + saveTutorialState(data) { + return this._request('PUT', '/tutorial/state', data); + }, + + resetTutorialState() { + return this._request('DELETE', '/tutorial/state'); + }, exportIncident(id, format, scope) { const token = localStorage.getItem('osint_token'); return fetch(`${this.baseUrl}/incidents/${id}/export?format=${format}&scope=${scope}`, { diff --git a/src/static/js/chat.js b/src/static/js/chat.js index 81d4540..28ef729 100644 --- a/src/static/js/chat.js +++ b/src/static/js/chat.js @@ -6,6 +6,7 @@ const Chat = { _isOpen: false, _isLoading: false, _hasGreeted: false, + _tutorialHintDismissed: false, _isFullscreen: false, init() { @@ -64,10 +65,13 @@ const Chat = { if (!this._hasGreeted) { this._hasGreeted = true; this.addMessage('assistant', 'Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.'); - // Tutorial-Hinweis beim ersten Chat-Oeffnen der Session - if (typeof Tutorial !== 'undefined' && !sessionStorage.getItem('osint_tutorial_hint_dismissed')) { - this._showTutorialHint(); - } + } + + // Tutorial-Hinweis bei jedem Oeffnen aktualisieren (wenn nicht dismissed) + if (typeof Tutorial !== 'undefined' && !this._tutorialHintDismissed) { + var oldHint = document.getElementById('chat-tutorial-hint'); + if (oldHint) oldHint.remove(); + this._showTutorialHint(); } // Focus auf Input @@ -288,29 +292,57 @@ const Chat = { } }, - _showTutorialHint() { + async _showTutorialHint() { var container = document.getElementById('chat-messages'); if (!container) return; + + // API-State laden (Fallback: Standard-Hint) + var state = null; + try { state = await API.getTutorialState(); } catch(e) {} + var hint = document.createElement('div'); hint.className = 'chat-tutorial-hint'; hint.id = 'chat-tutorial-hint'; var textDiv = document.createElement('div'); textDiv.className = 'chat-tutorial-hint-text'; - textDiv.innerHTML = 'Tipp: Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt f\u00fcr Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.'; textDiv.style.cursor = 'pointer'; - textDiv.addEventListener('click', function() { - Chat.close(); - sessionStorage.setItem('osint_tutorial_hint_dismissed', '1'); - if (typeof Tutorial !== 'undefined') Tutorial.start(); - }); + + if (state && !state.completed && state.current_step !== null && state.current_step > 0) { + // Mittendrin abgebrochen + var totalSteps = (typeof Tutorial !== 'undefined') ? Tutorial._steps.length : 32; + textDiv.innerHTML = 'Tipp: Sie haben den Rundgang bei Schritt ' + (state.current_step + 1) + '/' + totalSteps + ' unterbrochen. Klicken Sie hier, um fortzusetzen.'; + textDiv.addEventListener('click', function() { + Chat.close(); + Chat._tutorialHintDismissed = true; + if (typeof Tutorial !== 'undefined') Tutorial.start(); + }); + } else if (state && state.completed) { + // Bereits abgeschlossen + textDiv.innerHTML = 'Tipp: Sie haben den Rundgang bereits abgeschlossen. Erneut starten?'; + textDiv.addEventListener('click', async function() { + Chat.close(); + Chat._tutorialHintDismissed = true; + try { await API.resetTutorialState(); } catch(e) {} + if (typeof Tutorial !== 'undefined') Tutorial.start(true); + }); + } else { + // Nie gestartet + textDiv.innerHTML = 'Tipp: Kennen Sie schon den interaktiven Rundgang? Er zeigt Ihnen Schritt für Schritt alle Funktionen des Monitors. Klicken Sie hier, um ihn zu starten.'; + textDiv.addEventListener('click', function() { + Chat.close(); + Chat._tutorialHintDismissed = true; + if (typeof Tutorial !== 'undefined') Tutorial.start(); + }); + } + var closeBtn = document.createElement('button'); closeBtn.className = 'chat-tutorial-hint-close'; - closeBtn.title = 'Schlie\u00dfen'; + closeBtn.title = 'Schließen'; closeBtn.innerHTML = '×'; closeBtn.addEventListener('click', function(e) { e.stopPropagation(); hint.remove(); - sessionStorage.setItem('osint_tutorial_hint_dismissed', '1'); + Chat._tutorialHintDismissed = true; }); hint.appendChild(textDiv); hint.appendChild(closeBtn); diff --git a/src/static/js/tutorial.js b/src/static/js/tutorial.js index 131dd51..6db3483 100644 --- a/src/static/js/tutorial.js +++ b/src/static/js/tutorial.js @@ -12,6 +12,7 @@ const Tutorial = { _resizeHandler: null, _demoRunning: false, _lastExitedStep: -1, + _highestStep: -1, _stepTimers: [], // setTimeout-IDs fuer den aktuellen Step _savedState: null, // Dashboard-Zustand vor dem Tutorial @@ -1152,14 +1153,52 @@ const Tutorial = { // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- - start() { + async start(forceRestart) { if (this._isActive) return; - this._isActive = true; - this._currentStep = -1; - // Chat schließen falls offen + // Chat schliessen falls offen if (typeof Chat !== 'undefined' && Chat._isOpen) Chat.close(); + // Server-State laden (Fallback: direkt starten) + var state = null; + try { state = await API.getTutorialState(); } catch(e) {} + + // Resume-Dialog wenn mittendrin abgebrochen + if (!forceRestart && state && !state.completed && state.current_step !== null && state.current_step > 0) { + this._showResumeDialog(state.current_step); + return; + } + + this._startInternal(forceRestart ? 0 : null); + }, + + _showResumeDialog(step) { + var self = this; + var overlay = document.createElement('div'); + overlay.className = 'tutorial-resume-overlay'; + overlay.innerHTML = '
Sie haben den Rundgang bei Schritt ' + (step + 1) + '/' + this._steps.length + ' unterbrochen.
' + + '