diff --git a/.gitignore b/.gitignore index cc31506..f473575 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ oauth_state.json mcp.pid mcp.log watchdog.log +webapp.log +webapp.pid diff --git a/start_webapp.sh b/start_webapp.sh new file mode 100644 index 0000000..7a70993 --- /dev/null +++ b/start_webapp.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd /volume1/homes/Master/App/Sliding/python-pptx +pkill -f "uvicorn webapp_sliding:app" 2>/dev/null; fuser -k 8769/tcp 2>/dev/null +sleep 2 +source venv/bin/activate +uvicorn webapp_sliding:app --host 127.0.0.1 --port 8769 >> /volume1/homes/Master/App/Sliding/python-pptx/webapp.log 2>&1 & +echo $! > /volume1/homes/Master/App/Sliding/python-pptx/webapp.pid diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..7c58f28 --- /dev/null +++ b/static/style.css @@ -0,0 +1,50 @@ +:root{--navy:#061033;--navy2:#10204D;--coral:#F4795B;--glacier:#8FA9D0; +--card:#F4F1EC;--body:#2B3440;--muted:#8A93A0;--white:#FFFFFF} +*{box-sizing:border-box} +body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,sans-serif; +color:var(--body);background:#FAF9F7} +header{display:flex;justify-content:space-between;align-items:center; +padding:14px 28px;background:var(--navy);color:var(--white)} +.brand{color:var(--white);text-decoration:none;font-weight:700; +letter-spacing:.12em} +.brand span{color:var(--glacier);font-weight:400;letter-spacing:0} +header nav a{color:var(--glacier);text-decoration:none} +main{max-width:1200px;margin:28px auto;padding:0 20px} +h1,h2{font-family:Georgia,Cambria,serif;color:var(--navy)} +h1{font-size:1.7rem}h2{font-size:1.1rem;margin-top:0} +.card{background:var(--white);border:1px solid #E8E5DE;border-radius:10px; +padding:18px;box-shadow:0 2px 6px rgba(6,16,51,.06)} +.login{max-width:380px;margin:70px auto} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); +gap:16px;margin-top:18px} +.proj{display:block;text-decoration:none;color:inherit} +.proj:hover{border-color:var(--coral)} +.cols{display:grid;grid-template-columns:1.4fr 1fr;gap:18px; +align-items:start} +@media(max-width:900px){.cols{grid-template-columns:1fr}} +aside>.card{margin-bottom:16px} +#chat{max-height:420px;overflow-y:auto;border:1px solid #EEE; +border-radius:8px;padding:10px;background:#FCFBF9;margin-bottom:10px} +.msg pre{white-space:pre-wrap;margin:0;font:inherit} +.msg{padding:8px 12px;border-radius:8px;margin:6px 0} +.msg-user{background:var(--navy);color:var(--white);margin-left:14%} +.msg-bot{background:var(--card);margin-right:8%} +.msg-sys{background:#EAF0F8;color:var(--muted);font-size:.85rem} +textarea,input[type=text],input[type=password],input[name=nom], +input[name=instruction]{width:100%;padding:10px;border:1px solid #D8D4CC; +border-radius:8px;font:inherit;margin:6px 0} +button,.btn{background:var(--coral);color:#fff;border:none;padding:9px 16px; +border-radius:8px;font:inherit;cursor:pointer;text-decoration:none; +display:inline-block;margin:4px 4px 0 0} +button:hover{filter:brightness(.95)} +.actions{margin-top:12px} +.newproj{display:flex;gap:10px;max-width:480px} +.hint{color:var(--muted);font-size:.9rem} +.ok{color:#2C7A4B}.err{color:#B3261E} +.gallery{display:grid;grid-template-columns:repeat(auto-fill, +minmax(280px,1fr));gap:14px;margin:18px 0} +.gallery img{width:100%;border:1px solid #E3E0D9;border-radius:6px} +figure{margin:0}figcaption{font-size:.85rem;color:var(--muted)} +.htmx-indicator{display:none;position:fixed;bottom:20px;right:20px; +background:var(--navy);color:#fff;padding:10px 18px;border-radius:8px} +.htmx-request .htmx-indicator,.htmx-request.htmx-indicator{display:block} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5472894 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,18 @@ + + + + + +Sliding — {% block title %}{% endblock %} + + + + +
+ SLIDING · Pernod Ricard + +
+
{% block content %}{% endblock %}
+
Le NAS travaille…
+ + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..8611623 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,12 @@ +{% extends "base.html" %}{% block title %}Connexion{% endblock %} +{% block content %} +
+

Sliding Automation

+

Mot de sécurité (le même que le connecteur MCP).

+ {% if error %}

{{ error }}

{% endif %} +
+ + +
+
+{% endblock %} diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000..3c787c1 --- /dev/null +++ b/templates/project.html @@ -0,0 +1,69 @@ +{% extends "base.html" %}{% block title %}{{ p.slug }}{% endblock %} +{% block content %} +

{{ p.slug }}

+
+
+

Narrator

+
{{ chat_html | safe }}
+
+ + +
+
+ + + {% if p.dernier_pptx %} + Télécharger le PPTX + {% endif %} +
+
+
+ +
+ +{% endblock %} diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..058be44 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,19 @@ +{% extends "base.html" %}{% block title %}Projets{% endblock %} +{% block content %} +

Projets

+
+ + +
+
+{% for pr in projects %} + +

{{ pr.nom }}

+

{{ pr.sessions }} session(s) + {% if pr.a_un_plan %}· plan ✓{% endif %} + {% if pr.dernier_pptx %}· deck ✓{% endif %}

+

{{ pr.maj or "jamais ouvert" }}

+
+{% endfor %} +
+{% endblock %} diff --git a/watchdog.sh b/watchdog.sh index 4a2672f..bbf271e 100644 --- a/watchdog.sh +++ b/watchdog.sh @@ -44,3 +44,4 @@ check_and_restart() { } check_and_restart "mcp" "http://127.0.0.1:8768/health" "start_mcp.sh" +check_and_restart "webapp" "http://127.0.0.1:8769/health" "start_webapp.sh" diff --git a/webapp_sliding.py b/webapp_sliding.py new file mode 100644 index 0000000..e5b62de --- /dev/null +++ b/webapp_sliding.py @@ -0,0 +1,421 @@ +#!/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)}
') + return "".join(out) or '

É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 = [] + pptx = info.get("dernier_pptx") + if pptx: + prev_dir = Path(pptx).parent / f"{Path(pptx).stem}_previews" + 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) + + +# ── 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.' + '

') + try: + md = entry["agent"].send( + "formalise — produis la formalisation complète slide par " + "slide (SLIDE N — titre, contenus, Notes éventuelles), " + "sans commentaire autour.") + entry["chat"].append(("system", "Formalisation demandée…")) + 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'

' + f'{html.escape(str(r))}

') + return HTMLResponse( + f'

Deck généré : {r["slides"]} slides — ' + f'{html.escape(r["validation"])}
' + f'Télécharger le PPTX · ' + f'

') + + +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'

' + f'{html.escape(r["error"])}

') + cells = "".join( + f'
' + f'
' + f'
' + for i, n in enumerate(r["slides"])) + return HTMLResponse( + f'
' + f'' + 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é.
' + f'Télécharger ·

') + + +# ── routes : fichiers ──────────────────────────────────────────────────────── + +async def preview_png(request): + guard = require_login(request) + if guard: + return guard + slug = request.path_params["slug"] + name = request.path_params["name"] + proj = fac.Project(slug) + state = proj.load_state() + pptx = state.get("dernier_pptx") + if not pptx: + return Response(status_code=404) + prev_dir = Path(pptx).parent / f"{Path(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") + + +async def download(request): + guard = require_login(request) + if guard: + return guard + slug = request.path_params["slug"] + proj = fac.Project(slug) + pptx = proj.load_state().get("dernier_pptx") + if not pptx or not Path(pptx).exists(): + return Response(status_code=404) + return FileResponse(pptx, filename=Path(pptx).name) + + +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("/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)