Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
440cfdcc31 | ||
|
|
0ca92c3723 | ||
|
|
f434f30286 | ||
|
|
f4b8935189 | ||
|
|
014df322e7 | ||
|
|
39f9cc16e4 | ||
|
|
b4b3032456 | ||
|
|
edef1ea708 | ||
|
|
7523670949 | ||
|
|
d81f328859 | ||
|
|
1a39d601de | ||
|
|
68d3dd85a9 |
+129
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.71.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.76.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,134 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.76.1] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark : bouton « Console ↗ » (#221, Fusion)** — la
|
||||
barre des discussions du panneau Spark (asset vendangé,
|
||||
octet-identique dans les 4 apps) gagne un bouton « Console ↗ »
|
||||
qui ouvre la console web du service d'agents
|
||||
(https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||
discussion actuellement affichée (`?disc=<id>`, URL construite
|
||||
au clic) et avec l'app d'origine (`?app=obsigate`, déduite du
|
||||
sous-domaine `og`). Même livraison dans TikTak 0.59.1,
|
||||
Flowdeck 7.80.1 et Kanbante 0.28.1. Redéploiement Docker
|
||||
requis pour l'activer côté ObsiGate.
|
||||
|
||||
---
|
||||
|
||||
## [2.76.0] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Émission d'événements vers le journal du service d'agents
|
||||
(#220, Fusion — Lot 2 vague 5)** — ObsiGate émet ses mutations de
|
||||
notes **explicites** au journal d'activité du service d'agents
|
||||
(contrat ACC-0.2) : `obsigate.note.created / edited / moved /
|
||||
deleted`, enveloppes signées HMAC-SHA256 (clé
|
||||
`AGENT_EVENEMENTS_CLE`), envoyées en arrière-plan sans jamais
|
||||
bloquer le métier, inactives tant que la clé n'est pas posée.
|
||||
Points d'accroche : les endpoints d'écriture de l'API web (acteur
|
||||
`user`) et la couche outils / serveur MCP (acteur `agent:mcp`).
|
||||
**Garde-fou : jamais le watcher** — les modifications faites hors
|
||||
API (Obsidian, éditeur externe) n'émettent rien. Domaine par vault
|
||||
via `AGENT_EVENEMENTS_DOMAINES`, repli `pro` (Q-A ouverte).
|
||||
Redéploiement Docker requis, puis pose de la clé des deux côtés
|
||||
pour activer.
|
||||
|
||||
---
|
||||
|
||||
## [2.75.0] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark v2 (#219, Fusion — Lot 1)** — les assets partagés
|
||||
`frontend/draco-panneau.js` / `frontend/draco-panneau.css` passent
|
||||
en v2, à l'identique dans les quatre applications : réponses de
|
||||
l'agent en **Markdown rendu** (HTML produit par le service
|
||||
d'agents 0.10.0, sanitizé côté serveur), **largeur réglable**
|
||||
persistée, **thème clair/sombre** aligné sur l'application hôte
|
||||
(suivi en direct de `html[data-theme]`), et **discussions façon
|
||||
Muse** — discussion principale perpétuelle et discussions
|
||||
parallèles créées, archivées, supprimées depuis le tiroir du
|
||||
panneau. Aucun code propre à ObsiGate n'est modifié ;
|
||||
redéploiement Docker requis.
|
||||
|
||||
---
|
||||
|
||||
## [2.74.0] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark intégré (#218, Fusion)** — le bouton Spark de la
|
||||
barre commune ouvre le panneau de l'agent commun (conversation,
|
||||
Aujourd'hui, approbations, missions longues), comme dans TikTak,
|
||||
Flowdeck et Kanbante : assets partagés `frontend/draco-panneau.js`
|
||||
et `frontend/draco-panneau.css` vendangés à l'identique, action
|
||||
`spark` branchée sur `DRACO_PANNEAU.toggle()`. La CSP nomme
|
||||
`https://agent.dracodev.net` en `connect-src` (seul hôte ajouté).
|
||||
Redéploiement Docker requis.
|
||||
|
||||
---
|
||||
|
||||
## [2.73.1] — 2026-10-21
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Barre commune v0.1.4 — palier mobile élargi de 700 à 900 px**
|
||||
(suite de BUG-118) : entre les deux seuils, 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 ; le resserré
|
||||
(app courante, alertes, Spark, avatar) s'applique maintenant
|
||||
dès 900 px, et le décalage de `.app-container` suit le même
|
||||
seuil. Le changement d'app reste assuré par le menu du ✦.
|
||||
|
||||
---
|
||||
|
||||
## [2.73.0] — 2026-10-21
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Menu des applications par la marque ✦** (barre commune
|
||||
**v0.1.3**, #215) : cliquer le ✦ — un peu plus gros (24 px) —
|
||||
ouvre le menu des 4 applications (liens, icônes, badges), sur
|
||||
mobile comme sur bureau. C'est le changement d'app en mobile,
|
||||
où les onglets sont repliés depuis la v0.1.2. Fermeture au
|
||||
clic extérieur ou à Échap. Choix de Bruno (2026-10-21).
|
||||
|
||||
---
|
||||
|
||||
## [2.72.1] — 2026-10-21
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-118 — barre d'apps Fusion en mobile** : la barre commune
|
||||
passe en **v0.1.2** (dracodev-commons) — au palier ≤ 700 px elle
|
||||
ne garde que la marque, l'onglet de l'app courante, les alertes,
|
||||
Spark et l'avatar (avant : onglets et pastilles domaines entiers,
|
||||
barre débordante sur téléphone). Le bouton ☰ de la barre pilote
|
||||
désormais le bon bouton selon la largeur : barre latérale bureau
|
||||
au-delà de 768 px, **vrai hamburger mobile** (tiroir) en dessous —
|
||||
avant, il cliquait le bouton bureau et ne faisait rien sur
|
||||
téléphone, donnant l'impression d'un hamburger mort à côté de
|
||||
celui d'ObsiGate. Test : `tests/test_draco_barre.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.72.0] — 2026-10-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Fusion ACC-3.2 (#217) — validation locale du jeton
|
||||
`draco_session`** : `get_current_user` accepte le cookie partagé à
|
||||
défaut de jeton natif valide ; le `sub` étant l'identifiant
|
||||
ObsiGate, la correspondance vers le compte local est directe
|
||||
(compte actif et garde-fou « changement de mot de passe » exigés).
|
||||
Fiche : `docs/features/fusion-acc32-validation-locale-217.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.71.0] — 2026-10-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.71.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.76.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.71.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.76.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.71.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.76.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.71.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.76.1 | Last updated: September 2026*
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Émission d'événements vers le journal du service d'agents
|
||||
(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220).
|
||||
|
||||
ObsiGate émet ses mutations de notes **explicites** vers
|
||||
``POST /api/v1/evenements`` du service d'agents, qui les expose dans
|
||||
le journal de sa console. L'enveloppe ne contient qu'une référence,
|
||||
un libellé et un lien — jamais le contenu de la note :
|
||||
``{id, type, occurred_at, actor, domain, source_app,
|
||||
object{ref, label, url}}``.
|
||||
|
||||
**Garde-fou (conception validée)** : ce module n'est appelé que
|
||||
depuis les points d'écriture explicites de l'API web —
|
||||
:mod:`backend.routers.files_write` (session utilisateur, acteur
|
||||
``user``) et la couche outils :mod:`backend.tools.service`
|
||||
(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré,
|
||||
acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et
|
||||
l'indexeur ne l'appellent **jamais** : le vault principal compte
|
||||
plus de 220 000 fichiers, un branchement sur les événements du
|
||||
système de fichiers inonderait le journal. Une modification faite
|
||||
hors API (éditeur externe, synchro disque…) reste donc invisible
|
||||
du journal — c'est voulu.
|
||||
|
||||
Authentification : signature HMAC-SHA256 du corps brut, en-tête
|
||||
``X-Draco-Signature``, clé propre à ObsiGate
|
||||
(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est
|
||||
**inactif** : aucun appel, aucun bruit. URL surchargée par
|
||||
``AGENT_EVENEMENTS_URL``.
|
||||
|
||||
Discipline « au mieux » (même esprit que TikTak et Kanbante) :
|
||||
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
|
||||
et toute erreur est avalée avec une ligne de journal — une panne
|
||||
du récepteur ne doit jamais casser ni ralentir le métier.
|
||||
|
||||
Domaine : ObsiGate ne modélise pas encore le domaine d'un vault
|
||||
(question Q-A du programme Fusion toujours ouverte). La
|
||||
correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES``
|
||||
(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi
|
||||
le défaut du journal du service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("obsigate.agent_journal")
|
||||
|
||||
SOURCE_APP = "obsigate"
|
||||
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
|
||||
DELAI_S = 2.0
|
||||
|
||||
TYPE_CREE = "obsigate.note.created"
|
||||
TYPE_EDITE = "obsigate.note.edited"
|
||||
TYPE_DEPLACE = "obsigate.note.moved"
|
||||
TYPE_SUPPRIME = "obsigate.note.deleted"
|
||||
|
||||
ACTEUR_USER = "user"
|
||||
ACTEUR_MCP = "agent:mcp"
|
||||
|
||||
|
||||
def _cle() -> str:
|
||||
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
|
||||
|
||||
|
||||
def actif() -> bool:
|
||||
"""L'émission n'existe que si la clé d'ObsiGate est configurée."""
|
||||
return bool(_cle())
|
||||
|
||||
|
||||
def domaine_du_vault(vault: str) -> str:
|
||||
"""Domaine journal du vault : correspondance déclarée dans
|
||||
``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``."""
|
||||
brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip()
|
||||
if brut:
|
||||
try:
|
||||
correspondance = json.loads(brut)
|
||||
except ValueError:
|
||||
correspondance = {}
|
||||
if isinstance(correspondance, dict):
|
||||
domaine = correspondance.get(vault)
|
||||
if isinstance(domaine, str) and domaine:
|
||||
return domaine
|
||||
return "pro"
|
||||
|
||||
|
||||
def lien_fichier(vault: str, chemin: str) -> str:
|
||||
"""Lien profond ObsiGate vers un fichier (format ``#file=`` lu
|
||||
par le frontend : ``#file=<vault>:<chemin>``, décodé en URI)."""
|
||||
return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}"
|
||||
|
||||
|
||||
def construire_enveloppe(
|
||||
type_: str,
|
||||
vault: str,
|
||||
chemin: str,
|
||||
actor: str = ACTEUR_USER,
|
||||
) -> tuple[dict, bytes]:
|
||||
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
|
||||
signé puis envoyé — la signature porte sur ces octets-là).
|
||||
``chemin`` est le chemin relatif au vault ; pour un déplacement,
|
||||
c'est le **nouveau** chemin (la référence pointe sur l'objet tel
|
||||
qu'il existe après l'opération)."""
|
||||
env = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"type": type_,
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
"actor": actor or ACTEUR_USER,
|
||||
"domain": domaine_du_vault(vault),
|
||||
"source_app": SOURCE_APP,
|
||||
"object": {
|
||||
"ref": f"fichier:{vault}/{chemin}",
|
||||
"label": chemin.rsplit("/", 1)[-1] if chemin else vault,
|
||||
"url": lien_fichier(vault, chemin),
|
||||
},
|
||||
}
|
||||
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
||||
return env, corps
|
||||
|
||||
|
||||
def _signer(corps: bytes) -> str:
|
||||
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _envoyer(corps: bytes) -> None:
|
||||
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
|
||||
journal distant est un confort, jamais une dépendance."""
|
||||
try:
|
||||
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
|
||||
client.post(
|
||||
_url(),
|
||||
content=corps,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Draco-Signature": _signer(corps),
|
||||
},
|
||||
)
|
||||
except Exception as exc: # panne, délai, DNS… : on log et on passe
|
||||
logger.warning("événement non livré au journal du service (%s)", exc)
|
||||
|
||||
|
||||
def emettre(
|
||||
type_: str,
|
||||
vault: str,
|
||||
chemin: str,
|
||||
actor: str = ACTEUR_USER,
|
||||
) -> bool:
|
||||
"""Émet un événement en tâche de fond. Retourne True si l'envoi
|
||||
a été déclenché, False si le module est inactif (pas de clé) ou
|
||||
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
|
||||
if not actif():
|
||||
return False
|
||||
try:
|
||||
_env, corps = construire_enveloppe(type_, vault, chemin, actor)
|
||||
except Exception:
|
||||
logger.warning("événement %s non construit", type_, exc_info=True)
|
||||
return False
|
||||
threading.Thread(
|
||||
target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements"
|
||||
).start()
|
||||
return True
|
||||
@@ -9,6 +9,7 @@ import sys
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend import draco_session
|
||||
from backend.services.net import get_client_ip
|
||||
|
||||
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
|
||||
@@ -86,20 +87,22 @@ def get_current_user(
|
||||
token = request.cookies["access_token"]
|
||||
|
||||
if not token:
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
# BUG-027: access tokens revoked at logout must be rejected immediately.
|
||||
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
|
||||
# le jti draco et efface son cookie.)
|
||||
jti = payload.get("jti")
|
||||
if jti and is_token_revoked(jti):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
user = get_user(payload["sub"])
|
||||
if not user or not user.get("active"):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
# BUG-028: a password change invalidates every token issued before it.
|
||||
pca = user.get("password_changed_at")
|
||||
@@ -107,9 +110,9 @@ def get_current_user(
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
if int(iat) < int(float(pca)):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
# Attach vault permissions from the token (snapshot at login time)
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
@@ -127,6 +130,17 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def _draco_fallback(request: Request) -> dict | None:
|
||||
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
|
||||
partagé draco_session authentifie l'utilisateur (sub = id
|
||||
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
|
||||
"""
|
||||
user = draco_session.user_for_draco(request)
|
||||
if user is not None:
|
||||
user["_request_ip"] = get_client_ip(request)
|
||||
return user
|
||||
|
||||
|
||||
def require_auth(current_user=Depends(get_current_user)):
|
||||
"""Dependency: require a valid authenticated user."""
|
||||
if not current_user:
|
||||
|
||||
@@ -175,6 +175,46 @@ def clear_cookie(response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/", **extra)
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
|
||||
|
||||
def user_for_draco(request) -> dict | None:
|
||||
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
|
||||
|
||||
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
|
||||
jeton EST son identifiant d'utilisateur — la correspondance est
|
||||
directe, par id. Le garde-fou « jeton antérieur au dernier
|
||||
changement de mot de passe » (BUG-028 côté natif) est appliqué
|
||||
à l'identique via le claim `iat`.
|
||||
"""
|
||||
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return None
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
from backend.auth.user_store import get_all_users
|
||||
|
||||
for candidate in get_all_users():
|
||||
if str(candidate.get("id")) != sub:
|
||||
continue
|
||||
if not candidate.get("active"):
|
||||
return None
|
||||
pca = candidate.get("password_changed_at")
|
||||
iat = claims.get("iat")
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
if int(iat) < int(float(pca)):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
user = dict(candidate)
|
||||
user["_token_vaults"] = candidate.get("vaults", [])
|
||||
user["_token_scope"] = "full"
|
||||
user["_draco_sub"] = sub
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
# ── Renouvellement glissant (middleware) ────────────────────────────────
|
||||
|
||||
class DracoRenewalMiddleware:
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:; "
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend import agent_journal
|
||||
from backend.audit import log_file_delete, log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import (
|
||||
@@ -133,6 +134,9 @@ async def api_file_save(
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||
|
||||
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@@ -201,6 +205,7 @@ def api_file_xlsx_save(
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
@@ -250,6 +255,7 @@ def api_file_csv_save(
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
@@ -313,6 +319,7 @@ def api_file_xlsx_structure(
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
@@ -381,6 +388,7 @@ def api_file_xlsx_style(
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
@@ -405,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
||||
|
||||
result = service_delete_file(vault_name, path)
|
||||
|
||||
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||
agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"])
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
@@ -577,6 +588,9 @@ async def api_file_create(
|
||||
|
||||
result = service_create_file(vault_name, body.path, body.content)
|
||||
|
||||
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||
agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"])
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
@@ -686,6 +700,9 @@ async def api_file_rename(
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin.
|
||||
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||
|
||||
# Update index
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
@@ -731,6 +748,11 @@ async def api_file_move(
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
# Journal du service d'agents (#220) — fichiers seulement (le catalogue
|
||||
# est note.*) ; les déplacements de dossiers ne sont pas émis.
|
||||
if item_type == "file":
|
||||
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||
|
||||
# Update index
|
||||
if item_type == "directory":
|
||||
from backend.indexer import reload_single_vault
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from backend import agent_journal
|
||||
from backend.indexer import get_backlinks as _get_backlinks
|
||||
from backend.indexer import get_vault_names
|
||||
from backend.services.backups import diff_backup as _diff_backup
|
||||
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
|
||||
from backend.services.search import list_tags as _list_tags
|
||||
from backend.services.search import search_paths as _search_paths
|
||||
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
||||
from backend.tools.context import ToolContext, ToolRisk
|
||||
from backend.tools.context import ToolContext, ToolMode, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AppendToFileInput,
|
||||
@@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
||||
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _acteur_journal(ctx: ToolContext) -> str:
|
||||
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
|
||||
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
|
||||
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_file",
|
||||
description=(
|
||||
@@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
||||
)
|
||||
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
||||
"""Create a vault file (fails if it already exists)."""
|
||||
return _create_file(params.vault, params.path, params.content)
|
||||
result = _create_file(params.vault, params.path, params.content)
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
|
||||
actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
|
||||
)
|
||||
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||
"""Replace a vault file's content."""
|
||||
return _edit_file(params.vault, params.path, params.content)
|
||||
result = _edit_file(params.vault, params.path, params.content)
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
|
||||
actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||
)
|
||||
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
||||
"""Append content to a vault file."""
|
||||
return _append_to_file(params.vault, params.path, params.content)
|
||||
result = _append_to_file(params.vault, params.path, params.content)
|
||||
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
|
||||
)
|
||||
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
||||
"""Rename a vault file."""
|
||||
return _rename_file(params.vault, params.path, params.new_name)
|
||||
result = _rename_file(params.vault, params.path, params.new_name)
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_DEPLACE, params.vault,
|
||||
result.get("new_path", params.path), actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
|
||||
)
|
||||
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
||||
"""Move a vault file or directory."""
|
||||
return _move_path(params.vault, params.source_path, params.destination_dir)
|
||||
result = _move_path(params.vault, params.source_path, params.destination_dir)
|
||||
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
|
||||
if result.get("item_type") == "file":
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_DEPLACE, params.vault,
|
||||
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
|
||||
)
|
||||
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
||||
"""Delete a vault file."""
|
||||
return _delete_file(params.vault, params.path)
|
||||
result = _delete_file(params.vault, params.path)
|
||||
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.71.0"
|
||||
version = "2.76.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.71.0"
|
||||
version = "2.76.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.71.0",
|
||||
"version": "2.76.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -51,8 +51,6 @@ services:
|
||||
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
|
||||
- VAULT_5_NAME=Sessions
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
|
||||
@@ -354,6 +354,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
|
||||
+93
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.71.0 | **Dernière mise à jour :** 2026-10-20
|
||||
> **Version :** 2.76.1 | **Dernière mise à jour :** 2026-10-22
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -30,6 +30,82 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
|
||||
|
||||
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** 🔵 en cours
|
||||
- **Fiche :** [features/fusion-panneau-console-221.md](./features/fusion-panneau-console-221.md)
|
||||
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||
octet-identique dans les 4 apps) gagne un bouton « Console ↗ » dans sa barre
|
||||
des discussions (demande de Bruno, 2026-10-22) : il ouvre la console web du
|
||||
service d'agents (https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||
discussion actuellement affichée (`?disc=<id>`, URL construite au clic) et
|
||||
avec l'app d'origine (`?app=obsigate`, déduite du sous-domaine `og`). La
|
||||
console consomme déjà ces deux paramètres (lien profond de discussion +
|
||||
puce « Ouvert depuis »). Même livraison : TikTak 0.59.1, Flowdeck 7.80.1,
|
||||
Kanbante 0.28.1.
|
||||
- **Sous-tâches :**
|
||||
- [x] Bouton + `ouvrirConsole()` / `appHote()` dans le panneau canonique
|
||||
(TikTak), recopié octet-identique dans les 4 apps
|
||||
- [x] Test `tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||
- [ ] CI verte + redéploiement Docker (geste de Bruno)
|
||||
|
||||
### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-journal-evenements-220.md](./features/fusion-journal-evenements-220.md)
|
||||
- **Description :** ObsiGate émet ses mutations de notes **explicites**
|
||||
vers le journal d'activité du service d'agents (contrat ACC-0.2,
|
||||
carte fusion-trois-apps #18) : `obsigate.note.created / edited /
|
||||
moved / deleted`, signées HMAC, en arrière-plan, inactives sans la
|
||||
clé `AGENT_EVENEMENTS_CLE`. Points d'accroche : les endpoints
|
||||
d'écriture de l'API web (acteur `user`) et la couche outils /
|
||||
serveur MCP (acteur `agent:mcp`). **Garde-fou : jamais le
|
||||
watcher** — seules les opérations explicites via l'API émettent.
|
||||
- **Sous-tâches :**
|
||||
- [x] Module `backend/agent_journal.py` (enveloppe ACC-0.2,
|
||||
signature HMAC-SHA256, envoi « au mieux »)
|
||||
- [x] Branchements `backend/routers/files_write.py` (créer,
|
||||
sauvegarder, renommer, déplacer, supprimer)
|
||||
- [x] Branchements `backend/tools/service.py` (create/edit/
|
||||
append/rename/move/delete, acteur selon le mode)
|
||||
- [x] Tests `tests/test_agent_journal.py` — dont la preuve du
|
||||
garde-fou watcher (écriture disque + indexation = silence)
|
||||
|
||||
### 218. Fusion — panneau Spark intégré (agent commun)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-panneau-spark-218.md](./features/fusion-panneau-spark-218.md)
|
||||
- **Description :** le bouton Spark de la barre commune (#215) ouvre le
|
||||
panneau de l'agent commun (service d'agents dracodev : conversation,
|
||||
Aujourd'hui, approbations, missions longues), comme dans TikTak,
|
||||
Flowdeck et Kanbante. Assets partagés vendangés à l'identique ;
|
||||
la CSP nomme le service en `connect-src`.
|
||||
- **Sous-tâches :**
|
||||
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||
vendangés depuis le panneau partagé du programme
|
||||
- [x] `frontend/index.html` : link CSS + script, action `spark` →
|
||||
`DRACO_PANNEAU.toggle()` (repli honnête si le panneau ne charge pas)
|
||||
- [x] CSP `connect-src` : `https://agent.dracodev.net` ajouté
|
||||
(`backend/main.py`, seul hôte ajouté)
|
||||
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
|
||||
|
||||
### 219. Fusion — panneau Spark v2 (Lot 1)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.75.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-panneau-spark-v2-219.md](./features/fusion-panneau-spark-v2-219.md)
|
||||
- **Description :** le panneau Spark (#218) passe en v2, à l'identique
|
||||
dans les quatre applications (carte fusion-trois-apps #17) :
|
||||
réponses de l'agent en **Markdown rendu** (HTML produit par le
|
||||
service d'agents 0.10.0, sanitizé côté serveur), **largeur
|
||||
réglable** persistée, **thème clair/sombre aligné** sur
|
||||
l'application hôte, **discussions façon Muse** (principale
|
||||
perpétuelle + parallèles créées/archivées/supprimées). Côté
|
||||
ObsiGate : assets vendangés uniquement, aucun code propre touché.
|
||||
- **Sous-tâches :**
|
||||
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||
remplacés par la v2 partagée du programme
|
||||
- [x] Fiche de conception `features/fusion-panneau-spark-v2-219.md`
|
||||
|
||||
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
|
||||
|
||||
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
|
||||
@@ -79,6 +155,22 @@
|
||||
re-validé côté frontend (`auth.js`)
|
||||
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
|
||||
|
||||
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
|
||||
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
|
||||
- **Description :** pendant consommateur de #216 — `get_current_user`
|
||||
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
|
||||
étant l'identifiant ObsiGate, la correspondance vers le compte local
|
||||
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
|
||||
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
|
||||
- **Sous-tâches :**
|
||||
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
|
||||
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
|
||||
authentifié » du chemin natif (`backend/auth/middleware.py`)
|
||||
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
|
||||
sub inconnu, inerte sans secret)
|
||||
|
||||
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
|
||||
|
||||
Statut : ✅ livré le 2026-10-21 (2.72.0)
|
||||
|
||||
## Contexte
|
||||
|
||||
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
|
||||
de **consommatrice** : `get_current_user` accepte le cookie partagé
|
||||
`draco_session` à défaut de jeton natif valide. C'est la brique qui
|
||||
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
|
||||
requêtes SSO. Les validateurs des autres applications sont leurs
|
||||
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
|
||||
|
||||
## Correspondance sub → compte local
|
||||
|
||||
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
|
||||
son identifiant d'utilisateur — la résolution est directe, par `id`,
|
||||
dans `draco_session.user_for_draco()` :
|
||||
|
||||
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
|
||||
- compte **actif** exigé ;
|
||||
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
|
||||
avant le dernier changement de mot de passe (`password_changed_at`)
|
||||
est refusé ;
|
||||
- le dict utilisateur retourné porte `_token_vaults` (droits du
|
||||
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
|
||||
|
||||
## Branchement
|
||||
|
||||
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
|
||||
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
|
||||
compte introuvable/inactif, jeton antérieur au changement de mot de
|
||||
passe) passe par `_draco_fallback()`. Sûreté du repli après
|
||||
déconnexion globale : le logout révoque aussi le `jti` draco et
|
||||
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
|
||||
`verify_token`.
|
||||
|
||||
## Limite connue
|
||||
|
||||
Le repli authentifie les **appels d'API**. Le bootstrap d'une
|
||||
session complète du SPA (jetons natifs émis à partir du seul cookie
|
||||
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
|
||||
native EST la connexion SSO — le cas d'usage du repli est la
|
||||
continuité d'API quand le jeton natif a expiré avant le cookie
|
||||
partagé, et les consommateurs machine de l'API.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
|
||||
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
|
||||
refusé, inactivité sans secret configuré.
|
||||
@@ -0,0 +1,98 @@
|
||||
# #220 — Fusion : émission d'événements vers le journal du service d'agents
|
||||
|
||||
**Contexte.** Le Lot 2 du programme Fusion (carte fusion-trois-apps
|
||||
#18) dote le service d'agents d'un **journal d'activité** cross-apps :
|
||||
chaque application y émet ses mutations significatives au contrat
|
||||
**ACC-0.2** — enveloppe `{id, type, occurred_at, actor, domain,
|
||||
source_app, object{ref, label, url}}` signée **HMAC-SHA256** du corps
|
||||
brut (en-tête `X-Draco-Signature`, clé propre à chaque application),
|
||||
que le service expose dans la vue Journal de sa console (agent-commun
|
||||
0.11.0). TikTak (0.59.0) et Kanbante (0.28.0) émettent déjà ; ObsiGate
|
||||
est la troisième source, après Flowdeck.
|
||||
|
||||
**Principe directeur : référence, jamais contenu.** L'enveloppe ne
|
||||
contient que la référence de la note (`fichier:<vault>/<chemin>`),
|
||||
son nom (libellé) et un lien profond (`/#file=<vault>:<chemin>`) —
|
||||
jamais son contenu.
|
||||
|
||||
## Garde-fou central : jamais le watcher
|
||||
|
||||
ObsiGate surveille les vaults par un **watcher** de système de
|
||||
fichiers (`backend/watcher.py`) qui réindexe chaque changement
|
||||
disque — le vault principal compte **222 393 fichiers**. Brancher
|
||||
l'émission sur le watcher inonderait le journal (et confondrait les
|
||||
gestes d'Obsidian, de la synchro de fichiers ou d'un éditeur externe
|
||||
avec des opérations faites *dans* ObsiGate).
|
||||
|
||||
L'émission n'a donc lieu que pour les **opérations explicites via
|
||||
l'API web**, à deux points d'accroche, et nulle part ailleurs :
|
||||
|
||||
| Source | Point d'accroche | Acteur |
|
||||
|---|---|---|
|
||||
| Interface web / clients REST | `backend/routers/files_write.py` | `user` |
|
||||
| Serveur MCP d'ObsiGate (outils) | `backend/tools/service.py`, mode `mcp` | `agent:mcp` |
|
||||
| Assistant intégré (outils, mode `in_app`) | `backend/tools/service.py` | `user` |
|
||||
|
||||
Conséquences assumées :
|
||||
|
||||
- une note créée/modifiée **hors API** (Obsidian, éditeur externe,
|
||||
copie de fichiers) n'apparaît **pas** au journal — c'est voulu ;
|
||||
- les écritures internes qui passent par la couche mutations sans
|
||||
passer par ces points (planificateur, crawler, fusion de doublons,
|
||||
restauration de sauvegarde) n'émettent pas non plus ;
|
||||
- le test `test_watcher_et_indexation_sans_evenement` verrouille le
|
||||
garde-fou : écriture disque directe + gestionnaires du watcher +
|
||||
`update_single_file` de l'indexeur → **zéro émission**.
|
||||
|
||||
## Catalogue d'événements émis
|
||||
|
||||
| Type | Déclencheur |
|
||||
|---|---|
|
||||
| `obsigate.note.created` | `POST /api/file/{vault}` ; outil `create_file` |
|
||||
| `obsigate.note.edited` | `PUT /api/file/{vault}/save` (+ variantes xlsx/csv : cellules, structure, style) ; outils `edit_file`, `append_to_file` |
|
||||
| `obsigate.note.moved` | `PATCH /api/file/{vault}` (renommage), `POST /api/move/{vault}` (**fichiers seulement**) ; outils `rename_file`, `move_path` (fichiers seulement) — la référence pointe sur le **nouveau** chemin |
|
||||
| `obsigate.note.deleted` | `DELETE /api/file/{vault}` ; outil `delete_file` |
|
||||
|
||||
Hors catalogue (non émis, volontairement) : dossiers (création,
|
||||
renommage, déplacement, suppression — le catalogue est `note.*`),
|
||||
téléversement par lots (`batch-upload`), duplication, restauration
|
||||
de sauvegarde, `replace_in_files` (multi-fichiers).
|
||||
|
||||
## Module `backend/agent_journal.py`
|
||||
|
||||
- **Activation** : `AGENT_EVENEMENTS_CLE` (clé propre à ObsiGate,
|
||||
jamais en dur). Sans clé, le module est **inactif** : aucun appel,
|
||||
aucun bruit. URL surchargée par `AGENT_EVENEMENTS_URL` (défaut
|
||||
`https://agent.dracodev.net/api/v1/evenements`).
|
||||
- **Discipline « au mieux »** (identique à TikTak/Kanbante) : envoi
|
||||
dans un fil d'arrière-plan démon via httpx, délai maximal 2 s,
|
||||
exceptions avalées avec une ligne de journal local — une panne du
|
||||
service d'agents ne casse ni ne ralentit jamais le métier (prouvé
|
||||
par `test_recepteur_en_panne_ne_casse_rien`).
|
||||
- **Domaine** : ObsiGate ne modélise pas encore le domaine d'un
|
||||
vault (la question Q-A du programme — les domaines sont-ils des
|
||||
vaults distincts ? — est toujours ouverte). La correspondance peut
|
||||
être déclarée par `AGENT_EVENEMENTS_DOMAINES` (JSON
|
||||
`{"NomDuVault": "famille"}`) ; repli `pro`, qui est aussi le
|
||||
défaut du journal du service.
|
||||
|
||||
## Activation en production (gestes de Bruno)
|
||||
|
||||
1. Générer une clé propre à ObsiGate (`openssl rand -hex 32`).
|
||||
2. Service d'agents : ajouter l'entrée `obsigate` à
|
||||
`AGENT_EVENEMENTS_CLES` (JSON des clés par application), recréer
|
||||
le conteneur.
|
||||
3. ObsiGate : poser `AGENT_EVENEMENTS_CLE=<même valeur>` dans son
|
||||
environnement et **redéployer** (Docker).
|
||||
|
||||
Tant que la clé n'est pas posée des deux côtés, ObsiGate n'émet
|
||||
rien — c'est le comportement voulu.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_agent_journal.py` (7 tests) : enveloppe et signature
|
||||
revérifiées comme le fait le service ; cycle de vie complet en REST
|
||||
(créer → éditer → renommer → déplacer → supprimer, acteur `user`) ;
|
||||
chemin outils/MCP (`create/edit/rename/delete`, acteur
|
||||
`agent:mcp`) ; inactivité sans clé ; garde-fou watcher/indexation ;
|
||||
panne du récepteur sans effet métier ; correspondance de domaines.
|
||||
@@ -0,0 +1,42 @@
|
||||
# #221 — Fusion : bouton « Console ↗ » dans le panneau Spark
|
||||
|
||||
**Contexte.** Le panneau Spark (#218) et la console web du service
|
||||
d'agents (Lot 2, https://agent.dracodev.net/) sont deux faces du même
|
||||
service : mêmes discussions, mêmes approbations, mêmes missions. Il
|
||||
manquait le lien de navigation entre les deux. Demande de Bruno
|
||||
(2026-10-22) : un bouton dans le panneau qui ouvre la console.
|
||||
|
||||
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||
|
||||
- un bouton fantôme « Console ↗ » dans la barre des discussions, à
|
||||
côté de « ☰ Discussions » et « + Nouvelle » (même classe `d-btn`,
|
||||
donc visible en thème clair comme sombre, le panneau suivant le
|
||||
thème de l'app hôte) ;
|
||||
- au clic, `ouvrirConsole()` construit l'URL **à ce moment-là** (la
|
||||
discussion peut avoir changé sans recharger le panneau) :
|
||||
`https://agent.dracodev.net/?disc=<id de la discussion affichée>&app=<app hôte>` ;
|
||||
- la discussion vient de l'état du panneau (`convId`, repli
|
||||
`sessionStorage draco_panneau_conv`) ; l'app hôte est déduite du
|
||||
sous-domaine par `appHote()` (`og` → `obsigate`, `tiktak`,
|
||||
`flowdeck`, `kanbante` ; hôte inconnu → pas de paramètre) ;
|
||||
- ouverture en **nouvel onglet** (`window.open(..., "_blank",
|
||||
"noopener")`). Sans discussion connue : lien simple vers la
|
||||
racine de la console.
|
||||
|
||||
La console consomme déjà les deux paramètres : `?disc=` ouvre la
|
||||
discussion demandée (lien profond), `?app=` s'affiche en puce
|
||||
« Ouvert depuis : … » dans « Ce que Spark voit ».
|
||||
|
||||
**Vérifications.** Syntaxe JS (`node --check`), test fonctionnel de
|
||||
la construction d'URL (4 cas : app connue/inconnue × discussion
|
||||
présente/absente), test de non-régression
|
||||
`tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||
(marqueurs du bouton et de la logique dans l'asset servi), somme
|
||||
md5 commune des 4 copies : `c67cc2ab8e657b4e545fa93b28c3a0c4`.
|
||||
|
||||
**Versions.** ObsiGate 2.76.1 (cette livraison) ; TikTak 0.59.1 et
|
||||
Flowdeck 7.80.1 déployées automatiquement sur release ; Kanbante
|
||||
0.28.1 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||
par Bruno.**
|
||||
@@ -0,0 +1,38 @@
|
||||
# #218 — Fusion : panneau Spark intégré (agent commun)
|
||||
|
||||
**Contexte.** Le programme Fusion a livré le service d'agents commun
|
||||
(agent-commun : conversation LLM, lectures/écritures des quatre
|
||||
applications via approbations, sous-agents, missions longues). Trois
|
||||
applications affichaient déjà son panneau partagé (TikTak, Flowdeck,
|
||||
Kanbante) ; ObsiGate, pourtant émettrice de l'identité `draco_session`,
|
||||
gardait le message d'attente « arrive en phase 4 » sur le bouton Spark
|
||||
de sa barre commune (#215).
|
||||
|
||||
**Livraison.**
|
||||
|
||||
- Assets vendangés à l'identique des autres applications :
|
||||
`frontend/draco-panneau.js` et `frontend/draco-panneau.css`, à la
|
||||
racine de `frontend/` comme les autres assets draco (servis par le
|
||||
montage `/static`, `Cache-Control: no-cache` déjà en place — aucun
|
||||
mécanisme de versionnage à ajouter, `index.html` n'est pas un
|
||||
gabarit Jinja).
|
||||
- `frontend/index.html` : lien CSS après celui de la barre, script
|
||||
différé après `draco-liens.js`, action `spark` de
|
||||
`DRACO_BAR_CONFIG.onAction` branchée sur
|
||||
`window.DRACO_PANNEAU.toggle()` avec repli explicite si l'asset ne
|
||||
charge pas.
|
||||
- CSP (`backend/main.py`, `SecurityHeadersMiddleware`) :
|
||||
`https://agent.dracodev.net` ajouté à `connect-src` — le panneau
|
||||
appelle le service en `fetch` avec le cookie de session ; aucun
|
||||
autre hôte n'est ouvert (précédent : Flowdeck, même exception).
|
||||
|
||||
**Vérifications.** Tests `tests/test_draco_barre.py` étendus
|
||||
(présence et références des assets, branche spark, hôte CSP) ;
|
||||
vérification visuelle et E2E à confirmer après redéploiement Docker
|
||||
(le panneau, `z-index: 400`, cohabite avec des surfaces ObsiGate à
|
||||
z-index plus élevé — à observer en réel).
|
||||
|
||||
**Hors périmètre.** Les textes du panneau vivent dans l'asset partagé
|
||||
du programme (français, identique dans les quatre applications) ; ils
|
||||
ne passent pas par les locales FR/EN d'ObsiGate — écart assumé au
|
||||
niveau du programme, pas de cette fiche.
|
||||
@@ -0,0 +1,29 @@
|
||||
# #219 — Fusion : panneau Spark v2 (Lot 1)
|
||||
|
||||
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
|
||||
réponses de l'agent en texte brut (les `##` et `**` du Markdown
|
||||
restaient visibles), avait une largeur fixe, un thème clair codé en
|
||||
dur déconnecté de l'application hôte, et une seule conversation sans
|
||||
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
|
||||
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
|
||||
l'identique dans les quatre applications du programme.
|
||||
|
||||
**Livraison (côté ObsiGate : assets vendangés uniquement).**
|
||||
|
||||
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||
remplacés par la v2 partagée (octet pour octet avec TikTak,
|
||||
Flowdeck et Kanbante) :
|
||||
- **Markdown rendu** : les réponses et les rapports de missions
|
||||
affichent le HTML produit par le service d'agents (0.10.0 :
|
||||
mistune + sanitizer côté serveur) ;
|
||||
- **largeur réglable** à la souris (poignée, persistée,
|
||||
double-clic = largeur par défaut) ;
|
||||
- **thème clair/sombre aligné** sur l'application hôte
|
||||
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
|
||||
- **discussions façon Muse** : discussion principale
|
||||
perpétuelle + discussions parallèles créées, archivées et
|
||||
supprimées depuis le tiroir du panneau.
|
||||
- Le travail de fond est dans le service d'agents (agent-commun
|
||||
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
|
||||
déjà servi en `Cache-Control: no-cache` : aucun versionnage
|
||||
d'asset à ajouter. Redéploiement Docker requis.
|
||||
@@ -1,4 +1,8 @@
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
|
||||
/* 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) ;
|
||||
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. */
|
||||
@@ -7,6 +11,7 @@
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
align-items: center; gap: 16px; padding: 0 12px;
|
||||
@@ -24,9 +29,29 @@
|
||||
.draco-bar-centre { justify-content: center; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
|
||||
/* Marque */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
|
||||
.draco-brand-mark { color: var(--agent); }
|
||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
|
||||
min-width: 248px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
.draco-apps-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
color: var(--ink); text-decoration: none;
|
||||
}
|
||||
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
|
||||
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
|
||||
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
|
||||
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
|
||||
.draco-apptabs { display: flex; gap: 6px; }
|
||||
@@ -119,9 +144,23 @@
|
||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
|
||||
en barre basse propre à chaque app (patron du prototype v5+). */
|
||||
.draco-apptabs, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { height: 52px; }
|
||||
@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.
|
||||
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; }
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||
}
|
||||
|
||||
+79
-7
@@ -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);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||
partagent donc toujours le même mode. */
|
||||
.draco-panneau {
|
||||
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||
background: var(--p-fond); color: var(--p-texte); 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[data-draco-theme="dark"] {
|
||||
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||
--p-lien: #b39df5;
|
||||
}
|
||||
.draco-panneau.open { transform: translateX(0); }
|
||||
.draco-poignee {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||
}
|
||||
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||
.draco-panneau-hd {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||
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; }
|
||||
/* Barre des discussions (façon Muse) */
|
||||
.draco-disc-bar {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-disc-bar .d-titre {
|
||||
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-disc-bar .d-btn {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||
}
|
||||
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-discussions {
|
||||
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||
}
|
||||
.draco-discussions .d-section {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||
}
|
||||
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||
.draco-discussions .d-item {
|
||||
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||
.draco-discussions .d-actions button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||
}
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||
.draco-appro {
|
||||
align-self: stretch; background: var(--p-appro-fond); 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-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||
.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: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); 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: var(--p-doux); 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: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: var(--p-accent); 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: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.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: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid var(--p-bord); 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: var(--p-doux); 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: var(--p-doux); }
|
||||
.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: var(--p-danger-fond); color: var(--p-danger); }
|
||||
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||
.draco-msg.md code, .m-rapport.md code {
|
||||
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
.draco-msg.md pre, .m-rapport.md pre {
|
||||
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||
overflow-x: auto; margin: 7px 0;
|
||||
}
|
||||
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||
color: var(--p-doux);
|
||||
}
|
||||
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||
background: var(--p-chip);
|
||||
}
|
||||
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||
.draco-msg.md table, .m-rapport.md table {
|
||||
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||
display: block; overflow-x: auto; max-width: 100%;
|
||||
}
|
||||
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||
}
|
||||
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||
@@ -0,0 +1,702 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||
*
|
||||
* 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 ;
|
||||
* la réponse arrive avec son HTML
|
||||
* Markdown rendu côté service (v2)
|
||||
* GET /api/v1/conversations — discussions façon Muse (v2) : une
|
||||
* principale perpétuelle + des
|
||||
* parallèles créées/archivées/
|
||||
* supprimées par l'utilisateur
|
||||
* GET /api/v1/conversations/… — historique d'une discussion (les
|
||||
* messages agent portent leur html)
|
||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||
* externes J4 : état de configuration)
|
||||
* POST /api/v1/missions — missions longues (J5)
|
||||
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||
* Bouton « Console ↗ » (barre des discussions) : ouvre la console web
|
||||
* du même service (agent.dracodev.net) dans un nouvel onglet, sur la
|
||||
* discussion courante (?disc=) et avec l'app d'origine (?app=),
|
||||
* déduite du sous-domaine de l'hôte.
|
||||
* É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;
|
||||
var convKind = null; // 'principale' | 'parallele'
|
||||
var convTitre = "";
|
||||
var etatConnu = false;
|
||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||
var titreEl = null, tiroirEl = null;
|
||||
|
||||
function domaine() {
|
||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||
}
|
||||
function stockerConv() {
|
||||
try {
|
||||
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
|
||||
else sessionStorage.removeItem("draco_panneau_conv");
|
||||
} catch (e) { /* stockage indisponible */ }
|
||||
}
|
||||
function convStockee() {
|
||||
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
/* ── Console web (même service, nouvel onglet) ─────────────── */
|
||||
function appHote() {
|
||||
var h = (window.location.hostname || "").split(".")[0];
|
||||
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||
kanbante: "kanbante", og: "obsigate" };
|
||||
return correspondance[h] || null;
|
||||
}
|
||||
function ouvrirConsole() {
|
||||
var params = [];
|
||||
var id = convId || convStockee();
|
||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||
var app = appHote();
|
||||
if (app) params.push("app=" + encodeURIComponent(app));
|
||||
window.open(API + "/" + (params.length ? "?" + params.join("&") : ""),
|
||||
"_blank", "noopener");
|
||||
}
|
||||
|
||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||
function themeHote() {
|
||||
var t = document.documentElement.getAttribute("data-theme");
|
||||
if (t === "dark" || t === "light") return t;
|
||||
if (document.documentElement.classList.contains("dark") ||
|
||||
document.body.classList.contains("dark")) return "dark";
|
||||
try {
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
} catch (e) { /* pas de matchMedia */ }
|
||||
return "light";
|
||||
}
|
||||
function appliquerTheme() {
|
||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||
}
|
||||
function surveillerTheme() {
|
||||
try {
|
||||
new MutationObserver(appliquerTheme).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||
window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", appliquerTheme);
|
||||
} catch (e) { /* surveillance facultative */ }
|
||||
}
|
||||
|
||||
/* ── Largeur réglable ──────────────────────────────────────── */
|
||||
function largeurStockee() {
|
||||
try {
|
||||
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
|
||||
return isNaN(v) ? null : v;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function appliquerLargeur() {
|
||||
if (!root) return;
|
||||
var v = largeurStockee();
|
||||
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
|
||||
}
|
||||
function brancherPoignee(poignee) {
|
||||
poignee.addEventListener("pointerdown", function (ev) {
|
||||
if (window.innerWidth <= 700) return; // mobile : pleine largeur
|
||||
ev.preventDefault();
|
||||
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
|
||||
function bouger(e2) {
|
||||
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
|
||||
root.style.width = w + "px";
|
||||
}
|
||||
function lacher(e2) {
|
||||
document.removeEventListener("pointermove", bouger);
|
||||
document.removeEventListener("pointerup", lacher);
|
||||
var w = Math.round(root.getBoundingClientRect().width);
|
||||
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
|
||||
}
|
||||
document.addEventListener("pointermove", bouger);
|
||||
document.addEventListener("pointerup", lacher);
|
||||
});
|
||||
poignee.addEventListener("dblclick", function () {
|
||||
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
|
||||
appliquerLargeur();
|
||||
});
|
||||
}
|
||||
|
||||
function construire() {
|
||||
root = document.createElement("aside");
|
||||
root.className = "draco-panneau";
|
||||
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||
root.innerHTML =
|
||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||
'<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="padding-top:8px;padding-bottom:8px">' +
|
||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||
'</div>' +
|
||||
'<div class="draco-disc-bar">' +
|
||||
' <span class="d-titre">Discussion principale</span>' +
|
||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) dans un nouvel onglet, sur cette discussion">Console ↗</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-discussions" hidden></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]");
|
||||
titreEl = root.querySelector(".d-titre");
|
||||
tiroirEl = root.querySelector(".draco-discussions");
|
||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
|
||||
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);
|
||||
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||
appliquerTheme();
|
||||
surveillerTheme();
|
||||
appliquerLargeur();
|
||||
window.addEventListener("resize", appliquerLargeur);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Message agent rendu en Markdown : le HTML vient du service
|
||||
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
|
||||
function ajouterMsgMd(texte, html) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "draco-msg agent md";
|
||||
if (html) d.innerHTML = html;
|
||||
else 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 majTitre() {
|
||||
titreEl.textContent = convTitre || "Discussion principale";
|
||||
titreEl.title = titreEl.textContent;
|
||||
}
|
||||
|
||||
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 { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Discussions (façon Muse) ────────────────────────────── */
|
||||
function chargerListeDiscussions() {
|
||||
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
|
||||
{ credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
|
||||
function demarrerDiscussions() {
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
var precedente = convStockee();
|
||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||
var active = trouvee || d.principale;
|
||||
if (!active) return;
|
||||
convId = active.id; convKind = active.kind; convTitre = active.title;
|
||||
stockerConv(); majTitre();
|
||||
chargerHistorique();
|
||||
})
|
||||
.catch(function () {
|
||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||
});
|
||||
}
|
||||
|
||||
function basculerDiscussion(c) {
|
||||
convId = c.id; convKind = c.kind; convTitre = c.title;
|
||||
stockerConv(); majTitre();
|
||||
tiroirEl.hidden = true;
|
||||
msgsEl.innerHTML = "";
|
||||
ajouterMsg("sys", (c.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
|
||||
chargerHistorique();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function nouvelleDiscussion() {
|
||||
fetch(API + "/api/v1/conversations", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain: domaine() })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (c) {
|
||||
basculerDiscussion(c);
|
||||
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
|
||||
}
|
||||
|
||||
function actionDiscussion(id, action) {
|
||||
var methode = action === "supprimer" ? "DELETE" : "POST";
|
||||
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
|
||||
return fetch(url, { method: methode, credentials: "include" })
|
||||
.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();
|
||||
});
|
||||
}
|
||||
|
||||
function ligneDiscussion(c, actions) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "d-meta";
|
||||
var quand = "";
|
||||
try {
|
||||
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
|
||||
{ day: "numeric", month: "short" });
|
||||
} catch (e) { /* date illisible */ }
|
||||
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
|
||||
ligne.appendChild(meta);
|
||||
var sous = document.createElement("div");
|
||||
sous.className = "d-sous";
|
||||
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
|
||||
(c.kind === "principale" ? " · perpétuelle" : "");
|
||||
ligne.appendChild(sous);
|
||||
meta.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
sous.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
if (actions && actions.length) {
|
||||
var btns = document.createElement("div");
|
||||
btns.className = "d-actions";
|
||||
actions.forEach(function (a) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button"; b.textContent = a.label;
|
||||
if (a.danger) b.className = "danger";
|
||||
b.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (a.confirmer && !window.confirm(a.confirmer)) return;
|
||||
actionDiscussion(c.id, a.action)
|
||||
.then(function () {
|
||||
if (c.id === convId && a.action !== "desarchiver") {
|
||||
chargerListeDiscussions().then(function (d) {
|
||||
if (d.principale) {
|
||||
basculerDiscussion(d.principale);
|
||||
ajouterMsg("sys", a.action === "supprimer"
|
||||
? "Discussion supprimée — retour à la discussion principale."
|
||||
: "Discussion archivée — retour à la discussion principale.");
|
||||
}
|
||||
});
|
||||
}
|
||||
remplirTiroir();
|
||||
})
|
||||
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
|
||||
});
|
||||
btns.appendChild(b);
|
||||
});
|
||||
ligne.appendChild(btns);
|
||||
}
|
||||
return ligne;
|
||||
}
|
||||
|
||||
function remplirTiroir() {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
tiroirEl.innerHTML = "";
|
||||
var h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussion principale";
|
||||
tiroirEl.appendChild(h);
|
||||
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussions parallèles";
|
||||
tiroirEl.appendChild(h);
|
||||
if (!d.paralleles.length) {
|
||||
var vide = document.createElement("div");
|
||||
vide.className = "d-vide";
|
||||
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
|
||||
tiroirEl.appendChild(vide);
|
||||
}
|
||||
d.paralleles.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Archiver", action: "archive" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
if (d.archivees && d.archivees.length) {
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Archivées";
|
||||
tiroirEl.appendChild(h);
|
||||
d.archivees.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Désarchiver", action: "desarchiver" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function basculerTiroir() {
|
||||
tiroirEl.hidden = !tiroirEl.hidden;
|
||||
if (!tiroirEl.hidden) remplirTiroir();
|
||||
}
|
||||
|
||||
function chargerHistorique() {
|
||||
if (!convId) return;
|
||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) { convId = null; stockerConv(); return null; }
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convKind = d.kind; convTitre = d.title || convTitre;
|
||||
majTitre();
|
||||
if (!msgsEl.querySelector(".draco-msg")) {
|
||||
ajouterMsg("sys", (d.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
|
||||
}
|
||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||
(d.messages || []).forEach(function (m) {
|
||||
var el = m.role === "user"
|
||||
? ajouterMsg("user", m.content)
|
||||
: ajouterMsgMd(m.content, m.html);
|
||||
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { /* historique indisponible : on repart à neuf */ });
|
||||
}
|
||||
|
||||
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'>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 entete = document.createElement("div");
|
||||
entete.className = "m-entete";
|
||||
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||
ligne.appendChild(entete);
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "m-meta";
|
||||
meta.textContent = m.executor +
|
||||
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
|
||||
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
|
||||
ligne.appendChild(meta);
|
||||
if (m.result) {
|
||||
var rapport = document.createElement("div");
|
||||
rapport.className = "m-rapport md";
|
||||
if (m.result_html) rapport.innerHTML = m.result_html;
|
||||
else rapport.textContent = String(m.result).slice(0, 1200);
|
||||
ligne.appendChild(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 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;
|
||||
convKind = d.kind || convKind;
|
||||
if (d.title) convTitre = d.title;
|
||||
stockerConv(); majTitre();
|
||||
if (d.html) {
|
||||
attente.className = "draco-msg agent md";
|
||||
attente.innerHTML = d.html;
|
||||
} else {
|
||||
attente.textContent = d.reply;
|
||||
}
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
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(); demarrerDiscussions(); }
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
+20
-4
@@ -76,6 +76,7 @@
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/draco-barre.css" />
|
||||
<link rel="stylesheet" href="/static/draco-panneau.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
|
||||
@@ -331,14 +332,14 @@
|
||||
|
||||
<!-- Main Application (wrapped for show/hide) -->
|
||||
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
|
||||
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/draco-barre.css, /static/draco-barre.js. -->
|
||||
<header class="draco-bar" data-current="obsigate">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" aria-label="dracodev">
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
@@ -409,11 +410,25 @@
|
||||
setTimeout(function () { d.remove(); }, 3400);
|
||||
};
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") { var b = document.getElementById("sidebar-toggle-btn"); if (b) b.click(); return; }
|
||||
if (act === "rail") {
|
||||
// Bureau : afficher/masquer la barre latérale.
|
||||
// Mobile (≤ 768 px, point de bascule d'ObsiGate) :
|
||||
// le vrai hamburger, qui ouvre le tiroir — avant
|
||||
// ce correctif (BUG-118), le rail cliquait le
|
||||
// bouton bureau et ne faisait rien sur téléphone.
|
||||
var mobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
var b = document.getElementById(mobile ? "hamburger-btn" : "sidebar-toggle-btn");
|
||||
if (b) b.click();
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
|
||||
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
|
||||
if (act === "alertes") { dire("Alertes unifiées : 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 = "";
|
||||
@@ -6042,5 +6057,6 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</div>
|
||||
<script defer src="/static/draco-barre.js"></script>
|
||||
<script defer src="/static/draco-liens.js"></script>
|
||||
<script defer src="/static/draco-panneau.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -14296,7 +14296,7 @@ textarea.file-chat-input {
|
||||
/* — Barre d'apps commune dracodev + section « Liens » des notes
|
||||
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
|
||||
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
@media (max-width: 700px) {
|
||||
@media (max-width: 900px) {
|
||||
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
|
||||
}
|
||||
.draco-liens {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.71.0",
|
||||
"version": "2.76.1",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Émission d'événements vers le journal du service d'agents (#220,
|
||||
Fusion Lot 2 vague 5 — contrat ACC-0.2).
|
||||
|
||||
Un faux récepteur local capture les POST signés HMAC et les vérifie
|
||||
comme le fait le service d'agents ; on prouve aussi l'inactivité sans
|
||||
clé, la non-régression métier quand le récepteur est en panne, et le
|
||||
garde-fou central : le watcher / l'indexation n'émettent JAMAIS
|
||||
(seules les écritures explicites via l'API ou les outils le font).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
CLE = "cle-obsigate-de-test"
|
||||
|
||||
|
||||
class _Recepteur(BaseHTTPRequestHandler):
|
||||
recus = []
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
corps = self.rfile.read(n)
|
||||
type(self).recus.append(
|
||||
{"headers": {k.lower(): v for k, v in self.headers.items()},
|
||||
"corps": corps})
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _demarrer(monkeypatch):
|
||||
_Recepteur.recus = []
|
||||
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
|
||||
port = serveur.server_address[1]
|
||||
threading.Thread(target=serveur.serve_forever, daemon=True).start()
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||
f"http://127.0.0.1:{port}/api/v1/evenements")
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
return serveur
|
||||
|
||||
|
||||
def _attendre(types, delai=6.0):
|
||||
"""Attend que chaque type demandé soit arrivé ; retourne
|
||||
{type: enveloppe} — la signature HMAC de CHAQUE envoi est
|
||||
revérifiée ici, comme le fait le service."""
|
||||
vus = {}
|
||||
fin = time.time() + delai
|
||||
while time.time() < fin and not all(t in vus for t in types):
|
||||
for recu in list(_Recepteur.recus):
|
||||
env = json.loads(recu["corps"].decode("utf-8"))
|
||||
sig = recu["headers"].get("x-draco-signature", "")
|
||||
attendu = hmac.new(CLE.encode(), recu["corps"],
|
||||
hashlib.sha256).hexdigest()
|
||||
assert hmac.compare_digest(sig.lower(), attendu), \
|
||||
"signature HMAC invalide"
|
||||
vus.setdefault(env["type"], env)
|
||||
if not all(t in vus for t in types):
|
||||
time.sleep(0.05)
|
||||
manquants = [t for t in types if t not in vus]
|
||||
assert not manquants, f"événements jamais reçus : {manquants}"
|
||||
return vus
|
||||
|
||||
|
||||
# ── Enveloppe ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_enveloppe_et_signature(monkeypatch):
|
||||
from backend import agent_journal
|
||||
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
env, corps = agent_journal.construire_enveloppe(
|
||||
agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md")
|
||||
assert env["source_app"] == "obsigate"
|
||||
assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A)
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"] == {
|
||||
"ref": "fichier:TestVault/Journal/nouvelle.md",
|
||||
"label": "nouvelle.md",
|
||||
"url": "/#file=TestVault:Journal/nouvelle.md",
|
||||
}
|
||||
assert json.loads(corps.decode("utf-8")) == env
|
||||
assert env["id"] and env["occurred_at"]
|
||||
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
|
||||
assert len(sig) == 64
|
||||
|
||||
|
||||
def test_domaine_par_vault(monkeypatch):
|
||||
from backend import agent_journal
|
||||
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES",
|
||||
json.dumps({"TestVault": "famille"}))
|
||||
assert agent_journal.domaine_du_vault("TestVault") == "famille"
|
||||
assert agent_journal.domaine_du_vault("AutreVault") == "pro"
|
||||
|
||||
|
||||
# ── Écritures REST (acteur « user ») ─────────────────────────────────
|
||||
|
||||
|
||||
def test_cycle_de_vie_rest(client, monkeypatch):
|
||||
serveur = _demarrer(monkeypatch)
|
||||
try:
|
||||
r = client.post("/api/file/TestVault", json={
|
||||
"path": "Journal/nouvelle.md", "content": "# Nouvelle\n"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.put("/api/file/TestVault/save",
|
||||
params={"path": "Journal/nouvelle.md"},
|
||||
json={"content": "# Nouvelle v2\n"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.patch("/api/file/TestVault", json={
|
||||
"path": "Journal/nouvelle.md", "new_name": "renommee.md"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.post("/api/move/TestVault", json={
|
||||
"source_path": "Journal/renommee.md", "destination_dir": "Projets"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.delete("/api/file/TestVault",
|
||||
params={"path": "Projets/renommee.md"})
|
||||
assert r.status_code == 200
|
||||
|
||||
vus = _attendre([
|
||||
"obsigate.note.created", "obsigate.note.edited",
|
||||
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||
|
||||
env = vus["obsigate.note.created"]
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||
assert env["object"]["label"] == "nouvelle.md"
|
||||
assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md"
|
||||
|
||||
env = vus["obsigate.note.edited"]
|
||||
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||
|
||||
# moved : émis deux fois (renommage puis déplacement). Les
|
||||
# envois partent en fils d'arrière-plan — l'ordre d'arrivée
|
||||
# n'est pas garanti, on compare l'ensemble des références.
|
||||
refs_deplacees = {
|
||||
json.loads(recu["corps"].decode("utf-8"))["object"]["ref"]
|
||||
for recu in _Recepteur.recus
|
||||
if json.loads(recu["corps"].decode("utf-8"))["type"]
|
||||
== "obsigate.note.moved"}
|
||||
assert refs_deplacees == {
|
||||
"fichier:TestVault/Journal/renommee.md",
|
||||
"fichier:TestVault/Projets/renommee.md"}
|
||||
|
||||
env = vus["obsigate.note.deleted"]
|
||||
assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md"
|
||||
finally:
|
||||
serveur.shutdown()
|
||||
|
||||
|
||||
# ── Écritures par les outils / MCP (acteur « agent:mcp ») ───────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault_outils(tmp_path, monkeypatch):
|
||||
"""Vault minimal pour la couche outils (patron test_document_tools)."""
|
||||
import backend.indexer
|
||||
|
||||
vault_dir = tmp_path / "Vault"
|
||||
vault_dir.mkdir()
|
||||
monkeypatch.setitem(
|
||||
backend.indexer.index, "Vault",
|
||||
{"name": "Vault", "path": str(vault_dir), "config": {}})
|
||||
return vault_dir
|
||||
|
||||
|
||||
def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch):
|
||||
from backend.tools.api import ToolContext, ToolMode, call_tool
|
||||
|
||||
serveur = _demarrer(monkeypatch)
|
||||
try:
|
||||
ctx = ToolContext(
|
||||
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||
mode=ToolMode.MCP, audit_enabled=False)
|
||||
|
||||
out = call_tool("create_file", ctx, {
|
||||
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
out = call_tool("edit_file", ctx, {
|
||||
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
out = call_tool("rename_file", ctx, {
|
||||
"vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
out = call_tool("delete_file", ctx, {
|
||||
"vault": "Vault", "path": "renomme-mcp.md",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
vus = _attendre([
|
||||
"obsigate.note.created", "obsigate.note.edited",
|
||||
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||
for env in vus.values():
|
||||
assert env["actor"] == "agent:mcp"
|
||||
assert vus["obsigate.note.created"]["object"]["ref"] == \
|
||||
"fichier:Vault/par-mcp.md"
|
||||
assert vus["obsigate.note.moved"]["object"]["ref"] == \
|
||||
"fichier:Vault/renomme-mcp.md"
|
||||
finally:
|
||||
serveur.shutdown()
|
||||
|
||||
|
||||
# ── Inactivité sans clé ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_inactif_sans_cle(client, monkeypatch):
|
||||
serveur = _demarrer(monkeypatch)
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
|
||||
try:
|
||||
from backend import agent_journal
|
||||
|
||||
assert agent_journal.actif() is False
|
||||
assert agent_journal.emettre(
|
||||
agent_journal.TYPE_CREE, "TestVault", "x.md") is False
|
||||
r = client.post("/api/file/TestVault", json={
|
||||
"path": "sans-journal.md", "content": "# Rien\n"})
|
||||
assert r.status_code == 200
|
||||
time.sleep(0.6)
|
||||
assert _Recepteur.recus == []
|
||||
finally:
|
||||
serveur.shutdown()
|
||||
|
||||
|
||||
# ── Garde-fou : watcher et indexation n'émettent jamais ─────────────
|
||||
|
||||
|
||||
def test_watcher_et_indexation_sans_evenement(client, test_vault_dir,
|
||||
monkeypatch):
|
||||
"""Une écriture disque externe (éditeur, synchro de fichiers…)
|
||||
traitée par le watcher puis l'indexeur ne produit AUCUN
|
||||
événement : seuls les appels explicites à l'API émettent."""
|
||||
from pathlib import Path
|
||||
|
||||
appels = []
|
||||
monkeypatch.setattr(
|
||||
"backend.agent_journal.emettre",
|
||||
lambda *a, **k: appels.append((a, k)) or False)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
|
||||
cible = Path(test_vault_dir) / "externe.md"
|
||||
cible.write_text("# Écrit hors API\n", encoding="utf-8")
|
||||
|
||||
# Chemin watcher : les gestionnaires d'événements filesystem.
|
||||
from backend.watcher import VaultEventHandler
|
||||
|
||||
boucle = asyncio.new_event_loop()
|
||||
try:
|
||||
file_attente: asyncio.Queue = asyncio.Queue()
|
||||
handler = VaultEventHandler("TestVault", file_attente, boucle)
|
||||
evt = SimpleNamespace(is_directory=False, src_path=str(cible),
|
||||
dest_path=None)
|
||||
handler.on_created(evt)
|
||||
handler.on_modified(evt)
|
||||
|
||||
# Chemin indexation : la même fonction que le watcher appelle
|
||||
# après debounce pour intégrer le fichier à l'index.
|
||||
from backend.indexer import update_single_file
|
||||
|
||||
boucle.run_until_complete(update_single_file("TestVault", "externe.md"))
|
||||
finally:
|
||||
boucle.close()
|
||||
|
||||
assert appels == []
|
||||
|
||||
|
||||
# ── Panne du récepteur : le métier n'est jamais affecté ─────────────
|
||||
|
||||
|
||||
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
|
||||
# Port fermé : connexion refusée dans le fil d'arrière-plan.
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||
"http://127.0.0.1:59999/api/v1/evenements")
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
r = client.post("/api/file/TestVault", json={
|
||||
"path": "malgre-panne.md", "content": "# OK\n"})
|
||||
assert r.status_code == 200
|
||||
r = client.put("/api/file/TestVault/save",
|
||||
params={"path": "malgre-panne.md"},
|
||||
json={"content": "# OK v2\n"})
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"})
|
||||
assert r.status_code == 200
|
||||
assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu
|
||||
@@ -0,0 +1,90 @@
|
||||
"""BUG-118 — barre d'apps commune en mobile (Fusion ACC-1, #215).
|
||||
|
||||
Retours de Bruno (2026-10-21) sur téléphone : la barre débordait
|
||||
(pastilles domaines et onglets entiers affichés) et son bouton ☰
|
||||
ne faisait rien — il cliquait le bouton bureau
|
||||
``sidebar-toggle-btn`` au lieu du vrai hamburger mobile
|
||||
``hamburger-btn``. Correctifs verrouillés ici : CSS commune v0.1.2
|
||||
(palier 700 px resserré selon la spec) et rail contextuel dans
|
||||
index.html (mobile ≤ 768 px → hamburger-btn).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _index() -> str:
|
||||
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_palier_mobile_resserre_v012():
|
||||
css = _css()
|
||||
assert "@media (max-width: 900px)" in css
|
||||
# Seul l'onglet de l'app courante reste visible en mobile.
|
||||
assert '.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"]' in css
|
||||
# Domaines et boutons icônes (rail, menu) masqués en mobile.
|
||||
bloc = css.split("@media (max-width: 900px)", 1)[1]
|
||||
assert ".draco-domaines" in bloc
|
||||
assert "display: none" in bloc
|
||||
|
||||
|
||||
def test_menu_des_applications_par_la_marque():
|
||||
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
|
||||
# (changement d'app en mobile), logo à 24 px.
|
||||
html = _index()
|
||||
assert 'data-act="apps"' in html
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "draco-apps-menu" in js
|
||||
assert "font-size: 24px" in _css()
|
||||
|
||||
|
||||
def test_panneau_spark_integre_218():
|
||||
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
|
||||
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
|
||||
html = _index()
|
||||
assert (FRONTEND / "draco-panneau.js").is_file()
|
||||
assert (FRONTEND / "draco-panneau.css").is_file()
|
||||
assert '<link rel="stylesheet" href="/static/draco-panneau.css" />' in html
|
||||
assert '<script defer src="/static/draco-panneau.js"></script>' in html
|
||||
# L'action spark bascule sur le panneau — plus le message d'attente.
|
||||
assert "window.DRACO_PANNEAU.toggle()" in html
|
||||
assert "arrive en phase 4 du programme Fusion" not in html
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert "https://agent.dracodev.net" in panneau
|
||||
|
||||
|
||||
def test_panneau_bouton_console_221():
|
||||
# #221 : le panneau Spark (asset vendangé, octet-identique dans
|
||||
# les 4 apps) porte un bouton « Console ↗ » qui ouvre la console
|
||||
# web du service dans un nouvel onglet, sur la discussion
|
||||
# courante (?disc=) et avec l'app d'origine (?app= déduite du
|
||||
# sous-domaine : og → obsigate).
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert 'data-disc="console"' in panneau
|
||||
assert "Console ↗" in panneau
|
||||
assert "ouvrirConsole" in panneau
|
||||
assert '"disc=" + encodeURIComponent(id)' in panneau
|
||||
assert '"app=" + encodeURIComponent(app)' in panneau
|
||||
assert 'og: "obsigate"' in panneau
|
||||
assert 'window.open(API + "/"' in panneau
|
||||
assert '"_blank", "noopener"' in panneau
|
||||
|
||||
|
||||
def test_csp_ouvre_le_service_d_agents_218():
|
||||
# Le panneau parle au service d'agents : la CSP doit nommer son
|
||||
# hôte en connect-src, sans ouvrir de générique supplémentaire.
|
||||
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
|
||||
ligne = next(l for l in main.splitlines() if "connect-src" in l)
|
||||
assert "https://agent.dracodev.net" in ligne
|
||||
|
||||
|
||||
def test_rail_mobile_vers_le_vrai_hamburger():
|
||||
html = _index()
|
||||
assert 'matchMedia("(max-width: 768px)")' in html
|
||||
assert '"hamburger-btn"' in html
|
||||
assert '"sidebar-toggle-btn"' in html
|
||||
@@ -185,6 +185,41 @@ def test_sso_authorize_liste_blanche(client):
|
||||
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
|
||||
|
||||
def test_me_avec_cookie_draco_seul(admin_client):
|
||||
# Aucune session native : le cookie partagé suffit (sub = id).
|
||||
token, _jti, _exp = draco_session.issue_token(USER)
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["username"] == "admin"
|
||||
|
||||
|
||||
def test_me_draco_revoque_refuse(admin_client):
|
||||
token, jti, exp = draco_session.issue_token(USER)
|
||||
draco_session.revoke(jti, exp)
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_me_draco_sub_inconnu_refuse(admin_client):
|
||||
token, _j, _e = draco_session.issue_token(
|
||||
{"id": "personne-99", "username": "x", "display_name": "X"})
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
|
||||
token, _j, _e = draco_session.issue_token(USER)
|
||||
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
|
||||
token, jti, exp = draco_session.issue_token(USER)
|
||||
draco_session.revoke(jti, exp)
|
||||
|
||||
Reference in New Issue
Block a user