#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ webapp_sliding.py — Web app compagnon Sliding · Chantier C9 =========================================================== Enveloppe WEB de sliding_api.py (la façade C8) — même API interne que le MCP, autre canal. Starlette pur (Python 3.9, zéro dépendance nouvelle : starlette/uvicorn/jinja2/python-multipart déjà dans le venv). Port 8769 · reverse proxy DSM : sliding.bertha-cloud.fr → 127.0.0.1:8769 Auth : SECURITY_WORD (le même que l'OAuth MCP) + cookie de session signé HMAC (stdlib). Mono-utilisateur, cohérent avec l'écosystème. Narrator web : AgentSession(NARRATOR_ID) en mémoire par projet, réamorcée avec le plan compact persisté (reprime_narrator). Les commandes du prompt terminal (f:, d:) fonctionnent telles quelles. Limite v1 assumée : la conversation vit en RAM (un restart la perd) ; le plan compact, lui, est TOUJOURS persisté — même garantie que le terminal. """ import hashlib import hmac import html import os import secrets import time from pathlib import Path from dotenv import load_dotenv from jinja2 import Environment, FileSystemLoader, select_autoescape from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import (FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response) from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles import facilitator_v9 as fac import sliding_api as api load_dotenv() SECURITY_WORD = os.getenv("SECURITY_WORD", "") SESSION_SECRET = os.getenv("WEBAPP_SECRET") or hashlib.sha256( ("webapp:" + SECURITY_WORD).encode()).hexdigest() BASE = Path(__file__).resolve().parent env = Environment(loader=FileSystemLoader(str(BASE / "templates")), autoescape=select_autoescape(["html"])) # Sessions Narrator en mémoire : {slug: {"agent": AgentSession, # "chat": [(role, texte)]}} NARRATORS = {} # ── auth (cookie HMAC, stdlib) ─────────────────────────────────────────────── def _sign(value: str) -> str: sig = hmac.new(SESSION_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest() return f"{value}.{sig}" def _verify(cookie: str) -> bool: if not cookie or "." not in cookie: return False value, sig = cookie.rsplit(".", 1) good = hmac.new(SESSION_SECRET.encode(), value.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(sig, good) def logged_in(request: Request) -> bool: return _verify(request.cookies.get("sliding_session", "")) def require_login(request: Request): """None si connecté, sinon la redirection à retourner.""" if logged_in(request): return None return RedirectResponse("/login", status_code=302) # ── helpers ────────────────────────────────────────────────────────────────── def render(name, **ctx): return HTMLResponse(env.get_template(name).render(**ctx)) def _narrator(slug: str): """Session Narrator du projet — créée et réamorcée au besoin.""" entry = NARRATORS.get(slug) if entry: return entry proj = fac.Project(slug) session = fac.AgentSession(fac.NARRATOR_ID) chat = [] plan = proj.load_state().get("dernier_plan_compact", "") if plan: try: fac.reprime_narrator(session, proj) chat.append(("system", "Session reprise — plan compact rechargé.")) except Exception as e: chat.append(("system", f"Reprise sans réamorçage ({e})")) entry = {"agent": session, "chat": chat, "started": bool(plan)} NARRATORS[slug] = entry return entry def _chat_html(chat): """Fragment HTML de l'historique (échappé).""" out = [] for role, txt in chat: cls = {"user": "msg-user", "narrator": "msg-bot", "system": "msg-sys"}.get(role, "msg-bot") out.append(f'
'
f'{html.escape(txt)}Écris au Narrator pour ' \ 'démarrer (les commandes f: et d: ' \ 'fonctionnent).
' def _safe_child(root: Path, name: str) -> Path: """Garde anti path-traversal : name doit rester sous root.""" p = (root / name).resolve() if not str(p).startswith(str(root.resolve())): raise ValueError("chemin refusé") return p # ── routes : auth ──────────────────────────────────────────────────────────── async def login_get(request): if logged_in(request): return RedirectResponse("/", status_code=302) return render("login.html", error="") async def login_post(request): form = await request.form() if not SECURITY_WORD or form.get("word", "") != SECURITY_WORD: return render("login.html", error="Mot de sécurité incorrect.") resp = RedirectResponse("/", status_code=302) token = _sign(f"bastien:{int(time.time())}:" f"{secrets.token_urlsafe(8)}") resp.set_cookie("sliding_session", token, httponly=True, secure=True, samesite="lax", max_age=60 * 60 * 24 * 30) return resp async def logout(request): resp = RedirectResponse("/login", status_code=302) resp.delete_cookie("sliding_session") return resp # ── routes : projets ───────────────────────────────────────────────────────── async def home(request): guard = require_login(request) if guard: return guard return render("projects.html", projects=api.list_projects()) async def create_project(request): guard = require_login(request) if guard: return guard form = await request.form() nom = (form.get("nom") or "").strip() if nom: r = api.create_project(nom) return RedirectResponse(f"/p/{r['slug']}", status_code=302) return RedirectResponse("/", status_code=302) async def project_page(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] try: info = api.get_project(slug) except ValueError: return RedirectResponse("/", status_code=302) entry = NARRATORS.get(slug) chat = entry["chat"] if entry else [] # previews existantes ? previews, cache_v = [], 0 pptx = _pptx_path(slug) if pptx: prev_dir = pptx.parent / f"{pptx.stem}_previews" cache_v = int(pptx.stat().st_mtime) if prev_dir.is_dir(): previews = sorted(p.name for p in prev_dir.glob("slide-*.png")) return render("project.html", p=info, chat_html=_chat_html(chat), previews=previews, cache_v=cache_v) # ── routes : chat Narrator ─────────────────────────────────────────────────── async def chat_post(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] form = await request.form() msg = (form.get("message") or "").strip() if not msg: return HTMLResponse("") entry = _narrator(slug) entry["chat"].append(("user", msg)) try: if entry["started"]: answer = entry["agent"].send(msg) else: answer = entry["agent"].start(msg) entry["started"] = True entry["chat"].append(("narrator", answer)) except Exception as e: entry["chat"].append(("system", f"Erreur agent : {e}")) return HTMLResponse(_chat_html(entry["chat"])) async def plan_save(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] form = await request.form() api.set_plan(slug, form.get("plan_compact") or "") return HTMLResponse('Plan sauvegardé ✓') # ── routes : documents & assets ────────────────────────────────────────────── async def upload_doc(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] form = await request.form() up = form.get("file") if up is not None and getattr(up, "filename", ""): data = await up.read() try: text = data.decode("utf-8") api.add_document(slug, up.filename, text) except UnicodeDecodeError: import base64 as _b64 api.add_document(slug, up.filename, _b64.b64encode(data).decode("ascii"), encoding="base64") return RedirectResponse(f"/p/{slug}", status_code=302) async def upload_asset(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] proj = fac.Project(slug) form = await request.form() up = form.get("file") if up is not None and getattr(up, "filename", ""): assets = proj.root / "assets" assets.mkdir(exist_ok=True) dest = _safe_child(assets, Path(up.filename).name) dest.write_bytes(await up.read()) return RedirectResponse(f"/p/{slug}", status_code=302) # ── routes : pipeline (long — le reverse proxy doit tolérer 600 s) ────────── async def formalise(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] entry = _narrator(slug) if not entry["started"]: return HTMLResponse('Aucune conversation ' 'Narrator — travaille le plan d\'abord, ' 'puis sauvegarde-le.
') proj = fac.Project(slug) try: # Message IDENTIQUE à /formalise du facilitator (le Narrator # produit un Markdown structuré #/##/### — PAS des "SLIDE N — # layout" : ce sont le Designer puis l'Encoder qui les créent). md = entry["agent"].feedback( "Formalise maintenant le plan compact validé en Markdown " "structuré complet pour le Designer (format #, ##, ###), " "en développant le contenu de chaque slide. Inclus les " "slides tiroir en les marquant [OPT-XXX].") entry["chat"].append(("system", "Formalisation demandée.")) entry["chat"].append(("narrator", md)) # Garde : le Narrator a-t-il vraiment produit un plan ? if md.count("#") < 3 or len(md) < 300: return HTMLResponse( 'Le Narrator n\'a pas renvoyé un plan ' 'formalisé exploitable (réponse trop courte ou non ' 'structurée). Vérifie le plan dans le chat, ajuste, ' 'puis relance.
') proj.mark_formalised(md) r = api.formalise(slug, md) except Exception as e: return HTMLResponse(f'Échec : ' f'{html.escape(str(e))}
') if r.get("error"): return HTMLResponse( f'{html.escape(str(r.get("error")))}'
f'
Le Designer/Encoder n\'a pas su '
f'transcrire ce plan. Regarde la formalisation dans le '
f'chat : elle doit lister les slides une à une.'
f'
Deck généré : {r["slides"]} slides — '
f'{html.escape(r["validation"])}
'
f'Télécharger le '
f'PPTX puis « Générer les aperçus » '
f'ci-dessus pour les voir.
' f'{html.escape(r["error"])}
') cells = "".join( f'{len(r["slides"])} aperçus générés. Coche des ' f'slides pour les réviser.
' f'') async def revise(request): guard = require_login(request) if guard: return guard slug = request.path_params["slug"] form = await request.form() positions = [int(v) for v in form.getlist("positions")] instruction = (form.get("instruction") or "").strip() if not positions or not instruction: return HTMLResponse('Coche des slides et donne ' 'une instruction.
') r = api.revise(slug, positions, instruction) if r.get("error"): return HTMLResponse(f'' f'{html.escape(str(r))}
') return HTMLResponse( f'Slides {r["slides_revisees"]} révisées, deck '
f'complet régénéré.
Télécharger puis '
f'« Générer les aperçus » pour revoir le deck.
Aucun PPTX disponible pour ce projet — ' 'lance « Formaliser le deck » d\'abord.
', status_code=404) return FileResponse( str(pptx), filename=pptx.name, media_type="application/vnd.openxmlformats-officedocument." "presentationml.presentation") async def layouts_gallery(request): """Catalogue des layouts — l'index.html généré par build_gallery.py, servi tel quel (toujours à jour après une régénération).""" guard = require_login(request) if guard: return guard page = BASE / "index.html" if not page.exists(): return HTMLResponse( 'Galerie '
'absente — lancer python3 build_gallery.py '
'sur le NAS.