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 @@ + + +
+ + +Mot de sécurité (le même que le connecteur MCP).
+ {% if error %}{{ error }}
{% endif %} + +{{ pr.sessions }} session(s) + {% if pr.a_un_plan %}· plan ✓{% endif %} + {% if pr.dernier_pptx %}· deck ✓{% endif %}
+{{ pr.maj or "jamais ouvert" }}
+ +{% endfor %} +'
+ 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 = [] + 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'
' + f'{html.escape(r["error"])}
') + cells = "".join( + f'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 ·