Compare commits

..
4 Commits
Author SHA1 Message Date
Spark 465b72235c 7.76.0 — panneau Spark : section Missions (J5), assets à jour
FlowDeck CI / lint (push) Waiting to run
FlowDeck CI / test (push) Waiting to run
FlowDeck CI / docker (push) Blocked by required conditions
- Même panneau partagé que TikTak : missions longues, sélecteur
  d'exécuteur, rapports, annulation
2026-10-22 03:39:40 +00:00
Spark e36f80714c Flowdeck 7.76.0 — serveur MCP en écriture (create_page, update_page, create_collection)
- create_page: collection_id (int, requis), title (string, requis), icon (optionnel), properties (objet optionnel -> property_values_json)
- update_page: id (int, requis), title (optionnel), properties (objet optionnel, remplace property_values_json)
- create_collection: name (requis), description (optionnel), icon (optionnel) + vue par défaut
- Aucune suppression; erreurs métier en {"erreur": "..."}; GET /mcp read_only=false et liste mise à jour
- Tests étendus: création+relecture, mise à jour, create_collection, erreurs, 401 sans jeton sur écriture
2026-10-22 03:29:09 +00:00
Spark 9fe5114669 7.75.0 — Fusion : serveur MCP lecture seule + panneau Spark
FlowDeck CI / lint (push) Waiting to run
FlowDeck CI / docker (push) Blocked by required conditions
FlowDeck CI / test (push) In progress
- POST /mcp (JSON-RPC, dialecte TikTak/Kanbante) : list_collections,
  get_collection, get_page, search_pages, list_repos ; auth par les
  jetons d'API personnels existants (api_tokens)
- Panneau Spark vendangé de TikTak, branché sur l'action spark de la
  barre commune ; CSP connect-src : exception nommée agent.dracodev.net
- Tests : test_fusion_mcp_server (3) verts ; audit CSP mis à jour ;
  2 échecs locaux préexistants (DNS) inchangés
2026-10-21 22:54:42 -04:00
Spark a422c7d713 fix: barre commune — JS livré au bon chemin vendor/, v0.1.4 palier 900 px (7.74.3)
FlowDeck CI / lint (push) Failing after 1m26s
FlowDeck CI / test (push) Failing after 21m34s
FlowDeck CI / docker (push) Skipped
2026-10-21 12:48:11 -04:00
15 changed files with 1075 additions and 146 deletions
+55
View File
@@ -1,5 +1,60 @@
# Changelog - FlowDeck
## v7.76.0 (2026-10-21) — Fusion : serveur MCP en écriture
### Ajouté
- **Serveur MCP en écriture** (`POST /mcp`) : en plus des lectures
existantes, trois nouveaux outils — `create_page`
(`collection_id`, `title`, `icon` optionnel, `properties` optionnel
→ `property_values_json`), `update_page` (`id`, `title` optionnel,
`properties` optionnel qui remplace `property_values_json`) et
`create_collection` (`name`, `description` optionnel, `icon`
optionnel, avec vue par défaut comme l'API collections native).
Aucune suppression. Les erreurs métier (collection/page
introuvable, titre vide) reviennent en `{"erreur": "..."}` dans le
contenu texte, comme les lectures. `GET /mcp` reflète les nouveaux
outils (`read_only: false`). Authentification inchangée : jetons
d'API personnels existants.
- **Redéploiement Docker à la charge de Bruno** pour mettre cette
version en production.
## v7.75.0 (2026-10-21) — Fusion : serveur MCP + panneau Spark
### Ajouté
- **Serveur MCP en lecture seule** (`POST /mcp`, JSON-RPC, même
dialecte que TikTak et Kanbante) : `list_collections`,
`get_collection`, `get_page`, `search_pages`, `list_repos`.
Authentification par les **jetons d'API personnels existants**
(Paramètres → Jetons d'API) — le service d'agents lit au nom du
propriétaire du jeton qu'on lui crée. Flowdeck était la seule des
quatre applications sans porte MCP ; c'est corrigé.
- **Panneau Spark intégré** : le bouton Spark de la barre commune
ouvre le panneau de l'agent commun (conversation, Aujourd'hui,
approbations), assets vendangés à l'identique de TikTak.
### Sécurité
- **CSP `connect-src`** : exception nommée et unique pour
`https://agent.dracodev.net` (le service d'agents, premier parti) —
le panneau en a besoin pour parler au service. Aucun autre hôte
externe n'est ouvert ; le `https:` générique reste interdit
(test d'audit mis à jour en conséquence).
## v7.74.3 (2026-10-21) — Barre commune : le menu du ✦ arrive enfin + palier 900 px
### Corrigé
- **Le JavaScript de la barre était copié au mauvais endroit** :
la page charge `/static/js/vendor/draco-barre.js`, la v0.1.3
avait été posée dans `/static/js/` — le menu des applications
ne pouvait pas s'ouvrir (retour de Bruno). Fichier livré au bon
chemin, copie égarée supprimée.
- **Barre commune v0.1.4** : palier mobile élargi de 700 à
900 px (zone morte fenêtres réduites / tablettes / webviews),
décalage de `.app-layout` aligné.
## v7.74.2 (2026-10-21) — Barre commune v0.1.3 : menu des applications par le ✦
### Ajouté
+1 -1
View File
@@ -1 +1 @@
7.74.2
7.76.0
+3 -1
View File
@@ -192,7 +192,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.74.2",
version="7.74.3",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -239,6 +239,8 @@ app.include_router(admin.router)
app.include_router(gitea_router)
app.include_router(github_router)
app.include_router(public_api.router)
from app.routers import mcp_server as _mcp_server # Fusion — serveur MCP (service d'agents)
app.include_router(_mcp_server.router)
app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
+4 -1
View File
@@ -130,7 +130,10 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
# côté front — grep négatif) et websockets scopés à l'hôte de la
# requête ({host}) → plus de canal d'exfil vers un tiers.
"connect-src 'self' ws://{host} wss://{host}; "
# Exception Fusion (2026-10-21) : le panneau Spark parle au
# service d'agents commun (premier parti, même programme) —
# seul hôte externe nommé, rien d'autre ne s'ouvre.
"connect-src 'self' https://agent.dracodev.net ws://{host} wss://{host}; "
"media-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
+280
View File
@@ -0,0 +1,280 @@
"""FlowDeck — serveur MCP (Fusion, branchement du service d'agents).
Même dialecte JSON-RPC que les autres apps du programme (TikTak,
Kanbante) : POST /mcp, initialize → tools/list → tools/call,
réponses en contenu texte JSON.
Authentification : les jetons d'API personnels existants
(Settings → API tokens, table ``api_tokens``, vérifiés par
``public_api._token_owner``) — aucune nouvelle machinerie de clés.
Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
il lit et écrit en son nom.
Objet exposé : les **collections** (les projets/espaces de travail
de Flowdeck) et leurs pages, la recherche par titre, et le registre
des dépôts (table ``projects``). Doctrine du programme : Flowdeck
référence les tâches, il ne les copie pas — aucune tâche TikTak ne
transite par ici. Écritures limitées à la création/mise à jour de
pages et à la création de collections ; aucune suppression.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response
from app.db import get_conn
from app.routers.public_api import _token_owner
router = APIRouter(tags=["mcp"])
OUTILS = [
{"name": "list_collections",
"description": "Liste les collections Flowdeck (projets / espaces de travail) "
"avec leur nombre de pages.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_collection",
"description": "Lit une collection et ses pages (titres et propriétés).",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "get_page",
"description": "Lit une page de collection par son identifiant (titre, propriétés, contenu).",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "search_pages",
"description": "Recherche des pages par mots dans leur titre, toutes collections confondues.",
"inputSchema": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]}},
{"name": "list_repos",
"description": "Liste le registre des dépôts suivis par Flowdeck (builtin, Gitea, GitHub).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "create_page",
"description": "Crée une page dans une collection Flowdeck.",
"inputSchema": {"type": "object",
"properties": {"collection_id": {"type": "integer"},
"title": {"type": "string"},
"icon": {"type": "string"},
"properties": {"type": "object"}},
"required": ["collection_id", "title"]}},
{"name": "update_page",
"description": "Met à jour une page Flowdeck (titre et/ou propriétés ; "
"les propriétés fournies remplacent property_values_json).",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object"}},
"required": ["id"]}},
{"name": "create_collection",
"description": "Crée une collection Flowdeck (projet / espace de travail).",
"inputSchema": {"type": "object",
"properties": {"name": {"type": "string"},
"description": {"type": "string"},
"icon": {"type": "string"}},
"required": ["name"]}},
]
def _props(brut):
"""property_values_json → dict quand c'est du JSON, sinon inchangé."""
if not brut:
return {}
try:
return json.loads(brut)
except (ValueError, TypeError):
return brut
def _page_resume(row) -> dict:
return {"id": row["id"], "collection_id": row["collection_id"],
"title": row["title"], "icon": row["icon"],
"properties": _props(row["property_values_json"]),
"updated_at": row["updated_at"]}
def _appel_outil(nom: str, args: dict):
with get_conn() as conn:
if nom == "list_collections":
rows = conn.execute(
"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
"WHERE p.collection_id = c.id) AS page_count "
"FROM collections c ORDER BY c.name").fetchall()
return [{"id": r["id"], "name": r["name"],
"description": r["description"], "icon": r["icon"],
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
"page_count": r["page_count"]} for r in rows]
if nom == "get_collection":
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if coll is None:
return {"erreur": "collection introuvable"}
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? "
"ORDER BY position, id", (coll["id"],)).fetchall()
return {"collection": {"id": coll["id"], "name": coll["name"],
"description": coll["description"],
"icon": coll["icon"]},
"pages": [_page_resume(p) for p in pages]}
if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if p is None:
return {"erreur": "page introuvable"}
res = _page_resume(p)
for cle in ("content", "blocks", "blocks_json"):
if cle in p.keys() and p[cle]:
res[cle] = p[cle]
return res
if nom == "search_pages":
q = f"%{str(args.get('query', '')).strip()}%"
if q == "%%":
return []
rows = conn.execute(
"SELECT * FROM collection_pages WHERE title LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
return [_page_resume(p) for p in rows]
if nom == "list_repos":
rows = conn.execute(
"SELECT id, name, proj_type, owner, language, description "
"FROM projects ORDER BY proj_type, name").fetchall()
return [dict(r) for r in rows]
if nom == "create_page":
try:
collection_id = int(args.get("collection_id"))
except (TypeError, ValueError):
return {"erreur": "collection_id requis (entier)"}
title = str(args.get("title") or "").strip()
if not title:
return {"erreur": "titre vide"}
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(collection_id,)).fetchone()
if coll is None:
return {"erreur": "collection introuvable"}
props = args.get("properties")
if props is None:
props = {}
if not isinstance(props, dict):
return {"erreur": "properties doit être un objet"}
icon = str(args.get("icon") or "file")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
"WHERE collection_id = ?", (collection_id,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages "
"(collection_id, title, icon, position, property_values_json) "
"VALUES (?, ?, ?, ?, ?)",
(collection_id, title, icon, max_pos, json.dumps(props, ensure_ascii=False)))
conn.commit()
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(cur.lastrowid,)).fetchone()
return _page_resume(page)
if nom == "update_page":
try:
page_id = int(args.get("id"))
except (TypeError, ValueError):
return {"erreur": "id requis (entier)"}
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone()
if page is None:
return {"erreur": "page introuvable"}
title = page["title"]
if "title" in args and args.get("title") is not None:
title = str(args.get("title") or "").strip()
if not title:
return {"erreur": "titre vide"}
props_json = page["property_values_json"]
if "properties" in args and args.get("properties") is not None:
props = args.get("properties")
if not isinstance(props, dict):
return {"erreur": "properties doit être un objet"}
props_json = json.dumps(props, ensure_ascii=False)
conn.execute(
"UPDATE collection_pages SET title = ?, property_values_json = ?, "
"updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(title, props_json, page_id))
conn.commit()
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone()
return _page_resume(updated)
if nom == "create_collection":
name = str(args.get("name") or "").strip()
if not name:
return {"erreur": "nom vide"}
description = str(args.get("description") or "")
icon = str(args.get("icon") or "📋")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) "
"VALUES (?, ?, ?, '[]')",
(name, description, icon))
new_id = cur.lastrowid
# Vue par défaut, comme la création via l'API collections
# (app/routers/collections/crud.py).
conn.execute(
"INSERT INTO collection_views "
"(collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)",
(new_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"], "sorts": [], "filters": []})))
conn.commit()
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(new_id,)).fetchone()
return {"id": coll["id"], "name": coll["name"],
"description": coll["description"], "icon": coll["icon"]}
return {"erreur": f"outil inconnu : {nom}"}
def _version() -> str:
import pathlib
try:
return (pathlib.Path(__file__).resolve().parent.parent.parent
/ "VERSION").read_text(encoding="utf-8").strip()
except OSError:
return "dev"
@router.get("/mcp")
def mcp_info():
return {"name": "flowdeck", "version": _version(),
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False, "tools": [t["name"] for t in OUTILS]}
@router.post("/mcp")
async def mcp(request: Request):
auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not token or _token_owner(token) is None:
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
status_code=401)
try:
data = await request.json()
except Exception:
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "JSON invalide"}},
status_code=400)
methode = data.get("method", "")
rid = data.get("id")
if rid is None: # notification : rien à répondre
return Response(status_code=202)
if methode == "initialize":
return {"jsonrpc": "2.0", "id": rid,
"result": {"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "flowdeck", "version": _version()}}}
if methode == "ping":
return {"jsonrpc": "2.0", "id": rid, "result": {}}
if methode == "tools/list":
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
if methode == "tools/call":
params = data.get("params") or {}
res = _appel_outil(params.get("name", ""), params.get("arguments") or {})
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}}
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"Méthode inconnue : {methode}"}}
+6 -2
View File
@@ -1,5 +1,5 @@
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.3
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js. #}
@@ -85,7 +85,11 @@
}
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
+2
View File
@@ -31,6 +31,7 @@
aucune feuille de style (composants blancs en thème sombre). -->
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ asset_version }}">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@@ -2870,6 +2871,7 @@
</script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script src="/static/js/draco-panneau.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
+1 -1
View File
@@ -5313,7 +5313,7 @@ body.page-locked .get-started-toolbar { display: none !important; }
/* ── Barre d'apps commune dracodev + section « Liens » des pages
(Fusion ACC-1.1/1.3, snippet figé dracodev-commons v0.1.0) ── */
body.has-draco-bar .app-layout { height: calc(100vh - 60px); }
@media (max-width: 700px) {
@media (max-width: 900px) {
body.has-draco-bar .app-layout { height: calc(100vh - 52px); }
}
.draco-liens {
+8 -3
View File
@@ -1,7 +1,8 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21).
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -143,14 +144,18 @@
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
restaient affichés et la barre débordait sur téléphone.) */
restaient affichés et la barre débordait sur téléphone.
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
barre complète ne tenait pas (fenêtres réduites, tablettes,
webviews) alors que les apps sont déjà en mise en page mobile.) */
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
.draco-apptab { display: none; }
+83
View File
@@ -0,0 +1,83 @@
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1).
Composant d'intégration TikTak : le panneau définitif sera vendangé
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */
.draco-panneau {
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw;
background: #fbfbfa; color: #242624; z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
}
.draco-panneau.open { transform: translateX(0); }
.draco-panneau-hd {
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff;
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px;
width: 30px; height: 30px; cursor: pointer; font-size: 14px;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; }
.draco-panneau-outils button {
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px;
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: #e9ebe4; }
.draco-appro {
align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px;
}
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: #eef0ea; border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: #6c7268; font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid #e4e5e0; padding: 11px 12px; background: #fff; }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit;
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: #7c5cd6; color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: #6c7268; margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff;
}
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; }
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: #6c7268; }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; }
-128
View File
@@ -1,128 +0,0 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
if (ico) a.appendChild(ico.cloneNode(true));
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var fort = t.querySelector(".draco-apptab-txt strong");
var petit = t.querySelector(".draco-apptab-txt small");
var nom = document.createElement("span");
nom.className = "draco-apps-item-nom";
nom.textContent = fort ? fort.textContent : t.dataset.app;
txt.appendChild(nom);
if (petit) {
var role = document.createElement("span");
role.className = "draco-apps-item-role";
role.textContent = petit.textContent;
txt.appendChild(role);
}
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
});
bar.appendChild(menu);
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
buildMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+380
View File
@@ -0,0 +1,380 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps
* GET /api/v1/conversations/… — historique de la conversation
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5) : lancement,
* suivi, rapport, annulation
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
var convId = null;
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ }
var etatConnu = false;
var historiqueCharge = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
}
function construire() {
root = document.createElement("aside");
root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML =
'<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
});
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function chargerAujourdhui() {
var bloc = document.createElement("div");
bloc.className = "draco-auj";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px;color:#6c7268'>Décision enregistrée : " + esc(d.status) + "</span>";
})
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
"<div class='m-meta'>" + esc(m.executor) +
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport;
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function chargerHistorique() {
if (historiqueCharge || !convId) return;
historiqueCharge = true;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) {
convId = null;
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
return null;
}
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d || !d.messages || !d.messages.length) return;
var sys = msgsEl.querySelector(".draco-msg.sys");
d.messages.forEach(function (m) {
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ }
attente.textContent = d.reply;
if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) construire();
root.classList.add("open");
verifierIdentite();
chargerHistorique();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+79 -7
View File
@@ -1,4 +1,5 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -14,6 +15,9 @@
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
*/
(function () {
"use strict";
@@ -21,15 +25,16 @@
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
if (!b) return;
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
@@ -38,8 +43,70 @@
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
if (ico) a.appendChild(ico.cloneNode(true));
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var fort = t.querySelector(".draco-apptab-txt strong");
var petit = t.querySelector(".draco-apptab-txt small");
var nom = document.createElement("span");
nom.className = "draco-apps-item-nom";
nom.textContent = fort ? fort.textContent : t.dataset.app;
txt.appendChild(nom);
if (petit) {
var role = document.createElement("span");
role.className = "draco-apps-item-role";
role.textContent = petit.textContent;
txt.appendChild(role);
}
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
});
bar.appendChild(menu);
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
@@ -47,9 +114,14 @@
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
buildMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+7 -2
View File
@@ -291,8 +291,13 @@ def test_csp_no_cdn_and_vendor(client):
import re as _re
conn = _re.search(r"connect-src ([^;]*);", csp).group(1)
assert conn == "'self' ws://testserver wss://testserver", conn
assert " https:" not in conn and not conn.startswith("https:"), conn
# Exception Fusion (2026-10-21, 7.75.0) : le panneau Spark parle au
# service d'agents commun — seul hôte externe nommé autorisé.
assert conn == "'self' https://agent.dracodev.net ws://testserver wss://testserver", conn
# La source générique « https: » (tous hôtes) reste interdite ; seul
# l'hôte nommé du service d'agents est admis (voir l'assertion
# d'égalité ci-dessus, qui verrouille la liste complète).
assert "https:" not in conn.split(), conn
# vues chart/map : références locales (aucun CDN — A28 : le fichier
# collections.py est devenu un package, on balaie tous ses modules)
+166
View File
@@ -0,0 +1,166 @@
"""Fusion — serveur MCP Flowdeck (branchement du service d'agents).
POST /mcp, authentifié par les jetons d'API personnels existants
(api_tokens). Le service d'agents lit et écrit (création/mise à
jour de pages, création de collections — aucune suppression) au
nom du propriétaire du jeton.
"""
import hashlib
import json
import pytest
from app.db import get_conn
@pytest.fixture()
def jeton(client):
brut = "fd_test_token_pour_mcp"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (1, 'service-agents', ?, ?, 0)",
(hashlib.sha256(brut.encode()).hexdigest(), brut[:12]))
conn.commit()
return brut
@pytest.fixture()
def donnees(client):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, description, icon) VALUES ('Projet Alpha', 'Le projet de test', '🚀')")
cid = cur.lastrowid
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
"VALUES (?, 'Page de cadrage', 'file', 1, ?)",
(cid, json.dumps({"statut": "en cours"})))
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
"VALUES (?, 'Revue hebdo', 'file', 2, '{}')", (cid,))
conn.commit()
return cid
def _rpc(client, jeton, rid, methode, params=None):
h = {"Authorization": f"Bearer {jeton}"} if jeton else {}
corps = {"jsonrpc": "2.0", "id": rid, "method": methode}
if params is not None:
corps["params"] = params
return client.post("/mcp", json=corps, headers=h)
def _appel(client, jeton, rid, nom, args=None):
r = _rpc(client, jeton, rid, "tools/call", {"name": nom, "arguments": args or {}})
assert r.status_code == 200, r.text
return json.loads(r.json()["result"]["content"][0]["text"])
def test_mcp_sans_jeton_401(client):
r = _rpc(client, None, 1, "tools/list")
assert r.status_code == 401
r = _rpc(client, "mauvais-jeton", 1, "tools/list")
assert r.status_code == 401
def test_mcp_handshake_et_outils(client, jeton):
r = _rpc(client, jeton, 1, "initialize", {})
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
r = _rpc(client, jeton, 2, "tools/list")
noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_collections", "get_collection", "get_page",
"search_pages", "list_repos", "create_page",
"update_page", "create_collection"]
def test_mcp_collections_et_pages(client, jeton, donnees):
cols = _appel(client, jeton, 1, "list_collections")
alpha = [c for c in cols if c["name"] == "Projet Alpha"]
assert alpha and alpha[0]["page_count"] == 2
detail = _appel(client, jeton, 2, "get_collection", {"id": donnees})
assert detail["collection"]["name"] == "Projet Alpha"
titres = [p["title"] for p in detail["pages"]]
assert titres == ["Page de cadrage", "Revue hebdo"]
assert detail["pages"][0]["properties"] == {"statut": "en cours"}
res = _appel(client, jeton, 3, "search_pages", {"query": "cadrage"})
assert [p["title"] for p in res] == ["Page de cadrage"]
res = _appel(client, jeton, 4, "get_collection", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_create_page_puis_relecture(client, jeton, donnees):
creee = _appel(client, jeton, 10, "create_page", {
"collection_id": donnees, "title": "Page créée via MCP",
"icon": "file", "properties": {"statut": "à faire", "priorite": "haute"}})
assert creee["title"] == "Page créée via MCP"
assert creee["collection_id"] == donnees
assert creee["properties"] == {"statut": "à faire", "priorite": "haute"}
detail = _appel(client, jeton, 11, "get_collection", {"id": donnees})
titres = [p["title"] for p in detail["pages"]]
assert "Page créée via MCP" in titres
relue = [p for p in detail["pages"] if p["id"] == creee["id"]][0]
assert relue["properties"] == {"statut": "à faire", "priorite": "haute"}
def test_mcp_update_page_titre_et_proprietes(client, jeton, donnees):
creee = _appel(client, jeton, 20, "create_page", {
"collection_id": donnees, "title": "Titre initial",
"properties": {"statut": "brouillon"}})
maj = _appel(client, jeton, 21, "update_page", {
"id": creee["id"], "title": "Titre mis à jour",
"properties": {"statut": "publié"}})
assert maj["title"] == "Titre mis à jour"
# properties remplace property_values_json (pas de fusion)
assert maj["properties"] == {"statut": "publié"}
relue = _appel(client, jeton, 22, "get_page", {"id": creee["id"]})
assert relue["title"] == "Titre mis à jour"
assert relue["properties"] == {"statut": "publié"}
def test_mcp_create_collection(client, jeton):
creee = _appel(client, jeton, 30, "create_collection", {
"name": "Projet Bêta", "description": "Créée via MCP", "icon": "🧪"})
assert creee["name"] == "Projet Bêta"
assert creee["description"] == "Créée via MCP"
assert creee["icon"] == "🧪"
cols = _appel(client, jeton, 31, "list_collections")
beta = [c for c in cols if c["name"] == "Projet Bêta"]
assert beta and beta[0]["id"] == creee["id"]
assert beta[0]["page_count"] == 0
detail = _appel(client, jeton, 32, "get_collection", {"id": creee["id"]})
assert detail["collection"]["name"] == "Projet Bêta"
assert detail["pages"] == []
def test_mcp_erreurs_ecritures(client, jeton, donnees):
res = _appel(client, jeton, 40, "create_page", {
"collection_id": 99999, "title": "Orpheline"})
assert "introuvable" in res["erreur"]
res = _appel(client, jeton, 41, "create_page", {
"collection_id": donnees, "title": " "})
assert "erreur" in res
res = _appel(client, jeton, 42, "update_page", {
"id": 99999, "title": "Fantôme"})
assert "introuvable" in res["erreur"]
res = _appel(client, jeton, 43, "create_collection", {"name": " "})
assert "erreur" in res
def test_mcp_ecriture_sans_jeton_401(client, donnees):
r = _rpc(client, None, 50, "tools/call",
{"name": "create_page",
"arguments": {"collection_id": donnees, "title": "Refusée"}})
assert r.status_code == 401
r = _rpc(client, "mauvais-jeton", 51, "tools/call",
{"name": "create_collection", "arguments": {"name": "Refusée"}})
assert r.status_code == 401
def test_mcp_info_get(client, jeton):
r = client.get("/mcp")
assert r.status_code == 200
infos = r.json()
assert infos["read_only"] is False
assert "create_page" in infos["tools"]
assert "update_page" in infos["tools"]
assert "create_collection" in infos["tools"]