feat: web app compagnon sur sliding_api - chat Narrator, plan, galerie, revision (C9)
This commit is contained in:
@@ -17,3 +17,5 @@ oauth_state.json
|
|||||||
mcp.pid
|
mcp.pid
|
||||||
mcp.log
|
mcp.log
|
||||||
watchdog.log
|
watchdog.log
|
||||||
|
webapp.log
|
||||||
|
webapp.pid
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sliding — {% block title %}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<a class="brand" href="/">SLIDING <span>· Pernod Ricard</span></a>
|
||||||
|
<nav><a href="/logout">Déconnexion</a></nav>
|
||||||
|
</header>
|
||||||
|
<main>{% block content %}{% endblock %}</main>
|
||||||
|
<div id="busy" class="htmx-indicator">Le NAS travaille…</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}{% block title %}Connexion{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card login">
|
||||||
|
<h1>Sliding Automation</h1>
|
||||||
|
<p class="hint">Mot de sécurité (le même que le connecteur MCP).</p>
|
||||||
|
{% if error %}<p class="err">{{ error }}</p>{% endif %}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<input type="password" name="word" placeholder="Mot de sécurité" autofocus>
|
||||||
|
<button type="submit">Entrer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}{% block title %}{{ p.slug }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ p.slug }}</h1>
|
||||||
|
<div class="cols">
|
||||||
|
<section class="card chatbox">
|
||||||
|
<h2>Narrator</h2>
|
||||||
|
<div id="chat">{{ chat_html | safe }}</div>
|
||||||
|
<form hx-post="/p/{{ p.slug }}/chat" hx-target="#chat"
|
||||||
|
hx-indicator="#busy"
|
||||||
|
hx-on::after-request="this.reset();
|
||||||
|
document.getElementById('chat').scrollTop = 1e9">
|
||||||
|
<textarea name="message" rows="4"
|
||||||
|
placeholder="Parle au Narrator (multiligne natif — f: et d: fonctionnent)"></textarea>
|
||||||
|
<button type="submit">Envoyer</button>
|
||||||
|
</form>
|
||||||
|
<div class="actions">
|
||||||
|
<button hx-post="/p/{{ p.slug }}/formalise"
|
||||||
|
hx-target="#pipeline-out" hx-indicator="#busy"
|
||||||
|
hx-confirm="Lancer Designer + Encoder + rendu (1-3 min) ?">
|
||||||
|
Formaliser le deck</button>
|
||||||
|
<button hx-post="/p/{{ p.slug }}/preview"
|
||||||
|
hx-target="#gallery" hx-indicator="#busy">
|
||||||
|
Générer les aperçus</button>
|
||||||
|
{% if p.dernier_pptx %}
|
||||||
|
<a class="btn" href="/p/{{ p.slug }}/download">Télécharger le PPTX</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="pipeline-out"></div>
|
||||||
|
</section>
|
||||||
|
<aside>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Plan compact</h2>
|
||||||
|
<form hx-post="/p/{{ p.slug }}/plan" hx-target="#plan-status"
|
||||||
|
hx-indicator="#busy">
|
||||||
|
<textarea name="plan_compact" rows="14">{{ p.plan_compact }}</textarea>
|
||||||
|
<button type="submit">Sauvegarder</button>
|
||||||
|
<span id="plan-status"></span>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Documents</h2>
|
||||||
|
<ul class="hint">{% for d in p.documents %}<li>{{ d }}</li>{% endfor %}</ul>
|
||||||
|
<form method="POST" action="/p/{{ p.slug }}/upload"
|
||||||
|
enctype="multipart/form-data">
|
||||||
|
<input type="file" name="file"><button type="submit">Déposer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Assets (images)</h2>
|
||||||
|
<ul class="hint">{% for a in p.assets %}<li>{{ a }}</li>{% endfor %}</ul>
|
||||||
|
<form method="POST" action="/p/{{ p.slug }}/upload_asset"
|
||||||
|
enctype="multipart/form-data">
|
||||||
|
<input type="file" name="file" accept="image/*">
|
||||||
|
<button type="submit">Déposer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<section id="gallery">
|
||||||
|
{% if previews %}
|
||||||
|
<div class="gallery">
|
||||||
|
{% for n in previews %}
|
||||||
|
<figure><img src="/p/{{ p.slug }}/previews/{{ n }}" loading="lazy">
|
||||||
|
<figcaption>{{ n }}</figcaption></figure>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}{% block title %}Projets{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Projets</h1>
|
||||||
|
<form class="newproj" method="POST" action="/projects">
|
||||||
|
<input name="nom" placeholder="Nouveau projet…" required>
|
||||||
|
<button type="submit">Créer</button>
|
||||||
|
</form>
|
||||||
|
<div class="grid">
|
||||||
|
{% for pr in projects %}
|
||||||
|
<a class="card proj" href="/p/{{ pr.slug }}">
|
||||||
|
<h2>{{ pr.nom }}</h2>
|
||||||
|
<p class="hint">{{ pr.sessions }} session(s)
|
||||||
|
{% if pr.a_un_plan %}· plan ✓{% endif %}
|
||||||
|
{% if pr.dernier_pptx %}· deck ✓{% endif %}</p>
|
||||||
|
<p class="hint">{{ pr.maj or "jamais ouvert" }}</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -44,3 +44,4 @@ check_and_restart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_and_restart "mcp" "http://127.0.0.1:8768/health" "start_mcp.sh"
|
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"
|
||||||
|
|||||||
@@ -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'<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 = []
|
||||||
|
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('<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.'
|
||||||
|
'</p>')
|
||||||
|
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'<p class="err">Échec : '
|
||||||
|
f'{html.escape(str(e))}</p>')
|
||||||
|
if r.get("error"):
|
||||||
|
return HTMLResponse(f'<p class="err">'
|
||||||
|
f'{html.escape(str(r))}</p>')
|
||||||
|
return HTMLResponse(
|
||||||
|
f'<p class="ok">Deck généré : {r["slides"]} slides — '
|
||||||
|
f'{html.escape(r["validation"])}<br>'
|
||||||
|
f'<a href="/p/{slug}/download">Télécharger le PPTX</a> · '
|
||||||
|
f'<button hx-post="/p/{slug}/preview" '
|
||||||
|
f'hx-target="#gallery" hx-indicator="#busy">Générer les '
|
||||||
|
f'aperçus</button></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'<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 href="/p/{slug}/download">'
|
||||||
|
f'Télécharger</a> · <button hx-post="/p/{slug}/preview" '
|
||||||
|
f'hx-target="#gallery" hx-indicator="#busy">Re-générer les '
|
||||||
|
f'aperçus</button></p>')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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)
|
||||||
Reference in New Issue
Block a user