Files
sliding-automation/webapp_sliding.py
T

476 lines
18 KiB
Python

#!/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'<div class="msg {cls}"><pre>'
f'{html.escape(txt)}</pre></div>')
return "".join(out) or '<p class="hint">Écris au Narrator pour ' \
'démarrer (les commandes f: et d: ' \
'fonctionnent).</p>'
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('<span class="ok">Plan sauvegardé ✓</span>')
# ── 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('<p class="err">Aucune conversation '
'Narrator — travaille le plan d\'abord, '
'puis sauvegarde-le.</p>')
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(
'<p class="err">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.</p>')
proj.mark_formalised(md)
r = api.formalise(slug, md)
except Exception as e:
return HTMLResponse(f'<p class="err">Échec : '
f'{html.escape(str(e))}</p>')
if r.get("error"):
return HTMLResponse(
f'<p class="err">{html.escape(str(r.get("error")))}'
f'<br><span class="hint">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.</span>'
f'</p>')
return HTMLResponse(
f'<p class="ok">Deck généré : {r["slides"]} slides — '
f'{html.escape(r["validation"])}<br>'
f'<a class="btn" href="/p/{slug}/download">Télécharger le '
f'PPTX</a> <span class="hint">puis « Générer les aperçus » '
f'ci-dessus pour les voir.</span></p>')
async def preview(request):
guard = require_login(request)
if guard:
return guard
slug = request.path_params["slug"]
r = api.preview(slug)
if r.get("error"):
return HTMLResponse(f'<p class="err">'
f'{html.escape(r["error"])}</p>')
cells = "".join(
f'<figure><img src="/p/{slug}/previews/{n}" loading="lazy">'
f'<figcaption><label><input type="checkbox" '
f'name="positions" value="{i + 1}"> {n}</label>'
f'</figcaption></figure>'
for i, n in enumerate(r["slides"]))
return HTMLResponse(
f'<p class="ok">{len(r["slides"])} aperçus générés. Coche des '
f'slides pour les réviser.</p>'
f'<form hx-post="/p/{slug}/revise" hx-target="#pipeline-out" '
f'hx-indicator="#busy"><div class="gallery">{cells}</div>'
f'<input name="instruction" placeholder="Instruction de '
f'révision pour les slides cochées" style="width:70%%">'
f'<button type="submit">Réviser</button></form>')
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('<p class="err">Coche des slides et donne '
'une instruction.</p>')
r = api.revise(slug, positions, instruction)
if r.get("error"):
return HTMLResponse(f'<p class="err">'
f'{html.escape(str(r))}</p>')
return HTMLResponse(
f'<p class="ok">Slides {r["slides_revisees"]} révisées, deck '
f'complet régénéré.<br><a class="btn" href="/p/{slug}/'
f'download">Télécharger</a> <span class="hint">puis '
f'« Générer les aperçus » pour revoir le deck.</span></p>')
# ── routes : fichiers ────────────────────────────────────────────────────────
def _pptx_path(slug):
"""Chemin ABSOLU du dernier PPTX. project_state.json stocke un
chemin relatif à la racine du pipeline — la webapp peut avoir un
cwd différent, on résout donc depuis BASE."""
proj = fac.Project(slug)
raw = (proj.load_state().get("dernier_pptx") or "").strip()
if not raw:
return None
p = Path(raw)
if not p.is_absolute():
p = BASE / p
return p if p.exists() else None
async def preview_png(request):
guard = require_login(request)
if guard:
return guard
slug = request.path_params["slug"]
name = request.path_params["name"]
pptx = _pptx_path(slug)
if not pptx:
return Response(status_code=404)
prev_dir = pptx.parent / f"{pptx.stem}_previews"
try:
png = _safe_child(prev_dir, name)
except ValueError:
return Response(status_code=403)
if not png.exists():
return Response(status_code=404)
return FileResponse(str(png), media_type="image/png",
headers={"Cache-Control": "no-store"})
async def download(request):
guard = require_login(request)
if guard:
return guard
slug = request.path_params["slug"]
pptx = _pptx_path(slug)
if not pptx:
return HTMLResponse(
'<p class="err">Aucun PPTX disponible pour ce projet — '
'lance « Formaliser le deck » d\'abord.</p>',
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(
'<p style="font-family:sans-serif;margin:40px">Galerie '
'absente — lancer <code>python3 build_gallery.py</code> '
'sur le NAS.</p>', status_code=404)
return FileResponse(str(page), media_type="text/html",
headers={"Cache-Control": "no-store"})
async def health(request):
return JSONResponse({"status": "ok", "server": "webapp-sliding",
"version": "1.0.0"})
app = Starlette(routes=[
Route("/login", login_get, methods=["GET"]),
Route("/login", login_post, methods=["POST"]),
Route("/logout", logout, methods=["GET"]),
Route("/", home, methods=["GET"]),
Route("/projects", create_project, methods=["POST"]),
Route("/p/{slug}", project_page, methods=["GET"]),
Route("/p/{slug}/chat", chat_post, methods=["POST"]),
Route("/p/{slug}/plan", plan_save, methods=["POST"]),
Route("/p/{slug}/upload", upload_doc, methods=["POST"]),
Route("/p/{slug}/upload_asset", upload_asset, methods=["POST"]),
Route("/p/{slug}/formalise", formalise, methods=["POST"]),
Route("/p/{slug}/preview", preview, methods=["POST"]),
Route("/p/{slug}/revise", revise, methods=["POST"]),
Route("/p/{slug}/previews/{name}", preview_png, methods=["GET"]),
Route("/p/{slug}/download", download, methods=["GET"]),
Route("/layouts", layouts_gallery, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Mount("/static", StaticFiles(directory=str(BASE / "static")),
name="static"),
])
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8769)