Compare commits

...
7 Commits
Author SHA1 Message Date
bruno 54caa48d1f feat: chat — saisie auto-agrandissante, accusé de réception ✓✓, vignette de tuile #192 (BUG-109)
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m1s
2026-10-08 23:28:08 -04:00
bruno f00a8bea8f feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s
2026-10-08 22:33:29 -04:00
bruno 55a9fcc0f4 fix: retirer -f de logs du build.sh (bloquait le lock de deploiement)
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m31s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 17m16s
2026-10-08 21:06:18 -04:00
bruno 4cdea956d7 docs: spec Assistant V2 — second cerveau (mémoire, connecteurs, proactivité)
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 16m41s
2026-10-08 21:01:45 -04:00
bruno ae06436f91 feat: chat — suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre #190
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 1m30s
CI / e2e (push) Successful in 17m5s
- **A — Suivi du document** : l'en-tête du panneau chat affiche le document
  ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()`
  re-cible le panneau ouvert à chaque changement de document.
- **B — Chat général** : onglet « Chat » (dernier) dans la sidebar,
  conversation centrale stockée dans le store #169 via les sentinelles
  `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non
  lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` :
  allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}`
  résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi.
- **C — Ordre des onglets** : la barre de filtre passe sous les onglets et
  sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est
  actif (`switchSidebarTab` + `routeFilter`).
- Transport : broadcast SSE `chat_message` réutilisé (`vault __global__`
  route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket.
- i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur.
- Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5
  nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules.
- Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche
  `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
2026-10-08 20:40:54 -04:00
bruno d0e10d4cc6 docs: journal des interventions AI #189/#169 + versions d'index resynchronisées
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m24s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m37s
- `docs/ISSUES_TODOLIST.md` : deux lignes ajoutées au journal (features
  #189 mobile tableur et #169 chat par fichier, avec vérifications et
  résultats CI), exigées par le DoD pour tout passage d'IA modifiant du code.
- `docs/ROADMAP.md` : colonne version de l'index corrigée — #189 → 2.56.0,
  #169 → 2.57.0 (le hook de bump ne resynchronise pas ces rangées).
2026-10-08 18:42:31 -04:00
bruno c94f065f80 feat: chat intégré par fichier — panneau latéral, historique, temps réel SSE #169
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m11s
- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
  sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
  500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
  `resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
  `chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
  fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
  toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
  ≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
  (6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
  `docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
2026-10-08 17:03:29 -04:00
32 changed files with 3879 additions and 80 deletions
+2
View File
@@ -72,6 +72,7 @@ jobs:
node upload.test.mjs
node config-ai-keys.test.mjs
node xlsx-viewer.test.mjs
node filechat.test.mjs
else
echo "tests/frontend/node_modules missing - installing jsdom"
npm install --no-audit --no-fund --silent
@@ -93,6 +94,7 @@ jobs:
node upload.test.mjs
node config-ai-keys.test.mjs
node xlsx-viewer.test.mjs
node filechat.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
+114 -1
View File
@@ -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.56.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.61.0**.
---
@@ -14,6 +14,119 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.61.0] — 2026-10-08
### Ajouté
- **#192 - Chat : boîte de saisie auto-agrandissante + accusé de réception**
- **Saisie auto-agrandissante** : la boîte d'envoi (panneau document +
sidebar) est un `textarea` dont la hauteur suit le contenu (plafond
160 px puis défilement) — on relit son message entier avant l'envoi.
`Entrée` envoie, `Maj+Entrée` saute une ligne, la hauteur repasse à une
ligne après l'envoi.
- **Accusé de réception** : chaque conversation stocke
`{utilisateur: dernier_ts_de_lecture}` ; l'expéditeur voit **✓ envoyé**
puis **✓✓ lu** sur ses propres messages (un message est lu dès qu'un
*autre* participant ouvre la conversation). Le `GET` d'historique renvoie
la carte de lecture, `POST /api/chat/read` la met à jour et la diffuse
en SSE (`chat_read`) — les indicateurs basculent en direct.
- **Notification** : quand un message passe à ✓✓, l'expéditeur reçoit le
toast « {user} a lu votre message » (FR/EN) ; un message reçu pendant
l'affichage du canal accuse réception aussitôt.
- Verrou global sur le read-modify-write du store de chat (une lecture ne
peut plus faire disparaître un message).
- Tests : pytest `TestReadReceipts` (7) + JSDOM `filechat.test.mjs` +3
(19).
### Corrigé
- **BUG-109 - Tuile de lien : l'image de la carte ne s'affichait jamais** —
la CSP (`img-src 'self' data: blob:`) bloque toute `og:image` distante et
les images relatives étaient résolues contre l'origine d'ObsiGate (404).
La vignette est désormais téléversée à l'envoi dans `chat_uploads`
(`/api/chat/attachment/<uuid>`, même origine, garde SSRF + plafond 2 Mo +
allow-list d'extensions) ; échec du téléchargement → carte conservée sans
vignette. Les cartes déjà publiées gardent leur ancien lien (elles
étaient déjà muettes).
---
## [2.60.0] — 2026-10-08
---
## [2.59.0] — 2026-10-08
### Ajouté
- **#191 - Chat : suppression de post, messages privés, boîte compacte, link preview**
- **Suppression d'un post** : bouton 🗑 sur ses propres messages (et sur
tout le monde pour un admin), confirmation, `DELETE /api/chat/{id}` et
`/api/chat/dm/{user}/{id}`, retrait en direct chez tous les clients
(SSE `chat_deleted`).
- **Messages privés 2 utilisateurs** : rangée de canaux au-dessus du fil
(« Général » + un bouton par utilisateur), conversations isolées
(vault `__dm__`, chemin `alice|bob` trié), placeholder dédié,
compteurs de non-lus par canal, `GET /api/chat/users` pour la liste
des destinataires (sans hash/mot de passe, sans soi-même).
- **Boîte d'édition compacte** : bouton d'envoi réduit à une icône
(panneau document + sidebar), la saisie garde toute la largeur.
- **Link preview** : métadonnées OpenGraph récupérées à l'envoi
(titre, description, image, site), carte cliquable esthétique,
garde SSRF réutilisée (host privé/loopback rejeté), cache borné
200 entrées, échec silencieux (URL morte → le message passe sans carte).
- Tests : pytest +13 (45 au total file_chat), JSDOM `filechat.test.mjs`
+5 (16).
---
## [2.58.0] — 2026-10-08
### Ajouté
- **#190 - Chat : suivi du document, chat général en onglet sidebar**
- **Suivi du document** : le panneau du chat affiche le document ciblé
(titre + chemin au survol) et **suit automatiquement** le document
rendu — changer de fichier re-cible le panneau ouvert.
- **Onglet « Chat »** (dernier, après Historique IA) dans la sidebar :
conversation générale non liée à un fichier, **indicateur de messages
non lus** (pastille rouge sur l'onglet), **pièces jointes image/vidéo**
(upload avec allow-list d'extensions, 25 MB max, nom UUID), **URL
cliquables** dans les messages, date/heure d'envoi.
- **Ordre des onglets** : la barre de filtre passe **sous** les onglets
et sert de **recherche dans le chat** quand l'onglet Chat est actif
(texte + auteur).
- Backend : `GET/POST /api/chat`, `POST /api/chat/upload`,
`GET /api/chat/attachment/{name}` (même store #169 via les sentinelles
`__global__/general`), broadcast SSE `chat_message` réutilisé.
- Tests : pytest +12 (27 au total), JSDOM `filechat.test.mjs` +5 (11).
---
## [2.57.1] — 2026-10-08
---
## [2.57.0] — 2026-10-08
### Ajouté
- **#169 - Chat intégré par fichier**
- Bouton **💬 Chat** dans la barre d'actions du document : ouvre un panneau
latéral avec l'historique de la discussion et la saisie d'un message.
- Persistance par fichier (`data/chats/`, plafond 500 messages, texte
tronqué à 4000 caractères) ; routes `GET/POST /api/file/{vault}/chat`
protégées (auth + accès vault + contrôle de traversal).
- Temps réel via le transport SSE existant (événement `chat_message`) :
le panneau ouvert s'incrémente en direct, **toast de notification**
« Nouveau message de … » si le panneau est fermé ou affiche un autre
fichier (pas de doublon côté émetteur).
- Mobile : panneau plein écran ; i18n FR/EN.
- Tests : pytest `test_file_chat.py` (15) + JSDOM `filechat.test.mjs` (6),
ajoutés au pipeline CI.
---
## [2.56.0] — 2026-10-08
### Ajouté
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.56.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,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.56.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.61.0).
---
*Projet : ObsiGate | Version : 2.56.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.61.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.56.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,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.56.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.61.0).
---
*Project: ObsiGate | Version: 2.56.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.61.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.56.0
2.61.0
+408
View File
@@ -0,0 +1,408 @@
# backend/file_chat.py — historique de discussion (#169, #190)
"""Chat history persisted under ``data/chats/``.
One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so
the filename never carries user-controlled path separators. Writes are
atomic (tmp + move) and the message list is capped at
:data:`MAX_MESSAGES` to bound growth.
#190 adds the **general chat**: the same store addressed with the reserved
sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second
implementation. Messages may carry an ``attachment`` (image/video/url)
uploaded under ``data/chat_uploads/``.
"""
from __future__ import annotations
import hashlib
import html
import json
import logging
import re
import shutil
import threading
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlparse
import httpx
logger = logging.getLogger("obsigate.file_chat")
CHAT_DIR = Path("data/chats")
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
MAX_TEXT = 4000 # characters per message
# ponytail: global lock over read-modify-write — chat writes are HTTP-only and
# serialized anyway, this just makes losing a message to a future thread (or a
# watcher hook) impossible; per-file locks if it ever becomes contended.
_LOCK = threading.RLock()
# #190 — general (non file-bound) conversation, stored like any other one.
GLOBAL_VAULT = "__global__"
GLOBAL_PATH = "general"
# #191 — private (2 users) conversations reuse the same store: the vault is
# the reserved sentinel and the path is the sorted username pair, so the
# storage key never depends on who asks.
DM_VAULT = "__dm__"
# #190 — attachments (image/video) live outside the vaults.
UPLOAD_DIR = Path("data/chat_uploads")
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
ALLOWED_ATTACH_EXT = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
".mp4", ".webm", ".ogg", ".mov", ".m4v",
}
# #191 — link preview fetch budget
PREVIEW_TIMEOUT = 5.0 # seconds
PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail
def _chat_file(vault: str, path: str) -> Path:
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
CHAT_DIR.mkdir(parents=True, exist_ok=True)
key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32]
return CHAT_DIR / f"{key}.json"
def _read(vault: str, path: str) -> dict[str, Any]:
"""Load the raw chat document (empty structure when missing/corrupt)."""
file = _chat_file(vault, path)
if not file.exists():
return {"vault": vault, "path": path, "messages": []}
try:
doc = json.loads(file.read_text(encoding="utf-8"))
if not isinstance(doc.get("messages"), list):
raise TypeError("messages is not a list") # caught by the handler below
return doc
except Exception as e:
logger.error("Failed to read chat for %s/%s: %s", vault, path, e)
return {"vault": vault, "path": path, "messages": []}
def _write(file: Path, doc: dict[str, Any]) -> None:
"""Atomically persist *doc* (tmp file + rename)."""
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
shutil.move(str(tmp), str(file))
except Exception as e:
logger.error("Failed to write chat %s: %s", file.name, e)
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
"""Return the chat history for *(vault, path)* (chronological)."""
return list(_read(vault, path).get("messages", []))
def add_message(
vault: str,
path: str,
user: str,
text: str,
attachment: dict[str, Any] | None = None,
preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a message and persist it. Returns the stored message.
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
*attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url;
*preview* (#191) is the OpenGraph card of the first URL in *text*.
"""
text = (text or "").strip()[:MAX_TEXT]
msg: dict[str, Any] = {
"id": uuid.uuid4().hex[:12],
"user": user or "anonyme",
"text": text,
"ts": time.time(),
}
if attachment:
msg["attachment"] = attachment
if preview:
msg["preview"] = preview
return _append(vault, path, msg)
def _append(
vault: str,
path: str,
msg: dict[str, Any],
) -> dict[str, Any]:
"""Cap, persist and return *msg* (shared by file, global and DM chats).
The read-modify-write of the whole document happens under ``_LOCK`` so a
concurrent writer can never drop a message (same class of bug as
BUG-029 on ``users.json``).
"""
with _LOCK:
doc = _read(vault, path)
messages = list(doc.get("messages", []))
messages.append(msg)
if len(messages) > MAX_MESSAGES:
messages = messages[-MAX_MESSAGES:]
doc["messages"] = messages
_write(_chat_file(vault, path), doc)
return msg
# --- #192 : accusé de réception ---------------------------------------------
def get_read(vault: str, path: str) -> dict[str, float]:
"""Return ``{username: last_read_ts}`` for a conversation (#192)."""
return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()}
def mark_read(vault: str, path: str, user: str) -> dict[str, float]:
"""Record that *user* has seen the conversation (#192).
Returns the whole read map so the caller can broadcast it on SSE: a
sender learns their messages were received as soon as the recipient
displays the conversation.
"""
if not user:
return get_read(vault, path)
with _LOCK:
doc = _read(vault, path)
read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()}
read[user] = time.time()
doc["read"] = read
_write(_chat_file(vault, path), doc)
return read
# --- #191 : messages privés (2 utilisateurs) -------------------------------
def dm_path(user_a: str, user_b: str) -> str:
"""Storage path for the private conversation between two users.
The pair is sorted so both participants address the same document.
"""
return "|".join(sorted([user_a, user_b]))
def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]:
"""Return the private history between two users (chronological)."""
return get_messages(DM_VAULT, dm_path(user_a, user_b))
def add_dm_message(
user_a: str,
user_b: str,
author: str,
text: str,
attachment: dict[str, Any] | None = None,
preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a private message. Returns the stored message."""
return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview)
# --- #191 : suppression -----------------------------------------------------
def delete_message(vault: str, path: str, message_id: str) -> bool:
"""Remove one message from a conversation. True when it existed."""
with _LOCK:
doc = _read(vault, path)
messages = list(doc.get("messages", []))
kept = [m for m in messages if m.get("id") != message_id]
if len(kept) == len(messages):
return False
doc["messages"] = kept
_write(_chat_file(vault, path), doc)
return True
# --- #190 : chat général (conversation centrale, hors fichier) -------------
def get_global_messages() -> list[dict[str, Any]]:
"""Return the general-chat history (chronological)."""
return get_messages(GLOBAL_VAULT, GLOBAL_PATH)
def add_global_message(
user: str,
text: str,
attachment: dict[str, Any] | None = None,
preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a message to the general chat. Returns the stored message."""
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview)
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
"""Persist an uploaded attachment under :data:`UPLOAD_DIR`.
Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID
(never the client name), the extension must be in
:data:`ALLOWED_ATTACH_EXT` and the size is capped at
:data:`MAX_UPLOAD_BYTES`.
Raises:
ValueError: extension refused, empty file or size exceeded.
"""
ext = Path(filename or "").suffix.lower()
if ext not in ALLOWED_ATTACH_EXT:
raise ValueError(f"extension refusée : {ext or '(aucune)'}")
if not data:
raise ValueError("fichier vide")
if len(data) > MAX_UPLOAD_BYTES:
raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}{ext}"
(UPLOAD_DIR / name).write_bytes(data)
kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image"
return {
"name": name,
"url": f"/api/chat/attachment/{name}",
"mime": _MIME_BY_EXT.get(ext, "application/octet-stream"),
"kind": kind,
}
# --- #191 : link preview ----------------------------------------------------
_URL_RE = re.compile(r"https?://[^\s<>\"']+")
_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {}
PREVIEW_CACHE_MAX = 200
def _og(content: str, prop: str) -> str:
"""Extract one OpenGraph/``<title>`` value from an HTML head (regex)."""
for pattern in (
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
):
m = re.search(pattern, content, re.IGNORECASE)
if m:
return html.unescape(m.group(1)).strip()[:300]
if prop == "og:title":
m = re.search(r"<title[^>]*>([^<]*)</title>", content, re.IGNORECASE)
if m:
return html.unescape(m.group(1)).strip()[:300]
return ""
# BUG-109 — content-type → extension (the attachment allow-list decides).
_IMG_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
def _proxy_image(img_url: str, page_url: str) -> str:
"""Download *img_url* into ``chat_uploads`` and return a same-origin URL.
The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image``
would be blocked by the browser (BUG-109). Relative and protocol-relative
values are resolved against *page_url* first. Raises ``ValueError`` /
``SSRFError`` on any failure — the caller keeps the card and drops only
the thumbnail.
"""
from backend.tools.web import USER_AGENT, _assert_public_http_url
full = urljoin(page_url, img_url)
_assert_public_http_url(full)
resp = httpx.get(
full,
headers={"User-Agent": USER_AGENT},
timeout=PREVIEW_TIMEOUT,
follow_redirects=True,
)
if resp.status_code >= 400:
raise ValueError(f"HTTP {resp.status_code}")
data = resp.content
if not data or len(data) > PREVIEW_IMAGE_MAX:
raise ValueError("image vide ou trop lourde")
mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower()
# save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant)
return str(save_attachment(f"preview{ext}", data)["url"])
def build_preview(text: str) -> dict[str, Any] | None:
"""Fetch OpenGraph metadata for the first URL in *text* (#191).
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
bounded dict. Returns ``{url, title, description, image, site}`` or
``None`` when there is no URL / the fetch fails (never raises: a dead
link must not block the message). ``image`` is a **same-origin**
``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail
could not be fetched.
"""
m = _URL_RE.search(text or "")
if not m:
return None
url = m.group(0).rstrip(".,;:!?)")
if url in _PREVIEW_CACHE:
cached = _PREVIEW_CACHE[url]
return dict(cached) if cached else None
try:
from backend.tools.web import USER_AGENT, _assert_public_http_url
_assert_public_http_url(url)
resp = httpx.get(
url,
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
timeout=PREVIEW_TIMEOUT,
follow_redirects=True,
)
if resp.status_code >= 400:
raise ValueError(f"HTTP {resp.status_code}")
body = resp.text[:PREVIEW_MAX_BYTES]
# BUG-109 : vignette téléchargée côté serveur — une image distante
# échouerait à la CSP. Échec isolé = carte sans vignette.
image = _og(body, "og:image")
try:
image = _proxy_image(image, url) if image else ""
except Exception as ie:
logger.debug("preview image failed for %s: %s", url, ie)
image = ""
preview = {
"url": url,
"title": _og(body, "og:title") or _og(body, "og:site_name"),
"description": _og(body, "og:description"),
"image": image,
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
}
if not preview["title"]:
raise ValueError("pas de titre")
except Exception as e:
logger.debug("link preview failed for %s: %s", url, e)
preview = None
if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
_PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
_PREVIEW_CACHE[url] = preview
return dict(preview) if preview else None
def attachment_path(name: str) -> Path | None:
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
p = Path(name)
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
return None
file = UPLOAD_DIR / p.name
return file if file.exists() else None
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
_MIME_BY_EXT = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".webm": "video/webm",
".ogg": "video/ogg",
".mov": "video/quicktime",
".m4v": "video/x-m4v",
}
+2
View File
@@ -509,6 +509,7 @@ from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
from backend.routers.duplicates import router as duplicates_router
from backend.routers.file_chat import router as file_chat_router
from backend.routers.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router
from backend.routers.files_write import router as files_write_router
@@ -534,6 +535,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
+290
View File
@@ -0,0 +1,290 @@
# backend/routers/file_chat.py — chat (#169, #190)
"""Chat endpoints: history read + message post with SSE fan-out.
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
access + path traversal check (``resolve_safe_path`` raises
``ServiceError`` mapped by the app-level handler).
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
image/video attachments (extension allow-list, size cap, UUID name).
Every post is broadcast on the existing SSE channel (``chat_message``) so
all connected clients update live without a second WebSocket.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from backend import file_chat as _store
from backend.auth.middleware import check_vault_access, require_auth
from backend.auth.user_store import get_all_users, get_user
from backend.indexer import get_vault_data
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
"""Authz + traversal guard shared by both verbs."""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(404, "Vault not found")
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
async def api_file_chat_history(
vault_name: str,
path: str,
current_user: dict[str, Any] = Depends(require_auth),
):
"""Return the chat history for a file (chronological)."""
_check(vault_name, path, current_user)
return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
async def api_file_chat_post(
vault_name: str,
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
path = str(body.get("path") or "")
text = str(body.get("text") or "")
if not path:
raise HTTPException(400, "path is required")
if not text.strip():
raise HTTPException(400, "text is required")
_check(vault_name, path, current_user)
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
return {"message": msg, "status": "ok"}
# --- #190 : chat général ----------------------------------------------------
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
"""Validate the optional ``attachment`` object sent by the client."""
raw = body.get("attachment")
if not raw or not isinstance(raw, dict):
return None
name = str(raw.get("name") or "")
# Only an already-uploaded file (or an http(s) URL) may travel along.
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
raise HTTPException(400, "attachment inconnu")
return {
"name": name,
"url": str(raw.get("url") or ""),
"mime": str(raw.get("mime") or ""),
"kind": str(raw.get("kind") or "file"),
}
@router.get("/api/chat", response_model=ChatHistoryResponse)
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
"""Return the general chat history (#190, chronological)."""
return {
"messages": _store.get_global_messages(),
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
}
# --- #192 : accusé de réception ---------------------------------------------
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
"""Authorization for marking a conversation read (#192).
General chat is open to any member, a DM only to the two participants,
a file chat follows the vault ACL.
"""
if vault == _store.GLOBAL_VAULT:
return
if vault == _store.DM_VAULT:
if current_user.get("username") not in str(path).split("|"):
raise HTTPException(403, "Accès refusé à cette conversation privée")
return
_check(vault, path, current_user)
@router.post("/api/chat/read", response_model=ChatReadResponse)
async def api_chat_read(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Record that the caller has seen a conversation (#192).
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
✓✓ live on every connected client.
"""
vault = str(body.get("vault") or "")
path = str(body.get("path") or "")
if not vault or not path:
raise HTTPException(400, "vault and path are required")
_check_read(vault, path, current_user)
username = current_user.get("username", "")
read = _store.mark_read(vault, path, username)
await sse_manager.broadcast(
"chat_read",
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
)
return {"read": read, "status": "ok"}
@router.post("/api/chat", response_model=ChatMessageResponse)
async def api_chat_post(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
text = str(body.get("text") or "")
if not text.strip():
raise HTTPException(400, "text is required")
# #191 — best-effort link preview: a dead/slow URL never blocks the post.
msg = _store.add_global_message(
current_user.get("username", ""), text, _attachment(body),
_store.build_preview(text),
)
await sse_manager.broadcast(
"chat_message",
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
)
return {"message": msg, "status": "ok"}
@router.post("/api/chat/upload")
async def api_chat_upload(
file: UploadFile = File(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
data = await file.read()
try:
info = _store.save_attachment(file.filename or "", data)
except ValueError as e:
raise HTTPException(400, str(e)) from e
return {"attachment": info}
@router.get("/api/chat/attachment/{name}")
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Serve an uploaded attachment (name validated against the allow-list)."""
path = _store.attachment_path(name)
if not path:
raise HTTPException(404, "Attachment not found")
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
return FileResponse(str(path), media_type=info)
# --- #191 : suppression + messages privés -----------------------------------
def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None:
"""A post may be deleted by its author or by an admin."""
if current_user.get("role") != "admin" and current_user.get("username") != msg_user:
raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message")
def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]:
"""Locate *message_id* in the conversation and check the delete right."""
for m in _store.get_messages(vault, path):
if m.get("id") == message_id:
_owner_or_admin(m.get("user", ""), current_user)
return m
raise HTTPException(404, "Message not found")
@router.delete("/api/chat/{message_id}", response_model=StatusResponse)
async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Delete a message from the general chat (author or admin, #191)."""
_find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user)
if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id):
raise HTTPException(404, "Message not found")
await sse_manager.broadcast(
"chat_deleted",
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id},
)
return {"status": "deleted"}
@router.get("/api/chat/users", response_model=list[dict[str, Any]])
async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)):
"""Usernames available for a private conversation (chat DM picker, #191).
Every authenticated member may see who else is around — this is a
self-hosted portal, not a directory that needs hiding.
"""
me = current_user.get("username", "")
return [
{"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")}
for u in get_all_users()
if u.get("username") and u.get("username") != me
]
def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
"""Validate the DM peer exists and is not ourselves."""
if not username or username == current_user.get("username"):
raise HTTPException(400, "Destinataire invalide")
if not get_user(username):
raise HTTPException(404, "Utilisateur inconnu")
return username
@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse)
async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Private history with *username* (#191)."""
peer = _dm_peer(username, current_user)
return {
"messages": _store.get_dm_messages(current_user["username"], peer),
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
}
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
async def api_chat_dm_post(
username: str,
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Post a private message and broadcast it to both participants (#191)."""
peer = _dm_peer(username, current_user)
text = str(body.get("text") or "")
if not text.strip():
raise HTTPException(400, "text is required")
msg = _store.add_dm_message(
current_user["username"], peer, current_user.get("username", ""), text,
_attachment(body), _store.build_preview(text),
)
# Same shape as the general chat so the client routes on vault/path.
await sse_manager.broadcast(
"chat_message",
{"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg},
)
return {"message": msg, "status": "ok"}
@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse)
async def api_chat_dm_delete(
username: str,
message_id: str,
current_user: dict[str, Any] = Depends(require_auth),
):
"""Delete one private message (author or admin, #191)."""
peer = _dm_peer(username, current_user)
vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer)
_find_and_authorize(vault, path, message_id, current_user)
if not _store.delete_message(vault, path, message_id):
raise HTTPException(404, "Message not found")
await sse_manager.broadcast(
"chat_deleted", {"vault": vault, "path": path, "id": message_id}
)
return {"status": "deleted"}
+41
View File
@@ -151,6 +151,47 @@ class BacklinksResponse(BaseModel):
total: int
class ChatMessageItem(BaseModel):
"""One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``)."""
id: str = Field(description="Message id")
user: str = Field(description="Author username")
text: str = Field(description="Message body")
ts: float = Field(description="Unix timestamp")
attachment: dict[str, Any] | None = Field(
default=None,
description="Optional image/video/url attachment {name, url, mime, kind}",
)
preview: dict[str, Any] | None = Field(
default=None,
description="Link preview card {url, title, description, image, site} (#191)",
)
class ChatHistoryResponse(BaseModel):
"""Response for ``GET /api/file/{vault}/chat``."""
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
read: dict[str, float] = Field(
default_factory=dict,
description="{username: last read unix ts} per participant (#192)",
)
class ChatReadResponse(BaseModel):
"""Response for ``POST /api/chat/read`` (#192 read receipts)."""
read: dict[str, float] = Field(description="{username: last read unix ts}")
status: str = Field(description="'ok'")
class ChatMessageResponse(BaseModel):
"""Response for ``POST /api/file/{vault}/chat``."""
message: ChatMessageItem
status: str = Field(description="'ok'")
class BackupsListResponse(BaseModel):
"""Response for ``GET /api/backups``."""
+1 -1
View File
@@ -223,4 +223,4 @@ $COMPOSE_CMD -f "$COMPOSE_FILE" ps
echo ""
info "Logs récents (Ctrl+C pour quitter) :"
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20 -f
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.56.0"
version = "2.61.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.56.0"
version = "2.61.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.56.0",
"version": "2.61.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+527
View File
@@ -0,0 +1,527 @@
# Assistant V2 — ObsiGate comme second cerveau & assistant personnel
> **Statut :** 📐 Spécification de conception (non implémentée)
> **Dernière mise à jour :** 2026-10-08
> **IDs proposés :** ObsiGate **#191 → #201** (liste « Assistant V2 »)
> **Portée :** évolution de l'assistant IA existant en assistant à mémoire long terme,
> outillé (connecteurs externes), planifié (rappels, briefing) et proactif (insights).
> **Hors portée :** toute intégration avec l'infrastructure externe du homelab
> (Honcho, Hermes, Ollama partagé, ntfy homelab, cron système). ObsiGate est **autonome** :
> un seul conteneur, ses propres stores, ses propres clés.
---
## 1. Vision
Transformer ObsiGate d'un portail de notes avec assistant vers un **second cerveau
conversationnel et autonome** :
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, décisions,
entités), consolidée chaque nuit, **visible et éditable en markdown dans le vault**
(pas de boîte noire).
2. **Il agit** — lit/écrit le vault, mais aussi les services externes connectés :
Gmail, Google Agenda, Teams, Discord, Notion, Drive… (cadre de connecteurs unifié).
3. **Il anticipe** — rappels conversationnels, briefing quotidien généré par l'agent,
détection de boucles ouvertes et d'engagements non tenus.
4. **Il centralise** — ObsiGate devient le point d'entrée unique prise de notes +
assistant ; tout le reste (desktop Tauri, MCP externe, PWA mobile) n'est qu'un
accès à *ce même* cerveau.
Le modèle économique de la conception : **Muse/Snow/ChatGPT-Memory rendent l'effet
« l'assistant me connaît » ; ici la mémoire est auditable (markdown dans le vault,
audit log, dédupli, réversibilité) et les données ne quittent jamais le stack ObsiGate
sauf appel explicite aux API des services que Bruno a connectés lui-même.**
---
## 2. Principes directeurs (non négociables)
| # | Principe | Conséquence technique |
|---|---|---|
| P1 | **Autonomie du stack** | Tout vit dans le conteneur `obsigate` + son volume `data/`. Aucun nouveau conteneur runtime (Ollama est retiré — voir §9). |
| P2 | **Zéro framework d'agent externe** | Décision déjà prise en #92 : boucle maison `backend/agent/loop.py`. L'Assistant V2 l'étend, ne la remplace pas. |
| P3 | **Un outil = un `@tool`** | Toute nouvelle capacité passe par le registre typé (`backend/tools/registry.py`) : risque, scope, rate-limit, confirmation, audit, labels + i18n. |
| P4 | **Mémoire = markdown d'abord** | Les artefacts durables (profil, insights, briefings, rappels) sont des notes du vault ; la base SQLite n'est que l'index reconstructible. |
| P5 | **Humain dans la boucle** | `WRITE`/`DANGEROUS` exigent confirmation (#91/#92) ; un fait de confiance faible reste `pending` tant que l'utilisateur ne le valide pas. |
| P6 | **Secrets jamais committés** | Pattern existant : `data/api_keys.json` / `data/connector_secrets.json` (0600) ou variables `OBSIGATE_*` dans `.env` (`env_file`, jamais dans le compose). |
| P7 | **i18n FR/EN systématique** | Toute clé d'interface (`ai.step.*`, config connecteurs, cartes mémoire) existe dans les deux locales. |
| P8 | **Le desktop Tauri reste fonctionnel** | Les connecteurs se dégradent silencieusement hors ligne (capability = absente si pas de token) ; aucune dépendance à un service réseau au démarrage. |
| P9 | **Sécurité existante réutilisée** | `_resolve_safe_path()`, `require_auth`/`require_vault_access`, SSRF guards (`validate_webhook_url`, `is_safe_target`), `secret_redactor` avant tout appel LLM. |
---
## 3. Inventaire de l'existant (socle sur lequel on bâtit)
Constat clé : **l'ossature « agent » existe déjà**. Ce qui manque, c'est la mémoire
long terme, les connecteurs, la couche proactive et l'ordonnancement de haut niveau.
| Brique | Fichier(s) | État | Rôle dans la V2 |
|---|---|---|---|
| Boucle agent multi-étapes + tool calling natif | `backend/agent/loop.py` (`run_agent`), `backend/ai_chat.py` (payload `tools`/`tool_choice`, conversion Gemini, fallback si refus) | ✅ livré (#91/#92) | Moteur de l'assistant V2 — inchangé |
| Registre d'outils typé | `backend/tools/registry.py` (`@tool`, `ToolRisk` READ/WRITE/DANGEROUS, `ToolScope` IN_APP/MCP, confirmation auto si non-READ, rate-limit, audit, schéma OpenAI) | ✅ | Cadre d'insertion de **tous** les nouveaux outils |
| Outils vault (~26) | `backend/tools/service.py`, `backend/services/*` (files, mutations, search, vaults, duplicates…) | ✅ | Outils natifs de l'assistant |
| Recherche TF-IDF + sémantique embeddings (RRF) | `backend/search.py`, `backend/semantic_search.py`, `requirements-semantic.txt` (sentence-transformers **local, optionnel**, FAISS) | ✅ (#70) | Retrieval de la mémoire épisodique ; remplace le besoin Ollama embeddings |
| Historique de conversation persistant | `backend/ai_history.py` (`data/sessions`, par utilisateur) | ✅ (#95) | **Mémoire épisodique** brute — source de la consolidation |
| Serveur MCP externe | `backend/mcp/server.py` (Streamable HTTP `/mcp`, propose/apply) | ✅ (#79) | Même registre exposé — les outils V2 en profitent gratis |
| Scheduler type cron | `backend/scheduler.py` (tick asyncio 60 s, `data/scheduled_tasks.json`, `interval_hours`/`daily_time`/`once_at`, actions `create_file`/`append_to_file`/`notify`) + outils `create/list/delete/run_scheduled_task` (`backend/tools/scheduled.py`) | ✅ (#170) | Socle des rappels & briefing — à étendre (§8) |
| Notifications externes | `backend/notify.py` (canaux **Discord webhook, Telegram, SMTP, webhook générique**, triggers, secrets 0600) + outil `notify_external` | ✅ (#168) | Bras de sortie des rappels/briefing |
| Web Push PWA | `backend/push.py` (VAPID) + `frontend/sw.js` | ✅ (#67) | Deuxième canal de rappel, sans dépendance externe |
| Sources connectées (premier précédent) | `backend/tools/connected.py` (Gitea/GitHub, READ), page Configurations #103, `backend/tools/secrets.py` | ✅ (#92/#103) | Patron à généraliser en cadre connecteurs (§7) |
| Web : recherche + fetch + crawl | `backend/tools/web.py` (Tavily/Brave/SerpAPI/Exa/SearXNG, `fetch_url`), `backend/tools/crawler.py` (`crawl_site`) | ✅ | Outils « connaissances hors vault » |
| Production de documents | `backend/tools/documents.py` (`create_xlsx/docx/csv/pdf`) | ✅ | Actions de l'assistant |
| Doublons | `backend/tools/duplicates.py` (`find_duplicates`, `merge_duplicate_notes` DANGEROUS) | ✅ (#166) | Hygiène de la mémoire |
| Skills / slash-commandes | `backend/skills.py` (`/cmd`, `data/skills.json`) | ✅ | Vecteur naturel des prompts de consolidation & briefing |
| Cache SQLite | `backend/tools/webcache.py` (précédent `sqlite3` stdlib dans `data/`) | ✅ | Patron du store mémoire (§5) |
| Fournisseurs LLM | `backend/ai.py` (`PROVIDERS` : deepseek, openrouter, gemini, nvidia, qwencloud, xiaomi, mistral, ollama), `reload_ai_config()`, clés `data/api_keys.json` + fallback env | ✅ | Garder le multi-provider ; **retirer le conteneur ollama** (§9) |
| Audit + redaction | `backend/audit.py`, `backend/tools/audit.py`, `backend/secret_redactor.py` | ✅ | Traçabilité mémoire/connecteurs |
**Gaps** (→ §5–§8) : mémoire sémantique (faits) · miroir markdown · consolidation ·
cadre connecteurs générique + OAuth2 · Gmail/Calendar/Teams/Discord(in)/Notion/Drive ·
rappels conversationnels · agent programmé (briefing) · insights proactifs.
---
## 4. Architecture cible
```
┌────────────────────────────────────────────┐
│ FRONTEND (SPA + PWA + Tauri) │
│ Assistant panel · Cartes mémoire · Config │
│ « Sources connectées » · Briefings │
└───────────────┬────────────────────────────┘
│ REST + SSE (existant)
┌──────────────────────────────────────▼─────────────────────────────────────┐
│ BACKEND obsigate (1 conteneur) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ agent/loop.py│◄──│ tools/registry.py│◄──│ TOUS les outils V2 (@tool) │ │
│ │ (run_agent) │ │ risque/scope/ │ │ vault · web · docs (exist.)│ │
│ └──────┬───────┘ │ audit/limit │ │ memory.* (nouveau §5) │ │
│ │ └──────────────────┘ │ connector.* (§7) │ │
│ ┌──────┴────────┐ │ reminder.* (§8) │ │
│ │ ai_chat.py │ │ scheduled.* (étendu) │ │
│ │ providers API │ └────────────────────────────┘ │
│ └──────┬────────┘ │
│ │ │
│ ┌──────▼───────────┐ ┌────────────────────┐ ┌───────────────────────┐ │
│ │ scheduler.py │ │ memory/ │ │ connectors/ │ │
│ │ tick 60 s │ │ store SQLite │ │ base OAuth2+PKCE │ │
│ │ + agent_prompt │─►│ (faits) │ │ gmail/gcal/teams/ │ │
│ │ (nouv. action) │ │ consolidator │ │ discord/notion/drive │ │
│ └──────┬───────────┘ └─────────┬──────────┘ └───────────┬───────────┘ │
│ │ notify.broadcast │ miroir markdown │ HTTPS API │
│ │ + Web Push ▼ ▼ │
│ ┌──────▼──────────┐ ┌────────────────────┐ ┌─────────────────────┐ │
│ │ Vault Obsidian │ │ Vault Obsidian │ │ Services externes │ │
│ │ (5 volumes NFS) │ │ 99_Assistant/*.md │ │ (Google/MS/Discord) │ │
│ └─────────────────┘ └────────────────────┘ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
```
Nouveaux modules backend (alignés sur l'organisation existante) :
```
backend/
memory/
store.py # SQLite data/memory.sqlite3 — CRUD faits + runs
recall.py # fusion recall: facts + sémantique (#70) + épisodes (ai_history)
consolidator.py # job « dreaming » nocturne (prompt + parsing + dédup)
mirror.py # rendu markdown 99_Assistant/ + parse des edits humains
connectors/
base.py # ConnectorInterface: id, scopes, authorize_url, exchange,
# refresh, capabilities(), call() (retry + rate-limit + audit)
oauth.py # flux code + PKCE, redirect /api/connectors/{id}/callback
registry.py # registre des connecteurs installés (comme tools/registry)
gmail.py gcal.py teams.py discord.py notion.py drive.py
routers/
connectors.py # CRUD admin: installation, statut, test de connexion
memory.py # API faits (liste/édit/suppression/validation pending)
tools/
memory.py # @tool memory.* (§5.4)
reminders.py # @tool reminder.* (§8.1)
connectors.py # @tool par capability connecteur (§7.4)
```
---
## 5. Mémoire long terme — #192 (socle), #193 (miroir), #194 (consolidation)
### 5.1 Modèle de données (SQLite, `data/memory.sqlite3`)
Précédent : `webcache.py`. Une seule table applicative + une table d'audit des runs.
```sql
CREATE TABLE facts (
id TEXT PRIMARY KEY, -- f-<uuid4>
user_id TEXT NOT NULL,
subject TEXT NOT NULL, -- ex: "santé", "Projet FlowDeck", "bruno"
predicate TEXT NOT NULL, -- ex: "rdv", "préférence", "décision"
value TEXT NOT NULL, -- contenu FR court (< 500 car.)
kind TEXT NOT NULL CHECK (kind IN
('preference','decision','entity','event','habit','goal','open_loop')),
confidence REAL NOT NULL DEFAULT 0.5, -- 0..1 ; < 0.7 → status pending
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('pending','confirmed','rejected','expired')),
source TEXT NOT NULL, -- 'consolidation' | 'user' | 'chat'
source_ref TEXT, -- session id ou chemin de note
supersedes TEXT, -- id du fait remplacé (dédup temporel)
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL, -- reinforcement par usage
expires_at TEXT -- nullable (ex: rdv passé)
);
CREATE INDEX idx_facts_subject ON facts(user_id, subject);
CREATE INDEX idx_facts_status ON facts(user_id, status);
CREATE TABLE consolidation_runs (
id TEXT PRIMARY KEY, started_at TEXT, finished_at TEXT,
sessions_consumed INTEGER, notes_changed INTEGER,
facts_added INTEGER, facts_updated INTEGER, error TEXT
);
```
Règles :
- **Dédup temporel** : un nouveau fait `(subject, predicate)` avec valeur différente
crée une ligne et marque l'ancienne `superseded` (via `supersedes`, status `expired`).
Historique conservé, recall = dernier `confirmed` non expiré.
- **Renforcement** : `last_seen_at` bump quand le fait est réaffirmé en chat ;
les facts non revus depuis N jours (180) et non `decision` deviennent `expired`.
- **PII santé/famille** : `consolidator` force `status='pending'` (validation humaine
via cartes) si la note source appartient aux dossiers sensibles configurables
(`OBSIGATE_MEMORY_REVIEW_FOLDERS`, ex. `Santé,Famille`).
- Backup : `data/` étant déjà dans le cycle de backups existant, rien de neuf.
### 5.2 Miroir markdown dans le vault — #193
Dossier **`99_Assistant/`** (créé à la première activation, dans le vault principal
configurable `OBSIGATE_MEMORY_VAULT=Main`, dossier racine configurable) :
| Fichier | Contenu | Écrit par | Éditable par Bruno |
|---|---|---|---|
| `Profil.md` | Préférences/habitus/goals confirmés, groupés par sujet (H2) | mirror (consolidation) | ✅ — les edits sont respectés (§5.3 règles de merge) |
| `Insights.md` | Détections proactes #201 (boucles ouvertes, engagements, patterns), horodatées | mirror | ✅ (suppression = `rejected` en base) |
| `Rappels.md` | Table des rappels actifs (liens vers tâches planifiées) | mirror scheduler | lecture seule (UI dédiée) |
| `Briefings/YYYY-MM-DD.md` | Briefing quotidien #200 | agent programmé | ✅ (archivage, jamais régénéré) |
| `Journal.md` | Dernières consolidations (nb faits ajoutés/validés) | mirror | lecture seule |
Le miroir utilise des **balises HTML de section** :
```markdown
<!-- obsigate:managed:preferences -->
…bloc régénéré…
<!-- /obsigate:managed:preferences -->
```
Règle de merge : à chaque écriture, le mirror compare le contenu précédent généré
(hash stocké en base) avec le contenu actuel du fichier. Si l'utilisateur a modifié
l'intérieur d'une section gérée → la modification est **re-parse en faits `source='user'`**
(confiance 1.0, status confirmed) puis la section est régénérée à partir de la base.
Tout ce qui est hors balises est laissé intact. C'est le mécanisme qui rend la mémoire
*auditable et contestable* — l'anti-boîte-noire.
### 5.3 Consolidation nocturne (« dreaming ») — #194
Job planifié via le scheduler existant (§8.3), action `agent_prompt` dédiée :
1. **Collecte incrémentale** : sessions `ai_history` créées/modifiées depuis le dernier
run + fichiers `.md` modifiés depuis le dernier run (via l'index/watcher mtime) —
plafonds : 200 fichiers × 30 Ko (comme bookslm), contenu `redact_file_content()` avant LLM.
2. **Extraction** (1 appel LLM, provider par défaut = cheap model) : prompt système
d'extraction → JSON `[{subject, predicate, value, kind, confidence}]` validé Pydantic ;
rejet des valeurs non sourcées.
3. **Réconciliation** (déterministe, stdlib) : dédup/supersede par `(subject,predicate)`,
plancher de confiance, dossiers sensibles → pending.
4. **Écriture** : `facts` + `mirror.py` → sections gérées de `Profil.md`.
5. **Cartes de validation** : les faits `pending` deviennent des cartes dans le panneau
Assistant (« Valider / Modifier / Oublier »), persistées jusqu'à action.
Coût : 1–3 appels API/nuit (deepseek-chat ≈ quelques centimes). Budget max :
`OBSIGATE_CONSOLIDATION_MAX_TOKENS` (défaut 30 000/jour) pour bornes dures.
### 5.4 Outils mémoire (registés `@tool`) — #192
| Outil | Risque | Description (schéma) |
|---|---|---|
| `memory_recall` | READ | `{query, subject?, kind?}` → faits confirmed pertinents + fusion RRF avec sémantique #70 sur le vault. Injecté **automatiquement** dans le prompt système de chaque chat (voir §5.5). |
| `memory_save` | WRITE | `{subject, predicate, value, kind, confidence=0.9}` — le modèle propose, la carte de confirmation affiche le fait ; Bruno valide → `source='chat'`. |
| `memory_update` | WRITE | `{fact_id, value | status}` — correction/revalidation. |
| `memory_forget` | DANGEROUS | `{fact_id}` — status `rejected`, supprimé du miroir, tracé à l'audit. |
| `profile_get` | READ | `{section?}` → contenu courant de `Profil.md` (plus riche que le recall brut). |
### 5.5 Injection dans le contexte (le « ça me connaît » quotidien)
Au démarrage de chaque session agent (`bookslm_routes` → futur endpoint `/agent`),
construction d'un bloc mémoire coiffant le prompt système, budget ≤ 2 500 tokens :
```
<memoried-context>
Profil (résumé géré) : préférences, habitudes, objectifs actifs…
Faits saillants liés au sujet : <memory_recall(query=requête utilisateur)>
Rappels actifs : <n lignes de Rappels.md>
Dernier briefing : <3 puces>
</memoried-context>
```
Ce bloc est **visible** dans la section « étapes » du panneau (label « Mémoire »),
comme les autres étapes d'outils — pas de magie cachée.
---
## 6. Rattachement MCP / Desktop / Skills
- Tous les nouveaux outils `@tool` sont automatiquement exposés au **serveur MCP**
(`/mcp`, scopes IN_APP+MCP par défaut, propose/apply déjà gérés) → Claude Desktop /
Cursor bénéficient gratis de `memory.*`, `reminder.*`, `connector.*`.
- **Skills `/`** : nouvelles built-in `/profil` (montre Profil.md), `/rappel <texte>`
(crée un rappel), `/brief` (régénère le briefing du jour), `/consolider`
(déclenche le run de consolidation à la demande), `/connecteurs` (état des sources).
- **Desktop Tauri** : les connecteurs sans token enregistré n'apparaissent pas dans le
registre de schémas envoyés au LLM (`list_tools` filtré par capabilities actives) ;
zéro appel réseau au démarrage.
---
## 7. Connecteurs externes — #195 (cadre), #196–#198 (implémentations)
### 7.1 Cadre (`backend/connectors/`)
Décision : **remplacer la logique ad hoc de `connected.py` (Gitea/GitHub par variables
d'env) par un cadre général** — tout en gardant ces deux sources fonctionnelles
(migration interne, même UX « Sources connectées » #103 étendue).
Concepts :
- **Connector** = classe (id, label, docs URL, scopes OAuth, capabilities nommées).
- **Installation** = entrée `data/connectors.json` `{id, type, status, config (public),
created_at}` ; secrets dans `data/connector_secrets.json` (0600) ou
`OBSIGATE_CONNECTOR_SECRET_<ID>` — **même pattern strict que #168/#103**.
- **Auth** : soit *token statique* (Discord webhook, Notion key, GitHub), soit
**OAuth2 authorization-code + PKCE** (Google, Microsoft) :
`GET /api/connectors/{id}/authorize` → redirection → `GET /api/connectors/{id}/callback`
(échange + stockage `refresh_token` chiffré avec `data/secret.key` existant) →
`POST /api/connectors/{id}/test` (validation par un appel READ bon marché).
- **Refresh** transparent dans `call()` (anticipation du expiry, 1 retry sur 401).
- **Capabilities → tools** : chaque capability expose un `@tool` (dynamiquement
enregistré si le connecteur est installé), préfixé :
`gmail.search_messages`, `gcal.list_events`, `teams.list_chats`…
Risque par capability (lecture READ / écriture WRITE / envoi DANGEROUS).
- **Budget & limites** : rate-limit par connecteur via `tools/ratelimit.py`,
timeout httpx 15 s, retry backoff (pattern #92), cache SQLite des réponses GET
bornées (pattern `webcache.py`, TTL 5 min pour le contexte de chat).
- **Audit** : chaque `call()` journalisé (connecteur, capability, args tronqués,
latence, statut) via `tools/audit.py`.
### 7.2 Matrice des connecteurs cibles
| Connecteur | Auth | Capabilités (→ outil) | Risque | Phase |
|---|---|---|---|---|
| **Google — Calendar** | OAuth2 PKCE | `gcal.list_events` (période, requête), `gcal.get_event`, `gcal.create_event`, `gcal.update_event` | READ, READ, WRITE, WRITE | #196 |
| **Google — Gmail** | OAuth2 PKCE | `gmail.search_messages` (query `is:unread…`), `gmail.read_message`, `gmail.create_draft`, ~~`gmail.send`~~ | READ, READ, WRITE, 🔴 non exposé v1 | #196 |
| **Google — Drive** | OAuth2 PKCE | `drive.search`, `drive.read_file` (import md/docx → vault) | READ | #198 (rattache backlog #167) |
| **Microsoft — Teams / Graph** | Entra ID client, OAuth2 | `teams.list_chats`, `teams.read_messages`, `teams.send_message` | READ, READ, WRITE | #197 |
| **Microsoft — Outlook** | idem Graph | `outlook.search_messages`, `outlook.list_events` (calendriers persos MS) | READ | #197 (optionnel, même jeton) |
| **Discord** | webhook (sortie, ✅ #168) + **bot token lecture** (entrée, scopes `read`) | `discord.send` (existant via notify), `discord.list_messages`, `discord.search` (REST, pas de gateway websocket en v1) | WRITE, READ, READ | #198 |
| **Notion** | API key (user token) | `notion.search`, `notion.read_page`, `notion.append_blocks` | READ, READ, WRITE | #198 |
| **Slack** | webhook sortie + bot token lecture REST | `slack.send`, `slack.search_messages` | WRITE, READ | option future |
| **Gitea / GitHub** | token (existant) | migrés sous le cadre | READ | #195 (migration) |
| **Todoist / tasks externes** | token | — | — | hors v1 ; les tâches restent **dans le vault** (checklists) |
Règle transversale : **la prise de notes reste le vault**. Les connecteurs *nourrissent*
l'assistant (contexte) et reçoivent des *actions* (événement, brouillon, message) ; ils
ne remplacent jamais la note. C'est l'inverse des apps chat généralistes.
### 7.3 Utilisation des connecteurs par l'assistant
- L'agent choisit les outils selon les demandes (tool-calling existant, `tool_choice=auto`).
- **Contexte briefing** (§8.2) : agrège `gcal.list_events(jour)` + rappels + mails
non lus (si Gmail connecté) + notes modifiées → note de briefing.
- **Demande type** : « est-ce que j'ai une réponse du dentiste ? » →
`gmail.search_messages(from:...)` → cite le mail → propose la note.
- **Confidentialité** : le contenu des connecteurs entre dans la conversation mais
**jamais** dans la consolidation mémoire par défaut (seuls les faits *explicitement*
confirmés par l'utilisateur via `memory_save` entrent en base).
### 7.4 UI « Sources connectées » (#103 étendue)
Page Configurations : carte par connecteur (statut, scopes, bouton Connect/Test/
Déconnecter), journal des derniers appels (audit filtré), budget tokens. Textes FR/EN.
---
## 8. Rappels, briefing, agent programmé — #199 (rappels), #200 (agent+briefing)
### 8.1 Rappels conversationnels — #199
Le scheduler #170 sait déjà planifier `notify`/`create_file`. Les rappels = **action
typée de haut niveau**, pour que l'assistant puisse les gérer naturellement :
- Nouvelle action scheduler `reminder` : `{user_id, text, deliver: [channels…],
recurring: none|daily|weekly|cron}` → au due : `notify.broadcast` + Web Push +
append à `99_Assistant/Rappels.md` (historique `Rappels.md` → section « tenus »).
- Outils : `reminder_create` (WRITE, `{texte, quand (ISO ou relatif « in 3 weeks »
résolu côté serveur par la LLM→date), récurrence, canaux}`), `reminder_list` (READ),
`reminder_snooze` (WRITE, +`{task_id, delta}` — le snooze bump `last_snoozed_count`,
signal pour #201), `reminder_cancel` (WRITE).
- UX : panneau Assistant « Cloche » + section latérale dédiée ; les rappels dus sont
injectés en contexte mémoire (§5.5) pour que l'assistant puisse en parler de lui-même.
### 8.2 Agent programmé — nouveau type d'action `agent_prompt` — #200
Brique générique manquante : le scheduler doit pouvoir **lancer le run_agent** avec un
prompt, pas seulement des actions primitives.
- Sécurité de la tâche non interactive (pas de confirmation humaine possible) :
**whitelist de capacités** — READ sans limite ; WRITE restreint aux chemins
`99_Assistant/**` (le prompt d'un scheduled-agent ne peut écrire ailleurs) ;
DANGEROUS et `connectors.*` en écriture **interdits**. Paramètre
`allowed_extra_tools: []` pour élargir explicitement à la création.
- Plafonds : max 8 itérations d'outils, budget tokens par tâche, `last_error` +
broadcast `schedule_failure` (déjà en place), `last_run_at` persisté.
- Le **briefing du matin** = built-in : skill `/brief` + tâche `daily_time 07:00`
(fuseau `OBSIGATE_TZ`) → prompt agrégateur (§7.3) → écrit `Briefings/YYYY-MM-DD.md`
+ notification (canaux de l'utilisateur + Web Push).
- Autres prompts programmatifs naturels : « résumé hebdo des notes modifiées »,
« veille » (crawl d'une page + diff dans une note) — mais chacun reste une tâche
créée par l'utilisateur ou l'assistant, pas de magie par défaut.
### 8.3 Le job de consolidation passe par `agent_prompt`
#194 s'implémente comme tâche built-in `daily_time 23:30` + whitelist
`memory.*`/`profile_get` — l'infrastructure d'ordonnancement de #200 sert la mémoire.
(Ordre de livraison : #200 avant #194, cf. §10.)
---
## 9. Retrait d'Ollama — #191
Objectif utilisateur : ObsiGate ne doit pas hériter de l'infra (ni Ollama, ni Honcho) ;
et le conteneur Ollama (1,4 Go+ de poids, 2 modèles, RAM) ne sert plus.
1. `docker-compose.yml` : supprimer le service `ollama`, le volume `ollama_data`,
les vars `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, et la mention « port 11434 » de l'en-tête.
Idem `docker-compose.test-linux.yml` / `docker-compose.test-win.yml`.
2. `backend/ai.py` / `model_capabilities.py` : **conserver** le provider `ollama`
(option de configuration, utile au desktop hors-ligne qui pointe vers un ollama
local de l'utilisateur) mais retirer tout défaut pointant vers le réseau interne.
Les tests d'isolation : `OBSIGATE_*` ne référence plus `http://ollama:11434`.
3. Embeddings : par **sentence-transformers local optionnel** (déjà #70) — le mode
par défaut de l'Assistant V2 fonctionne **sans embeddings** (TF-IDF + RRF + faits
SQLite), les embeddings étant un raffinement auto-détecté (`SemanticIndex` existe,
avec fallback API provider si configurée).
4. Migration : `docker compose down && up -d` (règle .env connue P17/P29) ; supprimer
le volume `ollama_data` (procédure documentée dans le CHANGELOG + ROADMAP de la tache #191).
---
## 10. Phasage & IDs
> Chaque tranche est livrable indépendamment, CI verte + DoD `DELIVERY_WORKFLOW.md`,
> i18n FR/EN, tests, guide utilisateur `docs/GUIDES/`.
| ID | Tranche | Contenu | Effort | Dépend | Critères d'acceptation |
|---|---|---|---|---|---|
| #191 | **Purge Ollama** | compose ×3, docs, notes migration | 0,5 j | — | plus aucun conteneur ollama ; AI editor + agent OK sur deepseek/openrouter/gemini |
| #192 | **Mémoire socle** | `memory/store.py`, `recall.py`, 5 outils `@tool`, tests | 3–4 j | #191 | recall/save/update/forget fonctionnent en chat ; audit + confirmation OK |
| #193 | **Miroir markdown** | `mirror.py`, `99_Assistant/`, balises gérées, re-parse edits | 2–3 j | #192 | édition manuelle de Profil.md → faits `source='user'` ; réécritures idempotentes |
| #200 | **Agent programmé + briefing** | action `agent_prompt` (whitelist), skill `/brief`, tâches built-in | 3–4 j | #192 | briefing 07:00 → note + notif ; WRITE limité `99_Assistant/**` prouvé par tests |
| #194 | **Consolidation nocturne** | `consolidator.py`, incrémental, pending/cartes, budget tokens | 3–4 j | #200, #193 | 1 nuit test → ≥1 fait utile extrait ; aucun secret extrait (redaction test) ; budget respecté |
| #199 | **Rappels** | action `reminder`, 4 outils, UI cloche, snooze-count | 2–3 j | #200 | « rappelle-moi dans 3 semaines » → rappel réel ; snooze ≥3 → signal visible #201 |
| #195 | **Cadre connecteurs** | `connectors/` base+oauth+registry, UI Sources, migration Gitea/GitHub | 4–5 j | — | OAuth2 PKCE de bout en bout avec un provider de test ; secrets 0600 |
| #196 | **Google Calendar + Gmail** | capabilities §7.2, context briefing étendu | 3–4 j | #195 | agenda du jour cité dans briefing ; recherche mail via chat avec confirmation WRITE nulle |
| #197 | **Microsoft Teams/Outlook** | Graph client min, capabilities READ + send | 3–4 j | #195 | lire un chat Teams et y répondre via carte de confirmation |
| #198 | **Discord read + Notion + Drive** | capacités restantes §7.2 | 3–5 j | #195 | import page Notion → note vault ; `drive.read_file` OK |
| #201 | **Proactivité / insights** | détecteurs stdlib (open loops, snoozes, pourrissement TODO, récurrences) → `Insights.md` + suggestions en contexte | 3–4 j | #194, #199 | un scenario « note à faire > 21 jours » et « snooze ×3 » génèrent des insights réels et acceptables/refusables |
Ordre recommandé : `#191 → #192 → #193 → #200 → #194 → #199` (la mémoire et le
briefing rendent l'assistant utile **sans** connecteur) puis connecteurs `#195 → #196…`
et enfin `#201` (le coaching a besoin de la mémoire garnie).
---
## 11. Sécurité & confidentialité (rappel explicite)
- **Secrets** : uniquement `data/connector_secrets.json` (0600, chiffrés `secret.key`)
ou env `OBSIGATE_CONNECTOR_SECRET_*` — jamais le compose, jamais git (#168/#103).
- **SSRF** : toutes les URLs configurées par l'utilisateur passent
`validate_webhook_url`/`is_safe_target` ; les endpoints des providers OAuth sont
une allowlist codée (accounts.google.com, login.microsoftonline.com…).
- **Chiffrement au repos** optionnel : même pattern que #172 (backlog existant) —
si activé, s'applique à `connector_secrets.json` + `memory.sqlite3`.
- **Redaction** : `redact_file_content()` systématique avant LLM pour toute note
consolidée ; jamais le contenu brut des connecteurs dans les prompts hors
appel d'outil explicite.
- **Consentement par tâche planifiée** : les tâches `agent_prompt` listent leurs
capacités autorisées dans l'UI, avec journal `last_run` dépliable.
- **Effacement** : `memory_forget` + purge des sessions #95 + export de la base
(`GET /api/memory/export` JSON) → réversibilité complète RGPD-like.
---
## 12. Risques & arbitrages assumés
| Risque | Mitigation |
|---|---|
| Qualité variable de l'extraction de faits → mémoire « bruitée » | seuil confiance + pending/cartes ; dossiers sensibles → validation manuelle ; `last_seen` decay ; effacement facile |
| Coût LLM (consolidation + briefing quotidiens) | cheap model dédié par var `OBSIGATE_AGENT_BACKGROUND_MODEL` ; budgets durs en tokens ; aggregation déterministe (stdlib) avant LLM |
| Dérive du scheduler qui exécute des prompts non surveillés | whitelist capacités + budget itérations + plafond chemins ; journal d'audit par tâche |
| Élargissement du scope backend (main.py déjà volumineux) | nouveaux modules dédiés (`memory/`, `connectors/`) ; aucune logique dans `main.py` ; routers séparés |
| OAuth app Google/Microsoft à créer manuellement (client id) | guide `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN ; possible de rester sur tokens statiques Notion/GitHub sans projet cloud |
| Éditions humaines du miroir non re-parsables (markdown libre) | balises `managed` strictes ; sections hors-balises intouchables ; re-parse = faits, pas diff sémantique |
| Tauri desktop + connecteurs = surface réseau | capabilities absentes du registre tant que non installées ; offline-first global inchangé |
---
## 13. Définition de terminé (par tranche, conforme DoD dépôt)
Pour chaque ID : tests unitaires backend (+tests de non-régression pour tout bug),
tests JSDOM si UI, E2E Playwright si flux UI touché, ruff/mypy 0 erreur, i18n FR/EN,
labels `ai.step.*` pour tout nouvel outil, mise à jour `CHANGELOG.md` `[Unreleased]`,
`docs/ROADMAP.md` (statut + fiche `docs/features/assistant-v2-<slug>.md` par tranche),
guide utilisateur `docs/GUIDES/` si exposé à l'utilisateur, CI verte, commit conventionnel
référençant l'ID.
---
## Annexe A — Variables d'environnement nouvelles
```
# Mémoire
OBSIGATE_MEMORY_ENABLED=true
OBSIGATE_MEMORY_VAULT=Main # vault où vit 99_Assistant/
OBSIGATE_MEMORY_REVIEW_FOLDERS=Santé,Famille # forcent status=pending
OBSIGATE_CONSOLIDATION_MODEL= # cheap model, défaut = provider courant
OBSIGATE_CONSOLIDATION_MAX_TOKENS=30000
# Briefing / agent programmé
OBSIGATE_TZ=America/Toronto
OBSIGATE_BRIEFING_TIME=07:00
# Connecteurs (ou UI Sources connectées — stockage prioritaire)
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET # app Google Cloud (consent screen)
AZURE_CLIENT_ID / AZURE_CLIENT_SECRET # app Entra ID
OBSIGATE_NOTION_API_KEY=
OBSIGATE_DISCORD_BOT_TOKEN= # lecture REST uniquement
```
## Annexe B — Prompt de consolidation (extrait)
```
Tu extrais des faits durables à partir de notes et conversations récentes d'un
journal personnel. Pour chaque fait : subject, predicate, value (une phrase,
français), kind ∈ preference|decision|entity|event|habit|goal|open_loop,
confidence 0..1. Règles :
- Uniquement ce qui est explicitement affirmé ou fortement implicite ;
- Rien de sensible sans appui textuel direct (santé, finances, familles) ;
- Jamais de mot de passe, token, numéro personnel (signalés comme refusés) ;
- Si un fait contredit un fait existant, produit le nouveau avec note de supersede.
Sortie : JSON strict {facts: [...]} — aucun commentaire.
```
## Annexe C — Prompt du briefing (extrait)
```
Contexte fourni : agenda du jour (gcal), rappels dus, mails non lus importants
(gmail si connecté), notes modifiées hier, faits pending à valider.
Produis une note markdown Briefings/YYYY-MM-DD.md :
- Aujourd'hui : événements + rappels (liens vault si applicable)
- À trancher : 2–4 questions ouvertes détectées dans les notes récentes
- Mémoire : 1–3 faits nouveaux à valider (citez la source note)
Pas d'invention. Chaque affirmation cite sa source (chemin ou sujet connecteur).
```
+25
View File
@@ -178,6 +178,31 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
### Discuter d'un fichier
- **Bouton « Chat »** (💬, barre d'actions du document) : ouvre un panneau
latéral avec l'historique de la discussion et la saisie d'un message.
- Les nouveaux messages s'affichent en direct ; si le panneau est fermé, une
notification annonce l'envoi. L'historique est conservé par fichier.
- Le panneau **suit le document** : changer de fichier re-cible la discussion.
### Chat général
- Onglet **💬 Chat** (dernier de la sidebar, sous les onglets) : conversation
centrale, valable pour toute l'application, avec **pastille rouge** quand
de nouveaux messages arrivent.
- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables.
- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
cet onglet est actif.
- **🗑** (survol d'un message) : supprime votre message — un administrateur
peut supprimer celui de n'importe qui. Confirmation demandée, le retrait
est immédiat pour tout le monde.
- **Messages privés** : la rangée de pastilles au-dessus du fil propose
**Général** puis chaque utilisateur ; choisissez un destinataire pour une
conversation à deux, avec son propre compteur de non-lus.
- **Link preview** : coller une URL suffit — titre, description, image et
site s'affichent en carte cliquable sous le message (si le site répond).
---
## 7. Rechercher
+6
View File
@@ -216,6 +216,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -328,6 +329,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
| 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #191 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) |
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
---
+8 -24
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.56.0 | **Dernière mise à jour :** 2026-10-08
> **Version :** 2.61.0 | **Dernière mise à jour :** 2026-10-08
> **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)**
@@ -40,26 +40,6 @@
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager`
- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés)
- **Reste à faire :**
- [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule)
- [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers)
- [x] Rafraîchissement de la jump list après chaque ajout/retrait
- [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release
### 160. Premier lancement professionnel + section Configuration harmonisée
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes
- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02
- **Reste à faire :**
- [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno »)
- [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement
- [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px
- [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release
---
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
@@ -638,7 +618,7 @@
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
| 175 — Chat intégré | **Livré le 2026-10-08** (SSE, panneau latéral, historique, toast) | ✅ #169 |
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
@@ -709,7 +689,7 @@
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) — ✅ **livré le 2026-10-08** ([fiche](./features/file-chat-169.md) ; transport SSE #62, pas de 2ᵉ WebSocket) | 3-4 j | 🟢 | transport SSE #62 |
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
@@ -749,7 +729,11 @@
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) |
| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) |
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | Unreleased | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
| 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
| 192 | Chat — boîte de saisie auto-agrandissante + accusé de réception ✓✓ (retour live SSE, notification de lecture) · BUG-109 (image de tuile de lien) | 2.61.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
+224
View File
@@ -0,0 +1,224 @@
# Chat intégré par fichier — #169
- **Statut :** ✅ livré le 2026-10-08
- **Effort :** 3-4 j (réalisé en 1 session) | **Impact :** 🟢
- **Transport :** SSE existant (#62) — pas de second WebSocket
## Ce qui a été livré
### Backend
- **Store `backend/file_chat.py`** — historique JSON par paire
`(vault, path)` sous `data/chats/` :
- nom de fichier = SHA-256 de `vault\0path` (32 premiers hex) : aucun
séparateur de chemin utilisateur dans le nom → traversal impossible ;
- plafond **500 messages** par fichier (les plus vieux supprimés) ;
- texte tronqué à **4000 caractères** ;
- écriture atomique (tmp + move) ; fichier corrompu → historique vide
(fallback, pas de crash).
- **Routes `backend/routers/file_chat.py`** (tag OpenAPI « Files » dérivé) :
- `GET /api/file/{vault}/chat?path=…` → historique chronologique ;
- `POST /api/file/{vault}/chat` `{path, text}` → ajout + broadcast SSE ;
- garde commune : `require_auth` + `check_vault_access` +
`resolve_safe_path` (traversal → 403/500 via le handler `ServiceError`) ;
- `text` ou `path` vides → 400 ; vault inconnu → 404.
- **Schémas** `ChatMessageItem` / `ChatHistoryResponse` /
`ChatMessageResponse` dans `backend/schemas.py` (`response_model` sur les
deux routes).
- **Broadcast** : `sse_manager.broadcast("chat_message", {vault, path, message})`.
### Frontend
- **`frontend/js/filechat.js`** — panneau latéral :
- bouton 💬 (`.btn-chat`) dans le groupe nav de la toolbar fichier
(viewer.js), toggle ouvre/ferme ;
- historique rendu chronologiquement, bulles « mine » à droite,
`textContent` pour le corps (pas d'injection HTML) ;
- envoi POST optimiste + dédoublonnage par `id` à la réception SSE ;
- panneau fermé / autre fichier → **toast** « Nouveau message de … »
(silencier si c'est son propre message).
- **`frontend/js/sync.js`** — listener SSE `chat_message` avec import
dynamique de `filechat.js` (pas de cycle d'imports).
- **CSS** bloc `#169` : panneau fixe 340 px, **plein écran ≤ 768 px**,
variables CSS avec fallbacks, input 16 px (anti-zoom iOS).
- **i18n** : 10 clés `chat.*` dans `fr.json` et `en.json`.
## Tests
- `tests/test_file_chat.py` — 15 tests : roundtrip store, isolation par
fichier, plafond de rétention, troncature, traversal hashée, fichier
corrompu, routes (200/400/404/403), fixture autouse isolant `CHAT_DIR`.
- `tests/frontend/filechat.test.mjs` — 6 tests JSDOM : empty state, rendu
chronologique + classe `mine`, anti-injection HTML, append live + dédup
par id, isolation inter-fichiers, toggle. Locales réellement chargées
(fetch simulé vers `frontend/locales/`).
- Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`).
## #190 — Suivi du document, chat général, ordre des onglets (2026-10-08)
### A. Le panneau suit le document
- En-tête du panneau : `.file-chat-target` affiche le titre du document
(l'attribut `title` porte le chemin complet).
- `followFileChat(vault, path, title)` est appelé en tête de `renderFile()` :
panneau ouvert + autre document → re-cible (reload de l'historique) ;
panneau fermé → no-op. Le bouton 💬 transmet `data.title`.
### B. Chat général en onglet sidebar
- Onglet `#sidebar-tab-chat` (dernier, `message-circle`) + panneau
`#sidebar-panel-chat` (liste + 📎 + saisie + Envoyer, Entrée = envoyer).
- **Store** : rien de nouveau — les sentinelles `GLOBAL_VAULT="__global__"` /
`GLOBAL_PATH="general"` réutilisent `add_message`/`get_messages`.
- **Routes** : `GET/POST /api/chat`, `POST /api/chat/upload`,
`GET /api/chat/attachment/{name}`.
- **Pièces jointes** : allow-list d'extensions (11 : images + vidéos), 25 MB,
nom de stockage = UUID (le nom client n'est jamais utilisé), résolution
par comparaison au nom + allow-list (aucun chemin utilisateur).
- **Messages non lus** : timestamp de dernière visite dans
`localStorage['obsigate-chat-unread']`, comptage des messages plus récents
d'un autre auteur, pastille rouge `99+` sur l'onglet, effacée à l'ouverture.
- **URL** : linkification `http(s)://` au rendu (texte autour en `TextNode`,
donc pas d'injection).
- **SSE** : `vault === "__global__"` route vers la liste sidebar (append +
clear) ou vers badge + toast selon que l'onglet est actif.
### C. Ordre des onglets + filtre = recherche chat
- `index.html` : le bloc `.sidebar-filter` est **déplacé après** `.sidebar-tabs`.
- `switchSidebarTab` : placeholder `sidebar.filter_chat`, dispatch `chat`.
- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse).
## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08)
### A. Suppression d'un post
- `DELETE /api/chat/{id}` et `DELETE /api/chat/dm/{peer}/{id}` :
**auteur du message ou admin** (403 sinon), 404 si l'id n'existe pas.
- Store : `delete_message(vault, path, id)` — écriture atomique,
`False` si absent ; broadcast SSE `chat_deleted` `{vault, path, id}`.
- Frontend : bouton 🗑 (`.file-chat-del`, visible au survol) rendu dans
`_messageEl` uniquement si l'URL de suppression est fournie **et**
que l'utilisateur est auteur/admin ; `confirm()` natif ; retrait local
+ retrait en direct chez tous via `onChatDeleted` (relais `sync.js`).
### B. Messages privés 2 utilisateurs
- Store : `DM_VAULT = "__dm__"`, `dm_path(a, b)` = paire **triée** → les
deux participants lisent/écrivent le même document, indépendamment de
l'ordre.
- Routes : `GET /api/chat/users` (destinataires — usernames +
display_names, **sans soi-même**, aucun hash/mot de passe exposé),
`GET/POST /api/chat/dm/{username}` (404 utilisateur inconnu, 400 DM à
soi), `DELETE /api/chat/dm/{username}/{id}`.
- Frontend : rangée de pastilles `.chat-channels` (« Général » + un
bouton par utilisateur) au-dessus du fil ; `_channel` état courant,
`_channelKey`/`_channelUrl`/`_channelDelUrl` ; placeholder dédié
`chat.placeholder_dm` ; non-lus **par canal** (compteurs dans
`localStorage`, l'ancien mélange timestamp/compteur est devenu un
compteur unique `{"general": n, "dm:x": n}`, remis à zéro à l'ouverture,
pastille = total) ; les DM SSE routés par `_pairOf(path)`.
### C. Boîte d'édition compacte
- Bouton d'envoi réduit à une icône `send` (`.file-chat-send-icon`) dans
les deux formulaires (panneau document + sidebar) ; la saisie garde
toute la largeur. `safeCreateIcons()` à la fin du `_renderShell`.
### D. Link preview
- `build_preview(text)` (store) : 1ʳᵉ URL du texte → garde SSRF
`_assert_public_http_url` (host privé/loopback rejeté, schémas non
http refusés) → `httpx.get` 5 s, 512 Ko, `follow_redirects` (limite
httpx = 20) → parse OG par regex (`og:title` avec repli `<title>`,
`og:description`, `og:image`, `og:site_name`) →
`{url, title, description, image, site}`.
- **Best-effort** : n'importe quelle erreur (URL morte, timeout, garde)
→ `None`, le message passe quand même ; cache borné **200 entrées**.
- Frontend : `_previewEl` rend une carte cliquable (vignette lazy
`no-referrer`, site en capitales, titre/description tronqués 2 lignes)
sous le corps du message.
- Schéma : `ChatMessageItem.preview`.
### Tests #191
- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (8,
dont le happy path avec `httpx.get` mocké — un `max_redirects`
inexistante sur `httpx.get` levait un TypeError silencieusement avalé
par le `try`, testé par `test_build_preview_happy_path_parses_og`).
- JSDOM `filechat.test.mjs` +5 : carte preview, bouton supprimer
(droit auteur/admin), `onChatDeleted`, compteurs non-lus,
routage DM isolé du général (16 au total).
## #192 — Saisie auto-agrandissante, accusé de réception, vignette de lien (2026-10-08)
### A. Boîte de saisie qui grandit avec le texte
`<input type="text">` → `<textarea rows="1">` (panneau document créé dans
`_renderShell()`, sidebar statique dans `index.html`). `_autoGrow(el)`
pose `height: auto` puis `min(scrollHeight, 160)px` à chaque `input`, la
règle CSS `textarea.file-chat-input` ajoute `resize: none` +
`overflow-y: auto` (au-delà du plafond la zone défile) et la hauteur repasse
à une ligne après l'envoi. `Entrée` (sans `Maj`) est interceptée dans les
deux formulaires → envoi, `Maj+Entrée` garde le retour à la ligne. Le
placeholder de la sidebar est aussi appliqué à l'init (`t()`), le HTML ne
portant que le FR.
### B. Accusé de réception (✓ envoyé / ✓✓ lu)
- **Store** (`backend/file_chat.py`) : le document de conversation gagne
`read = {username: dernier_ts}` (`get_read()` / `mark_read()`), sous le
`_LOCK` du read-modify-write partagé avec l'ajout et la suppression.
- **API** : les trois `GET` d'historique renvoient `read` (schéma
`ChatHistoryResponse.read`) ; nouveau `POST /api/chat/read`
(`ChatReadResponse`) avec `_check_read()` — général = tout membre, DM =
pair uniquement (403 sinon), fichier = ACL vault + `resolve_safe_path` —
qui marque la lecture et diffuse `chat_read`
`{vault, path, user, ts, read}` en SSE.
- **Client** (`filechat.js`) : carte de lecture par surface (`_fileRead`,
`_channelRead`). Un message est « lu » dès qu'un **autre** participant a
un `ts ≥` celui du message (ma propre lecture ne compte pas).
`_messageEl()` affiche `✓`/`✓✓` (`.file-chat-status`, bleu accent quand
lu) ; `_applyRead()` fait basculer les indicateurs affichés ;
`_syncRead()` fusionne une carte reçue ; `_markRead(vault, path)` (POST
fire-and-forget) est émis à l'ouverture d'une conversation **et** à la
réception d'un message pendant qu'elle est affichée. `onChatRead()`
(relais `chat_read` ajouté dans `sync.js`) actualise en direct et
notifie l'expéditeur par toast `chat.read_by` quand un de ses messages
passe à ✓✓ — jamais pour son propre écho.
### C. BUG-109 — vignette des tuiles de lien
`img-src 'self' data: blob:` interdit toute `og:image` distante, et une
`og:image` relative était résolue contre l'origine d'ObsiGate (404).
`_proxy_image(img_url, page_url)` téléverse la vignette à l'envoi dans
`chat_uploads` via `save_attachment()` (allow-list + nom UUID) et renvoie
`/api/chat/attachment/<uuid>` — résolution `urljoin` contre la page,
garde SSRF réutilisée, plafond `PREVIEW_IMAGE_MAX` (2 Mo), dérivation de
l'extension depuis le `content-type`. Échec isolé → `image = ""`, la
carte reste (jamais de message perdu).
### Tests #192
- pytest `TestReadReceipts` (7) : roundtrip store, messages préservés,
`POST /api/chat/read` (enregistrement + restitution par le `GET`),
403 DM hors pair, 400 champs manquants, ACL vault, broadcast `chat_read`
(spy sur `sse_manager`) ; `TestLinkPreview` +2 (proxy same-origin avec
fichier écrit sur disque, dégradation sans vignette).
- JSDOM `filechat.test.mjs` +3 (19) : indicateur ✓/✓✓ (ma propre carte de
lecture exclue, aucun indicateur sur les messages des autres), bascule en
direct + notification + pas d'écho, textarea (tagName/rows, hauteur
pilotée, `Entrée` intercepté / `Maj+Entrée` non).
## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
`/api/events`, auth par cookie) existe déjà côté client dans `sync.js` ;
un WS par fichier aurait dupliqué reconnexion/heartbeat/auth pour zéro
bénéfice sur du messages-postés.
- **Pas d'événement dans le panneau de sync** (`_addEvent`) : les messages
de chat gonfleraient l'historique d'événements d'index sans valeur.
- **Tag OpenAPI** : pas de tag maison (`file-chat`) — non déclaré dans
`TAGS_METADATA`, ce qui cassait `test_used_tags_are_declared`. Les routes
`/api/file/*` sont dérivées automatiquement sous « Files ».
+114 -40
View File
@@ -812,46 +812,6 @@
role="navigation"
aria-label="Navigation des vaults"
>
<!-- Sidebar filter -->
<div class="sidebar-filter">
<div class="sidebar-filter-input-wrapper">
<i
data-lucide="filter"
class="sidebar-filter-icon"
style="width: 14px; height: 14px"
></i>
<input
type="text"
id="sidebar-filter-input"
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
autocomplete="off"
/>
<div class="sidebar-filter-actions">
<button
class="sidebar-filter-btn"
id="sidebar-filter-case-btn"
type="button"
title="Respecter la casse"
aria-label="Respecter la casse"
>
<span>Aa</span>
</button>
<button
class="sidebar-filter-btn"
id="sidebar-filter-clear-btn"
type="button"
title="Effacer le filtrage"
aria-label="Effacer le filtrage"
>
<i
data-lucide="x"
style="width: 14px; height: 14px"
></i>
</button>
</div>
</div>
</div>
<!-- Tab bar -->
<div
class="sidebar-tabs"
@@ -928,6 +888,61 @@
style="width: 18px; height: 18px"
></i>
</button>
<button
class="sidebar-tab"
id="sidebar-tab-chat"
role="tab"
aria-selected="false"
aria-controls="sidebar-panel-chat"
data-tab="chat"
title="Chat"
>
<i
data-lucide="message-circle"
style="width: 18px; height: 18px"
></i>
</button>
</div>
<!-- Sidebar filter (#190 C : sous les onglets ; sert de
recherche dans l'onglet Chat) -->
<div class="sidebar-filter">
<div class="sidebar-filter-input-wrapper">
<i
data-lucide="filter"
class="sidebar-filter-icon"
style="width: 14px; height: 14px"
></i>
<input
type="text"
id="sidebar-filter-input"
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
autocomplete="off"
/>
<div class="sidebar-filter-actions">
<button
class="sidebar-filter-btn"
id="sidebar-filter-case-btn"
type="button"
title="Respecter la casse"
aria-label="Respecter la casse"
>
<span>Aa</span>
</button>
<button
class="sidebar-filter-btn"
id="sidebar-filter-clear-btn"
type="button"
title="Effacer le filtrage"
aria-label="Effacer le filtrage"
>
<i
data-lucide="x"
style="width: 14px; height: 14px"
></i>
</button>
</div>
</div>
</div>
<!-- Vaults panel -->
@@ -1050,6 +1065,65 @@
</div>
</div>
<!-- General chat panel (#190 B) -->
<div
class="sidebar-tab-panel sidebar-chat-panel"
id="sidebar-panel-chat"
role="tabpanel"
aria-labelledby="sidebar-tab-chat"
>
<!-- #191 : rangée de canaux (Général + utilisateurs) -->
<div
id="sidebar-panel-chat-channels"
class="chat-channels"
role="tablist"
aria-label="Canaux de discussion"
></div>
<div
id="sidebar-panel-chat-list"
class="file-chat-list sidebar-chat-list"
></div>
<div id="sidebar-panel-chat-pending" class="sidebar-chat-pending"></div>
<form
id="sidebar-panel-chat-form"
class="file-chat-form sidebar-chat-form"
>
<button
type="button"
id="sidebar-panel-chat-attach"
class="file-chat-attach"
title="Joindre une image ou une vidéo"
aria-label="Joindre une image ou une vidéo"
>
<i
data-lucide="paperclip"
style="width: 15px; height: 15px"
></i>
</button>
<textarea
id="sidebar-panel-chat-input"
class="file-chat-input"
rows="1"
maxlength="4000"
placeholder="Écrire un message…"
autocomplete="off"
aria-label="Écrire un message"
></textarea>
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
<button
type="submit"
class="file-chat-send file-chat-send-icon"
title="Envoyer"
aria-label="Envoyer"
>
<i
data-lucide="send"
style="width: 15px; height: 15px"
></i>
</button>
</form>
</div>
<!-- User section (bottom of the sidebar, #112) -->
<div class="sidebar-user" id="sidebar-user" hidden>
<button
+2
View File
@@ -26,6 +26,7 @@ import { initDesktopIntegration, isTauriEnv } from './desktop.js';
import { initPush } from './push.js';
import { initPlugins } from './plugins.js';
import { populateVersions } from './config.js';
import { initSidebarChat } from './filechat.js';
// =========================================================================
// Initialization — mirrors the original app.js init() ordering
@@ -61,6 +62,7 @@ async function init() {
Legacy.initConfigModal();
Legacy.initAboutModal();
Sidebar.initSidebarFilter();
initSidebarChat(); // #190 B — formulaire du chat général (sidebar)
UI.initSidebarResize();
Utils.initEditor();
Auth.initLoginForm();
+8
View File
@@ -8,6 +8,7 @@ import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js';
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
import { openSidebarChat, filterChatMessages as _filterChat } from './filechat.js';
let _recentTimestampTimer = null;
let _recentFilesCache = [];
@@ -305,6 +306,7 @@ function switchSidebarTab(tab) {
recent: t("sidebar.filter_recent"),
saved: t("sidebar.filter_saved"),
ai: t("sidebar.filter_ai"),
chat: t("sidebar.filter_chat"),
};
filterInput.placeholder = placeholders[tab] || "";
}
@@ -315,6 +317,7 @@ function switchSidebarTab(tab) {
else if (tab === "recent") filterRecentFiles(query);
else if (tab === "saved") filterSavedSearches(query);
else if (tab === "ai") filterAIHistory(query);
else if (tab === "chat") _filterChat(query);
}
// Auto-load recent files when switching to the recent tab
if (tab === "recent") {
@@ -333,6 +336,11 @@ function switchSidebarTab(tab) {
if (filterInput) _aiQuery = filterInput.value.trim();
loadAISessionList();
}
// #190 B — Auto-load the general chat when entering its tab (fresh history;
// openSidebarChat clears the unread badge).
if (tab === "chat") {
openSidebarChat();
}
}
function initHelpModal() {
+795
View File
@@ -0,0 +1,795 @@
// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190)
//
// Deux surfaces, un module :
// 1. Panneau latéral du document (#169) — historique + saisie, suivi du
// document ouvert (followFileChat le re-cible à chaque renderFile).
// 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces
// jointes (image/vidéo/URL), indicateur de messages non lus, recherche
// via la barre de filtre de la sidebar.
//
// Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
import { api, AuthManager } from "./auth.js";
import { t } from "./i18n.js";
import { showToast } from "./ui.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
const GLOBAL_PATH = "general";
const DM_VAULT = "__dm__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
const MAX_INPUT_H = 160; // #192 — plafond de la boîte de saisie auto-agrandie
let _vault = null;
let _path = null;
let _title = "";
let _sending = false;
// État du chat sidebar (#190 B + #191 : canaux général / privé)
let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
let _channelMessages = [];
let _channelLoaded = false;
let _usersCache = null; // liste des destinataires DM (une seule requête)
let _pendingAttach = null;
// #192 — accusé de réception : {username: dernier_ts_de_lecture} par surface.
let _fileRead = {};
let _channelRead = {};
// ---------------------------------------------------------------------------
// Partagé
// ---------------------------------------------------------------------------
function _esc(s) {
return escapeHtml(String(s == null ? "" : s));
}
function _formatTime(ts) {
try {
return new Date(ts * 1000).toLocaleString(undefined, {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
});
} catch (_) {
return "";
}
}
function _currentUser() {
const u = AuthManager.getUser();
return (u && u.username) || "";
}
// #192 — chemin de la conversation du canal courant (général ou privé).
function _channelVaultPath() {
return _channel.type === "dm"
? { vault: DM_VAULT, path: _dmPath(_channel.user) }
: { vault: GLOBAL_VAULT, path: GLOBAL_PATH };
}
/** Paire DM triée côté client — même règle que ``dm_path()`` serveur. */
function _dmPath(user) {
return [_currentUser(), user].sort().join("|");
}
/**
* #192 — la boîte de saisie suit le contenu : hauteur auto, plafonné à
* ``MAX_INPUT_H`` (au-delà la zone défile).
*/
function _autoGrow(el) {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_H)}px`;
}
/** #192 — un message est lu dès qu'un AUTRE participant l'a ouvert. */
function _isRead(ts, readMap, me) {
return Object.keys(readMap || {}).some((u) => u !== me && Number(readMap[u]) >= Number(ts));
}
/**
* #192 — passe les indicateurs de mes messages (liste affichée) à ✓✓.
* Retourne le nombre de messages qui venaient de passer à « lu ».
*/
function _applyRead(list, readMap, me) {
if (!list || !readMap) return 0;
let flipped = 0;
list.querySelectorAll(".file-chat-msg.mine").forEach((el) => {
if (el.classList.contains("read")) return;
if (!_isRead(el.dataset.ts, readMap, me)) return;
el.classList.add("read");
const st = el.querySelector(".file-chat-status");
if (st) {
st.textContent = "✓✓";
st.title = t("chat.status_read");
}
flipped++;
});
return flipped;
}
/**
* #192 — fusionne *readMap* dans la conversation affichée à l'écran et
* actualise les indicateurs. Retourne le nombre de mes messages passés à lu.
*/
function _syncRead(vault, path, readMap) {
if (!readMap) return 0;
const me = _currentUser();
let flipped = 0;
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
_fileRead = Object.assign({}, _fileRead, readMap);
flipped += _applyRead(panel.querySelector(".file-chat-list"), _fileRead, me);
}
const key =
vault === DM_VAULT ? `dm:${_pairOf(path)}` : vault === GLOBAL_VAULT ? "general" : null;
const sidebar = document.getElementById("sidebar-panel-chat");
if (key && key === _channelKey() && sidebar && sidebar.classList.contains("active")) {
_channelRead = Object.assign({}, _channelRead, readMap);
flipped += _applyRead(_sidebarList(), _channelRead, me);
}
return flipped;
}
/**
* #192 — signale au serveur que la conversation est affichée (accusé de
* réception). Fire-and-forget : un réseau indisponible ne casse rien.
*/
async function _markRead(vault, path) {
try {
const data = await api("/api/chat/read", {
method: "POST",
body: JSON.stringify({ vault, path }),
});
_syncRead(vault, path, data && data.read ? data.read : {});
} catch (_) {
/* réseau indisponible : pas d'accusé de réception, ni d'erreur */
}
}
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
function _fillBody(body, text) {
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
parts.forEach((part) => {
if (!part) return;
if (/^https?:\/\//.test(part)) {
const a = document.createElement("a");
a.href = part;
a.textContent = part;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.className = "file-chat-link";
body.appendChild(a);
} else {
body.appendChild(document.createTextNode(part));
}
});
}
function _attachEl(att) {
if (!att || !att.url) return null;
if (att.kind === "image") {
const img = document.createElement("img");
img.src = att.url;
img.alt = att.name || "image";
img.loading = "lazy";
img.className = "file-chat-image";
return img;
}
if (att.kind === "video") {
const v = document.createElement("video");
v.src = att.url;
v.controls = true;
v.preload = "metadata";
v.className = "file-chat-video";
return v;
}
return null;
}
// #191 — suppression : auteur ou admin, avec confirmation native.
function _canDelete(m, currentUser) {
const u = AuthManager.getUser();
if (!u || !u.username) return false;
return u.role === "admin" || m.user === u.username;
}
async function _deleteMessage(delUrl, id, wrap) {
if (!window.confirm(t("chat.delete_confirm"))) return;
try {
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
wrap.remove();
} catch (_) {
showToast(t("chat.delete_error"), "error");
}
}
// #191 — carte de lien : site, titre, description, vignette.
function _previewEl(p) {
const card = document.createElement("a");
card.className = "file-chat-preview";
card.href = p.url;
card.target = "_blank";
card.rel = "noopener noreferrer";
if (p.image) {
const img = document.createElement("img");
img.className = "file-chat-preview-img";
img.src = p.image;
img.alt = "";
img.loading = "lazy";
img.referrerPolicy = "no-referrer";
card.appendChild(img);
}
const meta = document.createElement("div");
meta.className = "file-chat-preview-meta";
const site = document.createElement("div");
site.className = "file-chat-preview-site";
site.textContent = p.site || "";
const title = document.createElement("div");
title.className = "file-chat-preview-title";
title.textContent = p.title || p.url;
meta.appendChild(site);
meta.appendChild(title);
if (p.description) {
const desc = document.createElement("div");
desc.className = "file-chat-preview-desc";
desc.textContent = p.description;
meta.appendChild(desc);
}
card.appendChild(meta);
return card;
}
/**
* Render one message.
* *delUrl* (#191) is the DELETE endpoint base for this conversation; when
* provided the author/admin sees a trash button on their own messages.
* *readMap* (#192) drives the ✓/✓✓ receipt shown on my own messages.
*/
export function _messageEl(m, currentUser, delUrl, readMap) {
const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
wrap.dataset.id = m.id || "";
wrap.dataset.ts = m.ts || 0;
const meta = document.createElement("div");
meta.className = "file-chat-meta";
meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
if (mine) {
const read = _isRead(m.ts, readMap, currentUser);
const status = document.createElement("span");
status.className = "file-chat-status" + (read ? " read" : "");
status.textContent = read ? "✓✓" : "✓";
status.title = t(read ? "chat.status_read" : "chat.status_sent");
meta.appendChild(status);
}
if (delUrl && _canDelete(m, currentUser)) {
const del = document.createElement("button");
del.type = "button";
del.className = "file-chat-del";
del.title = t("chat.delete");
del.setAttribute("aria-label", t("chat.delete"));
del.textContent = "🗑";
del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap));
meta.appendChild(del);
}
const body = document.createElement("div");
body.className = "file-chat-body";
_fillBody(body, m.text);
wrap.appendChild(meta);
if (m.text) wrap.appendChild(body);
const att = _attachEl(m.attachment);
if (att) wrap.appendChild(att);
if (m.preview) wrap.appendChild(_previewEl(m.preview));
return wrap;
}
export function renderChatMessages(container, messages, currentUser, delUrl, readMap) {
container.innerHTML = "";
if (!messages || messages.length === 0) {
const empty = document.createElement("div");
empty.className = "file-chat-empty";
empty.textContent = t("chat.empty");
container.appendChild(empty);
return;
}
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
container.scrollTop = container.scrollHeight;
}
function _appendMessage(list, m, delUrl) {
const empty = list.querySelector(".file-chat-empty");
if (empty) empty.remove();
const id = m.id;
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
list.appendChild(_messageEl(m, _currentUser(), delUrl));
list.scrollTop = list.scrollHeight;
return true;
}
// ---------------------------------------------------------------------------
// 1. Panneau du document (#169)
// ---------------------------------------------------------------------------
function _panel() {
let el = document.getElementById(PANEL_ID);
if (!el) {
el = document.createElement("aside");
el.id = PANEL_ID;
el.className = "file-chat-panel hidden";
el.setAttribute("aria-label", t("chat.title"));
document.body.appendChild(el);
}
return el;
}
async function _load() {
const panel = _panel();
const list = panel.querySelector(".file-chat-list");
try {
const data = await api(
`/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`,
);
_fileRead = data.read || {};
renderChatMessages(list, data.messages || [], _currentUser(), null, _fileRead);
_markRead(_vault, _path); // #192 — accusé de réception
} catch (_) {
list.innerHTML = "";
const err = document.createElement("div");
err.className = "file-chat-empty";
err.textContent = t("chat.load_error");
list.appendChild(err);
}
}
function _renderShell(panel) {
panel.innerHTML = "";
const header = document.createElement("div");
header.className = "file-chat-header";
const titles = document.createElement("div");
titles.className = "file-chat-titles";
const title = document.createElement("h3");
title.textContent = t("chat.title");
titles.appendChild(title);
// #190 A — le document ciblé est affiché dans l'en-tête
const target = document.createElement("span");
target.className = "file-chat-target";
target.textContent = _title || _path || "";
target.title = _path || "";
titles.appendChild(target);
const close = document.createElement("button");
close.type = "button";
close.className = "file-chat-close";
close.title = t("chat.close");
close.setAttribute("aria-label", t("chat.close"));
close.textContent = "✕";
close.addEventListener("click", closeFileChat);
header.appendChild(titles);
header.appendChild(close);
const list = document.createElement("div");
list.className = "file-chat-list";
const form = document.createElement("form");
form.className = "file-chat-form";
// #192 — textarea auto-agrandie : on lit le message entier avant l'envoi.
const input = document.createElement("textarea");
input.className = "file-chat-input";
input.rows = 1;
input.placeholder = t("chat.placeholder");
input.maxLength = 4000;
input.setAttribute("aria-label", t("chat.placeholder"));
input.addEventListener("input", () => _autoGrow(input));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // Shift+Enter = retour à la ligne
_send(input);
}
});
const send = document.createElement("button");
send.type = "submit";
send.className = "file-chat-send file-chat-send-icon";
send.title = t("chat.send");
send.setAttribute("aria-label", t("chat.send"));
// #191 : icône compacte — la saisie garde toute la largeur
send.innerHTML = '<i data-lucide="send" style="width:15px;height:15px"></i>';
form.appendChild(input);
form.appendChild(send);
form.addEventListener("submit", (e) => {
e.preventDefault();
_send(input);
});
panel.appendChild(header);
panel.appendChild(list);
panel.appendChild(form);
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
}
export async function openFileChat(vault, path, title) {
_vault = vault;
_path = path;
_title = title || path || "";
const panel = _panel();
_renderShell(panel);
panel.classList.remove("hidden");
await _load();
const input = panel.querySelector(".file-chat-input");
if (input) input.focus();
}
export function closeFileChat() {
const panel = document.getElementById(PANEL_ID);
if (panel) panel.classList.add("hidden");
}
export function toggleFileChat(vault, path, title) {
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
closeFileChat();
return;
}
openFileChat(vault, path, title);
}
// #190 A — appelé à chaque renderFile : le panneau ouvert suit le document.
export function followFileChat(vault, path, title) {
const panel = document.getElementById(PANEL_ID);
if (!panel || panel.classList.contains("hidden")) return;
if (_vault === vault && _path === path) return;
openFileChat(vault, path, title);
}
async function _send(input) {
if (_sending) return;
const text = (input.value || "").trim();
if (!text) return;
_sending = true;
try {
const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, {
method: "POST",
body: JSON.stringify({ path: _path, text }),
});
input.value = "";
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
} catch (_) {
showToast(t("chat.send_error"), "error");
} finally {
_sending = false;
input.focus();
}
}
// ---------------------------------------------------------------------------
// 2. Chat sidebar (#190 B) + canaux privé #191
// ---------------------------------------------------------------------------
function _sidebarList() {
return document.getElementById("sidebar-panel-chat-list");
}
/** Clé de la conversation courante (unread + sélection). */
function _channelKey() {
return _channel.type === "dm" ? `dm:${_channel.user}` : "general";
}
/** URL d'API de base du canal courant. */
function _channelUrl() {
return _channel.type === "dm"
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
: "/api/chat";
}
/** URL de base de suppression du canal courant. */
function _channelDelUrl() {
return _channel.type === "dm"
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
: "/api/chat";
}
/** Rend la rangée de canaux : Général + un bouton par utilisateur. */
function _renderChannels(users) {
const holder = document.getElementById("sidebar-panel-chat-channels");
if (!holder) return;
holder.innerHTML = "";
const mk = (label, active, onClick, title) => {
const b = document.createElement("button");
b.type = "button";
b.className = "chat-channel" + (active ? " active" : "");
b.textContent = label;
if (title) b.title = title;
b.addEventListener("click", onClick);
holder.appendChild(b);
return b;
};
mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null));
(users || []).forEach((u) => {
mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username,
() => switchChannel(u.username), `@${u.username}`);
});
}
/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */
async function _loadUsers() {
if (_usersCache) return _usersCache;
try {
const data = await api("/api/chat/users");
_usersCache = Array.isArray(data) ? data : [];
} catch (_) {
_usersCache = [];
}
return _usersCache;
}
/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */
export async function openSidebarChat() {
const list = _sidebarList();
if (!list) return;
const users = await _loadUsers();
_renderChannels(users);
try {
const data = await api(_channelUrl());
_channelMessages = (data.messages || []).slice();
_channelLoaded = true;
_channelRead = data.read || {};
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl(), _channelRead);
_clearUnread();
const vp = _channelVaultPath();
_markRead(vp.vault, vp.path); // #192 — accusé de réception
} catch (_) {
list.innerHTML = "";
const err = document.createElement("div");
err.className = "file-chat-empty";
err.textContent = t("chat.load_error");
list.appendChild(err);
}
}
/** Change de canal (#191) : null = général, sinon l'username du pair. */
export function switchChannel(user) {
_channel = user ? { type: "dm", user } : { type: "general" };
const input = _sendInput();
if (input) {
input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder");
}
openSidebarChat();
}
/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */
export function filterChatMessages(query) {
const list = _sidebarList();
if (!list || !_channelLoaded) return;
const q = String(query || "").toLowerCase().trim();
const shown = q
? _channelMessages.filter((m) =>
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
)
: _channelMessages;
renderChatMessages(list, shown, _currentUser(), _channelDelUrl(), _channelRead);
}
function _sendInput() {
return document.getElementById("sidebar-panel-chat-input");
}
function _renderPending() {
const holder = document.getElementById("sidebar-panel-chat-pending");
if (!holder) return;
holder.innerHTML = "";
if (!_pendingAttach) return;
const chip = document.createElement("span");
chip.className = "file-chat-pending";
chip.textContent =
(_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe");
const x = document.createElement("button");
x.type = "button";
x.textContent = "✕";
x.setAttribute("aria-label", t("chat.attach_remove"));
x.addEventListener("click", () => {
_pendingAttach = null;
_renderPending();
});
chip.appendChild(x);
holder.appendChild(chip);
}
/** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */
export async function sendGeneralMessage() {
const input = _sendInput();
if (!input) return;
const text = (input.value || "").trim();
if (!text && !_pendingAttach) return;
const body = { text };
if (_pendingAttach) body.attachment = _pendingAttach;
try {
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
input.value = "";
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
_pendingAttach = null;
_renderPending();
const list = _sidebarList();
if (list) {
_channelMessages.push(data.message);
_appendMessage(list, data.message, _channelDelUrl());
}
input.focus();
} catch (_) {
showToast(t("chat.send_error"), "error");
}
}
/** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */
export async function pickAttachment() {
return new Promise((resolve) => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*,video/*";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.remove();
if (!file) return resolve(null);
try {
const form = new FormData();
form.append("file", file);
const res = await api("/api/chat/upload", { method: "POST", body: form });
_pendingAttach = res.attachment;
_renderPending();
resolve(_pendingAttach);
} catch (_) {
showToast(t("chat.attach_error"), "error");
resolve(null);
}
});
fileInput.click();
});
}
// --- Messages non lus (#190 B / #191) --------------------------------------
// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du
// canal → remise à zéro de sa clé.
function _readUnread() {
try {
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
} catch (_) {
return {};
}
}
function _writeUnread(u) {
try {
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
} catch (_) { /* quota */ }
}
function _clearUnread() {
const u = _readUnread();
u[_channelKey()] = 0;
_writeUnread(u);
_renderBadge(_totalUnread());
_renderChannels(_usersCache);
}
function _totalUnread() {
const u = _readUnread();
return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
}
function _renderBadge(count) {
const tab = document.getElementById("sidebar-tab-chat");
if (!tab) return;
let badge = tab.querySelector(".chat-unread-badge");
if (!count) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement("span");
badge.className = "chat-unread-badge";
tab.appendChild(badge);
}
badge.textContent = count > 99 ? "99+" : String(count);
}
/** Affiche le total des messages non lus (tous canaux confondus). */
export function refreshUnreadBadge() {
_renderBadge(_totalUnread());
}
/** Initialisation : formulaire + raccourcis clavier. */
export function initSidebarChat() {
const form = document.getElementById("sidebar-panel-chat-form");
if (!form) return;
const input = document.getElementById("sidebar-panel-chat-input");
if (input) {
input.placeholder = t("chat.placeholder"); // le HTML embarque le FR
input.addEventListener("input", () => _autoGrow(input)); // #192
}
form.addEventListener("submit", (e) => {
e.preventDefault();
sendGeneralMessage();
});
const attach = document.getElementById("sidebar-panel-chat-attach");
if (attach) attach.addEventListener("click", () => pickAttachment());
if (input) {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendGeneralMessage();
}
});
}
}
// ---------------------------------------------------------------------------
// SSE relay (appelé par sync.js sur l'événement `chat_message`)
// ---------------------------------------------------------------------------
export function onChatMessage(data) {
if (!data || !data.message) return;
// --- Canaux sidebar : général (#190) ou privé (#191) ---
if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
const isActive = key === _channelKey();
const panel = document.getElementById("sidebar-panel-chat");
const visible = panel && panel.classList.contains("active");
if (isActive) _channelMessages.push(data.message);
if (visible && isActive) {
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
_clearUnread();
// #192 — le canal est affiché : on accuse réception aussitôt.
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
} else if (data.message.user !== _currentUser()) {
_bumpUnread(key);
refreshUnreadBadge();
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
return;
}
// Chat d'un document (#169)
const panel = document.getElementById(PANEL_ID);
const isOpen =
panel && !panel.classList.contains("hidden") &&
_vault === data.vault && _path === data.path;
if (isOpen) {
const list = panel.querySelector(".file-chat-list");
if (list) _appendMessage(list, data.message);
// #192 — panneau affiché : accusé de réception immédiat.
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
return;
}
if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
/**
* #192 — SSE `chat_read` : quelqu'un vient d'ouvrir la conversation. Les
* indicateurs de MES messages passent à ✓✓ et, si un message change d'état,
* l'expéditeur est notifié.
*/
export function onChatRead(data) {
if (!data || !data.user || data.user === _currentUser()) return; // notre propre écho
const flipped = _syncRead(data.vault, data.path, data.read || { [data.user]: data.ts });
if (flipped) showToast(t("chat.read_by", { user: data.user }), "info");
}
/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */
function _pairOf(path) {
const me = _currentUser();
const parts = String(path || "").split("|");
return parts[0] === me ? parts[1] : parts[0];
}
function _bumpUnread(key) {
const u = _readUnread();
u[key] = (u[key] || 0) + 1;
_writeUnread(u);
}
/** Suppression d'un post (#191) — relayée par sync.js. */
export function onChatDeleted(data) {
if (!data || !data.id) return;
const sel = `[data-id="${CSS.escape(data.id)}"]`;
document.querySelectorAll(sel).forEach((el) => el.remove());
}
+4 -1
View File
@@ -6,6 +6,7 @@ import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRe
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js';
// ---------------------------------------------------------------------------
// Vault context switching
@@ -704,13 +705,14 @@ function initSidebarFilter() {
const caseBtn = document.getElementById("sidebar-filter-case-btn");
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
// Route the query to the active tab's own filter (#98/#99).
// Route the query to the active tab's own filter (#98/#99, chat #190 C).
const routeFilter = async (q) => {
const tab = state.activeSidebarTab;
if (tab === "vaults") await performTreeSearch(q);
else if (tab === "recent") filterRecentFiles(q);
else if (tab === "saved") filterSavedSearches(q);
else if (tab === "ai") filterAIHistory(q);
else if (tab === "chat") _filterChat(q);
else filterTagCloud(q);
};
const routeClear = async () => {
@@ -719,6 +721,7 @@ function initSidebarFilter() {
else if (tab === "recent") filterRecentFiles("");
else if (tab === "saved") filterSavedSearches("");
else if (tab === "ai") filterAIHistory("");
else if (tab === "chat") _filterChat("");
else filterTagCloud("");
};
+30
View File
@@ -120,6 +120,36 @@ export const IndexUpdateManager = (() => {
}
});
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
eventSource.addEventListener("chat_message", (e) => {
try {
const data = JSON.parse(e.data);
import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {});
} catch (err) {
console.error("SSE parse error:", err);
}
});
// #191 — suppression d'un post
eventSource.addEventListener("chat_deleted", (e) => {
try {
const data = JSON.parse(e.data);
import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
} catch (err) {
console.error("SSE parse error:", err);
}
});
// #192 — accusé de réception (un participant vient d'ouvrir la conversation)
eventSource.addEventListener("chat_read", (e) => {
try {
const data = JSON.parse(e.data);
import("./filechat.js").then((m) => m.onChatRead(data)).catch(() => {});
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
+10 -1
View File
@@ -16,6 +16,7 @@ import { t, getLocale } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
import { openAssistant } from './ai-fab.js';
import { toggleFileChat, followFileChat } from './filechat.js';
import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { createColourPicker } from './xlsx/color-picker.js';
import { buildCommandBar } from './xlsx/command-bar.js';
@@ -6896,6 +6897,8 @@ export function renderFile(data) {
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
// #190 A — an open chat panel follows the document being rendered.
followFileChat(data.vault, data.path, data.title);
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
@@ -7222,6 +7225,12 @@ export function renderFile(data) {
// Bookmark button — check if already bookmarked
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
// #169 — Chat intégré par fichier (panneau latéral)
const chatBtn = el("button", { class: "btn-action btn-chat", title: t("chat.button_title") }, [icon("message-square", 14), document.createTextNode(t("chat.button"))]);
chatBtn.addEventListener("click", () => {
toggleFileChat(data.vault, data.path, data.title);
});
// Check bookmark status and color the button
(async () => {
try {
@@ -7314,7 +7323,7 @@ export function renderFile(data) {
// spacer de groupe vide retire.
const navBtns = [];
if (data.is_markdown) navBtns.push(tocBtn);
navBtns.push(openNewWindowBtn, bookmarkBtn);
navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);
const editBtns = [];
if (!data.unsupported) editBtns.push(editBtn);
editBtns.push(sourceBtn);
+22
View File
@@ -319,6 +319,27 @@
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
"backups.title": "Backups",
"button.cancel": "Cancel",
"chat.attach_error": "Attachment upload failed",
"chat.channel_general": "General",
"chat.delete": "Delete this message",
"chat.delete_confirm": "Delete this message?",
"chat.delete_error": "Delete failed",
"chat.attach_remove": "Remove attachment",
"chat.button": "Chat",
"chat.button_title": "Discuss this file",
"chat.close": "Close the discussion panel",
"chat.empty": "No messages yet. Start the conversation!",
"chat.load_error": "Could not load the discussion",
"chat.new_message": "New message from {user}",
"chat.placeholder": "Write a message…",
"chat.placeholder_dm": "Private message to {user}…",
"chat.read_by": "{user} read your message",
"chat.send": "Send",
"chat.send_error": "Failed to send the message",
"chat.tab": "General chat",
"chat.status_read": "Read",
"chat.status_sent": "Sent",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "All",
"common.back": "Back",
@@ -1697,6 +1718,7 @@
"sidebar.file_icons": "Icons",
"sidebar.filter_instant": "Instant search",
"sidebar.filter_ai": "Filter AI history...",
"sidebar.filter_chat": "Search the chat...",
"sidebar.filter_recent": "Filter recent files...",
"sidebar.filter_saved": "Filter saved searches...",
"sidebar.filter_path": "Path filters",
+22
View File
@@ -319,6 +319,27 @@
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
"backups.title": "Backups",
"button.cancel": "Annuler",
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
"chat.channel_general": "Général",
"chat.delete": "Supprimer ce message",
"chat.delete_confirm": "Supprimer ce message ?",
"chat.delete_error": "Échec de la suppression",
"chat.attach_remove": "Retirer la pièce jointe",
"chat.button": "Chat",
"chat.button_title": "Discuter de ce fichier",
"chat.close": "Fermer le panneau de discussion",
"chat.empty": "Aucun message pour l'instant. Lancez la discussion !",
"chat.load_error": "Impossible de charger la discussion",
"chat.new_message": "Nouveau message de {user}",
"chat.placeholder": "Écrire un message…",
"chat.placeholder_dm": "Message privé à {user}…",
"chat.read_by": "{user} a lu votre message",
"chat.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général",
"chat.status_read": "Lu",
"chat.status_sent": "Envoyé",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "Tous",
"common.back": "Retour",
@@ -1697,6 +1718,7 @@
"sidebar.file_icons": "Icônes",
"sidebar.filter_instant": "Recherche instantanée",
"sidebar.filter_ai": "Filtrer l'historique IA...",
"sidebar.filter_chat": "Rechercher dans le chat...",
"sidebar.filter_recent": "Filtrer les fichiers récents...",
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
"sidebar.filter_path": "Filtres de chemin",
+379
View File
@@ -13748,3 +13748,382 @@ body.reading-mode .file-actions {
color: var(--text-muted);
font-size: 0.85rem;
}
/* ---------------------------------------------------------------------------
#169 — Chat intégré par fichier (panneau latéral)
--------------------------------------------------------------------------- */
.file-chat-panel {
position: fixed;
top: 0;
right: 0;
width: 340px;
max-width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary, #0d1117);
border-left: 1px solid var(--border, #21262d);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
z-index: 1500;
}
.file-chat-panel.hidden {
display: none;
}
.file-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #21262d);
}
.file-chat-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-chat-close {
border: none;
background: transparent;
color: var(--text-muted, #8b949e);
font-size: 1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.file-chat-close:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
color: var(--text-primary, #e6edf3);
}
.file-chat-list {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.file-chat-empty {
color: var(--text-muted, #8b949e);
font-size: 0.85rem;
text-align: center;
padding: 24px 8px;
}
.file-chat-msg {
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border, #21262d);
border-radius: 10px;
padding: 8px 10px;
max-width: 92%;
}
.file-chat-msg.mine {
align-self: flex-end;
background: var(--bg-hover, rgba(88, 166, 255, 0.12));
border-color: var(--accent, #58a6ff);
}
.file-chat-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.72rem;
color: var(--text-muted, #8b949e);
margin-bottom: 4px;
}
.file-chat-user {
font-weight: 600;
color: var(--accent, #58a6ff);
}
/* #192 — accusé de réception sur mes messages : ✓ envoyé, ✓✓ lu */
.file-chat-status {
color: var(--text-muted, #8b949e);
letter-spacing: -1px;
}
.file-chat-status.read {
color: var(--accent, #58a6ff);
}
.file-chat-body {
font-size: 0.88rem;
color: var(--text-primary, #e6edf3);
white-space: pre-wrap;
word-break: break-word;
}
.file-chat-form {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border, #21262d);
}
.file-chat-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* anti-zoom iOS */
font-family: inherit;
line-height: 1.4;
border: 1px solid var(--border, #21262d);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
color: var(--text-primary, #e6edf3);
}
/* #192 — textarea auto-agrandie (hauteur pilotée en JS, plafond ci-dessous) */
textarea.file-chat-input {
display: block;
resize: none;
overflow-y: auto;
max-height: 160px;
white-space: pre-wrap;
word-break: break-word;
}
.file-chat-input:focus {
outline: none;
border-color: var(--accent, #58a6ff);
}
.file-chat-send {
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--accent, #58a6ff);
color: #fff;
flex-shrink: 0;
}
.file-chat-send:disabled {
opacity: 0.6;
cursor: default;
}
@media (max-width: 768px) {
.file-chat-panel {
width: 100vw;
border-left: none;
}
}
/* ---------------------------------------------------------------------------
#191 — Chat : canaux, suppression, bouton compact, link preview
--------------------------------------------------------------------------- */
.chat-channels {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 10px 0;
}
.chat-channel {
border: 1px solid var(--border, #21262d);
border-radius: 999px;
background: var(--bg-secondary, #161b22);
color: var(--text-muted, #8b949e);
font-size: 0.78rem;
padding: 4px 10px;
cursor: pointer;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-channel:hover {
color: var(--text-primary, #e6edf3);
border-color: var(--accent, #58a6ff);
}
.chat-channel.active {
background: var(--accent, #58a6ff);
border-color: var(--accent, #58a6ff);
color: #fff;
}
/* Bouton d'envoi compact : une icône, la saisie garde la largeur */
.file-chat-send-icon {
padding: 6px 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Suppression d'un post */
.file-chat-del {
border: none;
background: transparent;
cursor: pointer;
font-size: 0.75rem;
padding: 0 2px;
opacity: 0;
transition: opacity 0.12s;
}
.file-chat-msg:hover .file-chat-del {
opacity: 0.7;
}
.file-chat-del:hover {
opacity: 1 !important;
}
/* Link preview (#191) */
.file-chat-preview {
display: flex;
gap: 10px;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--border, #21262d);
border-left: 3px solid var(--accent, #58a6ff);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
text-decoration: none;
color: inherit;
max-width: 100%;
}
.file-chat-preview:hover {
border-color: var(--accent, #58a6ff);
}
.file-chat-preview-img {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
}
.file-chat-preview-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-chat-preview-site {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent, #58a6ff);
}
.file-chat-preview-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.file-chat-preview-desc {
font-size: 0.75rem;
color: var(--text-muted, #8b949e);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* ---------------------------------------------------------------------------
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
--------------------------------------------------------------------------- */
.file-chat-titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.file-chat-target {
font-size: 0.72rem;
color: var(--text-muted, #8b949e);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tab {
position: relative;
}
.chat-unread-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 15px;
height: 15px;
padding: 0 3px;
border-radius: 8px;
background: #e5534b;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
line-height: 15px;
text-align: center;
pointer-events: none;
}
.sidebar-chat-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
}
.sidebar-chat-panel.active {
display: flex;
}
.sidebar-chat-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
.sidebar-chat-pending {
padding: 0 10px;
}
.sidebar-chat-form {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--border, #21262d);
}
.file-chat-attach {
border: 1px solid var(--border, #21262d);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
color: var(--text-muted, #8b949e);
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.file-chat-attach:hover {
color: var(--accent, #58a6ff);
border-color: var(--accent, #58a6ff);
}
.file-chat-pending {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 4px 0;
padding: 3px 8px;
border: 1px dashed var(--border, #21262d);
border-radius: 8px;
font-size: 0.75rem;
color: var(--text-muted, #8b949e);
}
.file-chat-pending button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.file-chat-image {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
margin-top: 6px;
display: block;
}
.file-chat-video {
max-width: 100%;
max-height: 240px;
border-radius: 8px;
margin-top: 6px;
display: block;
}
.file-chat-link {
color: var(--accent, #58a6ff);
word-break: break-all;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.56.0",
"version": "2.61.0",
"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": {
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM tests for the per-file chat panel (#169):
* `frontend/js/filechat.js`.
*
* Covers:
* - `renderChatMessages()`: chronological render + empty state + escaping
* - `onChatMessage()`: live append when open on the same file, dedupe by id,
* no duplicate append when the panel shows another file
*
* Usage: node tests/frontend/filechat.test.mjs
*/
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import { readFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dom = new JSDOM(
`<!DOCTYPE html><html><body><div id="toast-container"></div>
<div class="sidebar-tab-panel active" id="sidebar-panel-chat">
<div id="sidebar-panel-chat-list" class="file-chat-list"></div>
<div id="sidebar-panel-chat-pending"></div>
<form id="sidebar-panel-chat-form"><textarea id="sidebar-panel-chat-input"></textarea></form>
</div>
<button id="sidebar-tab-chat"></button>
</body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true },
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.HTMLElement = w.HTMLElement;
globalThis.CSS = w.CSS || { escape: (s) => s };
globalThis.localStorage = w.localStorage;
globalThis.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(cb, 16));
globalThis.cancelAnimationFrame = w.cancelAnimationFrame || clearTimeout;
globalThis.sessionStorage = w.sessionStorage;
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales");
// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map.
const apiStubs = new Map();
globalThis.fetch = (url) => {
const u = String(url);
if (u.includes("/static/locales/")) {
const name = u.split("/").pop();
try {
const body = readFileSync(path.join(LOCALES_DIR, name), "utf-8");
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(JSON.parse(body)) });
} catch (_) {
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
}
}
if (u.includes("/api/auth/me")) {
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) });
}
if (apiStubs.has(u)) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(apiStubs.get(u)) });
}
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ messages: [] }) });
};
const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
await initI18n();
// #191 — the delete right needs a logged-in identity
const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
AuthManager.getUser = () => ({ username: "bruno", role: "user" });
const mod = await import("../../frontend/js/filechat.js");
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted, onChatRead,
} = mod;
let pass = 0;
const cases = [];
function test(name, fn) {
cases.push([name, fn]);
}
function makeList() {
const list = document.createElement("div");
list.className = "file-chat-list";
return list;
}
test("renderChatMessages: empty/missing shows the empty state", async () => {
const list = makeList();
renderChatMessages(list, [], "bruno");
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
renderChatMessages(list, undefined, "bruno");
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
});
test("renderChatMessages: renders chronologically with mine class", async () => {
const list = makeList();
renderChatMessages(
list,
[
{ id: "1", user: "alice", text: "bonjour", ts: 1700000000 },
{ id: "2", user: "bruno", text: "salut !", ts: 1700000060 },
],
"bruno",
);
const msgs = list.querySelectorAll(".file-chat-msg");
assert.strictEqual(msgs.length, 2);
assert.strictEqual(msgs[0].querySelector(".file-chat-user").textContent, "alice");
assert.strictEqual(msgs[0].classList.contains("mine"), false);
assert.strictEqual(msgs[1].classList.contains("mine"), true);
assert.strictEqual(msgs[1].querySelector(".file-chat-body").textContent, "salut !");
});
test("renderChatMessages: message text is text (no HTML injection)", async () => {
const list = makeList();
renderChatMessages(list, [{ id: "1", user: "<img>", text: "<b>x</b>", ts: 1 }], "u");
assert.strictEqual(list.querySelector(".file-chat-body").innerHTML, "&lt;b&gt;x&lt;/b&gt;");
assert.strictEqual(list.querySelectorAll("b").length, 0);
});
test("onChatMessage: appends live message to the open panel", async () => {
await openFileChat("V", "note.md");
const panel = document.getElementById("file-chat-panel");
assert.strictEqual(panel.classList.contains("hidden"), false);
// Seed an empty state so we can assert its removal
const list = panel.querySelector(".file-chat-list");
renderChatMessages(list, [], "bruno");
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
assert.strictEqual(list.querySelector(".file-chat-empty"), null);
// Same id again → deduped
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
closeFileChat();
assert.strictEqual(document.getElementById("file-chat-panel").classList.contains("hidden"), true);
});
test("onChatMessage: ignores events for another file (no cross-file append)", async () => {
await openFileChat("V", "note.md");
const panel = document.getElementById("file-chat-panel");
const list = panel.querySelector(".file-chat-list");
renderChatMessages(list, [], "bruno");
onChatMessage({ vault: "V", path: "OTHER.md", message: { id: "x", user: "alice", text: "no", ts: 3 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
closeFileChat();
});
test("toggleFileChat: opens then closes on the same file", async () => {
await toggleFileChat("V", "t.md");
let panel = document.getElementById("file-chat-panel");
assert.strictEqual(panel.classList.contains("hidden"), false);
await toggleFileChat("V", "t.md");
panel = document.getElementById("file-chat-panel");
assert.strictEqual(panel.classList.contains("hidden"), true);
closeFileChat();
});
test("#190 A — followFileChat re-targets the open panel", async () => {
await openFileChat("V", "one.md", "Doc Un");
const panel = document.getElementById("file-chat-panel");
assert.strictEqual(panel.classList.contains("hidden"), false);
followFileChat("V", "two.md", "Doc Deux");
await new Promise((r) => setTimeout(r, 20));
const target = panel.querySelector(".file-chat-target");
assert.strictEqual(target.textContent, "Doc Deux");
assert.strictEqual(target.title, "two.md");
closeFileChat();
followFileChat("V", "three.md", "Doc Trois");
await new Promise((r) => setTimeout(r, 20));
assert.strictEqual(panel.querySelector(".file-chat-target").textContent, "Doc Deux");
});
test("#190 B — openSidebarChat renders the general history", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
});
test("#190 B — onChatMessage routes __global__ to the sidebar list", async () => {
const list = document.getElementById("sidebar-panel-chat-list");
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "salut tout le monde");
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
});
test("#190 C — filterChatMessages narrows by text/user", async () => {
onChatMessage({ vault: "__global__", path: "general", message: { id: "f1", user: "alice", text: "recette de pizza", ts: 10 } });
onChatMessage({ vault: "__global__", path: "general", message: { id: "f2", user: "bob", text: "deploiement docker", ts: 11 } });
const list = document.getElementById("sidebar-panel-chat-list");
filterChatMessages("pizza");
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "recette de pizza");
filterChatMessages("bob"); // la recherche couvre aussi l'auteur
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
filterChatMessages("");
assert.ok(list.querySelectorAll(".file-chat-msg").length >= 3);
});
test("#190 — URL rendered as a link, surrounding text stays plain", async () => {
const list = document.getElementById("sidebar-panel-chat-list");
onChatMessage({ vault: "__global__", path: "general", message: { id: "u1", user: "alice", text: "voici https://example.com/doc et du texte", ts: 20 } });
const msg = list.querySelector('[data-id="u1"]');
const a = msg.querySelector("a.file-chat-link");
assert.strictEqual(a.href, "https://example.com/doc");
assert.strictEqual(a.target, "_blank");
assert.ok(msg.textContent.includes("du texte"));
});
test("#191 — preview card rendered with site/title/description", () => {
const list = document.createElement("div");
renderChatMessages(list, [{
id: "p1", user: "alice", text: "voir https://example.com", ts: 40,
preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" },
}], "bruno");
const card = list.querySelector("a.file-chat-preview");
assert.ok(card, "carte preview présente");
assert.strictEqual(card.href, "https://example.com/");
assert.strictEqual(card.target, "_blank");
assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site");
assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc");
assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com");
renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno");
assert.strictEqual(list.querySelector("a.file-chat-preview"), null);
});
test("#191 — delete button hidden without delUrl, shown for own post", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno");
assert.strictEqual(list.querySelector(".file-chat-del"), null);
renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat");
assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible");
renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat");
assert.strictEqual(list.querySelector(".file-chat-del"), null);
});
test("#191 — onChatDeleted removes the message from any list", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } });
assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true);
onChatDeleted({ vault: "__global__", path: "general", id: "del1" });
assert.strictEqual(list.querySelector('[data-id="del1"]'), null);
});
test("#191 — unread counters accumulate then reset on open", async () => {
localStorage.removeItem("obsigate-chat-unread");
const panel = document.getElementById("sidebar-panel-chat");
panel.classList.remove("active");
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } });
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } });
let u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
assert.strictEqual(u.general, 2);
refreshUnreadBadge();
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2");
panel.classList.add("active");
await openSidebarChat();
u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
assert.strictEqual(u.general, 0);
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null);
});
test("#191 — DM routed by pair path, isolated from general", async () => {
const list = document.getElementById("sidebar-panel-chat-list");
const before = list.querySelectorAll(".file-chat-msg").length;
onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } });
const u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
assert.strictEqual(u["dm:alice"], 1);
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
});
// --- #192 : accusé de réception + boîte de saisie auto-agrandie --------------
test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {});
let st = list.querySelector(".file-chat-status");
assert.strictEqual(st.textContent, "✓");
assert.strictEqual(st.classList.contains("read"), false);
// MY OWN read timestamp must never count as "the peer read it"
renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 });
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 });
st = list.querySelector(".file-chat-status");
assert.strictEqual(st.textContent, "✓✓");
assert.strictEqual(st.classList.contains("read"), true);
// messages of others carry no receipt
renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {});
assert.strictEqual(list.querySelector(".file-chat-status"), null);
});
test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {});
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
const container = document.getElementById("toast-container");
const before = container.children.length;
onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } });
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓");
assert.ok(container.children.length > before, "notification de lecture affichée");
// notre propre écho ne notifie pas
const same = container.children.length;
onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } });
assert.strictEqual(container.children.length, same);
});
test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA");
await openFileChat("V", "grow.md");
const panel = document.getElementById("file-chat-panel");
const input = panel.querySelector(".file-chat-input");
assert.strictEqual(input.tagName, "TEXTAREA");
assert.strictEqual(input.rows, 1);
input.value = "ligne1\nligne2";
input.dispatchEvent(new w.Event("input", { bubbles: true }));
assert.ok(input.style.height, "hauteur pilotée en JS");
let prevented = false;
const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } });
input.value = ""; // rien à envoyer : on ne teste que le geste
input.dispatchEvent(ev);
assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté");
const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true });
let nlPrevented = false;
Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } });
input.dispatchEvent(nl);
assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne");
closeFileChat();
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
await fn();
pass++;
console.log(` ✓ ${name}`);
} catch (err) {
console.error(` ✗ ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
console.log(`\n${pass} passed`);
+1 -1
View File
@@ -39,7 +39,7 @@ test("viewer.js — group nav: TOC markdown-only, then pop-out, bookmark", () =>
const m = viewer.match(/const navBtns = \[\];([\s\S]*?)const editBtns/);
assert.ok(m, "navBtns block not found");
assert.match(m[1], /if \(data\.is_markdown\) navBtns\.push\(tocBtn\);/);
assert.match(m[1], /navBtns\.push\(openNewWindowBtn,\s*bookmarkBtn\);/);
assert.ok(m[1].includes("navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);"), "navBtns group order");
});
test("viewer.js — group edit = [edit, source, .ext, forge?, pretty?] in order", () => {
+481
View File
@@ -0,0 +1,481 @@
# tests/test_file_chat.py — Chat par fichier (#169)
"""Store + REST routes for the per-file chat history."""
import time
import pytest
from backend import file_chat as _store
def _login(client, username="admin", password="chab30") -> dict:
"""Log in against the auth-enabled ``admin_client`` fixture (#191)."""
resp = client.post("/api/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
@pytest.fixture(autouse=True)
def _isolated_chat_dir(tmp_path, monkeypatch):
"""Keep every test (store AND routes) out of the real ``data/chats``/
``data/chat_uploads``."""
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
monkeypatch.setattr(_store, "UPLOAD_DIR", tmp_path / "uploads")
class TestStore:
def test_get_messages_empty_when_missing(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
assert _store.get_messages("V", "a/b.md") == []
def test_add_then_get_roundtrip(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a/b.md", "bruno", "Bonjour !")
assert msg["user"] == "bruno"
assert msg["text"] == "Bonjour !"
assert msg["id"]
assert msg["ts"] <= time.time() + 1
got = _store.get_messages("V", "a/b.md")
assert len(got) == 1
assert got[0]["id"] == msg["id"]
def test_conversation_is_per_file(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
_store.add_message("V", "a.md", "u", "pour a")
_store.add_message("V", "b.md", "u", "pour b")
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["pour a"]
assert [m["text"] for m in _store.get_messages("V", "b.md")] == ["pour b"]
def test_path_traversal_never_escapes_chat_dir(self, tmp_path, monkeypatch):
"""The chat file name is hashed: '../../etc' cannot escape CHAT_DIR."""
chat_dir = tmp_path / "chats"
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
_store.add_message("V", "../../etc/passwd", "u", "x")
files = list(chat_dir.iterdir())
assert len(files) == 1
assert files[0].parent == chat_dir
assert "/" not in files[0].name and "\\" not in files[0].name
def test_retention_cap_drops_oldest(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
n = _store.MAX_MESSAGES + 25
for i in range(n):
_store.add_message("V", "a.md", "u", f"m{i}")
got = _store.get_messages("V", "a.md")
assert len(got) == _store.MAX_MESSAGES
assert got[0]["text"] == f"m{25}" # first 25 dropped
assert got[-1]["text"] == f"m{n - 1}"
def test_text_truncated_to_max(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
_store.add_message("V", "a.md", "u", "x" * (_store.MAX_TEXT + 500))
got = _store.get_messages("V", "a.md")
assert len(got[0]["text"]) == _store.MAX_TEXT
def test_corrupt_file_falls_back_to_empty(self, tmp_path, monkeypatch):
chat_dir = tmp_path / "chats"
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
chat_dir.mkdir(parents=True)
target = _store._chat_file("V", "a.md")
target.write_text("{not json", encoding="utf-8")
assert _store.get_messages("V", "a.md") == []
class TestRoutes:
def test_history_requires_existing_vault(self, client, test_vault_dir):
r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
assert r.status_code == 200
assert r.json() == {"messages": [], "read": {}}
def test_post_and_get_roundtrip(self, client, test_vault_dir):
r = client.post(
"/api/file/TestVault/chat",
json={"path": "note1.md", "text": "Vu hier ?"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["message"]["text"] == "Vu hier ?"
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
assert [m["text"] for m in r2.json()["messages"]] == ["Vu hier ?"]
def test_post_requires_text(self, client, test_vault_dir):
r = client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": " "})
assert r.status_code == 400
def test_post_requires_path(self, client, test_vault_dir):
r = client.post("/api/file/TestVault/chat", json={"path": "", "text": "hi"})
assert r.status_code == 400
def test_unknown_vault_404(self, client, test_vault_dir):
r = client.get("/api/file/Nope/chat", params={"path": "x.md"})
assert r.status_code == 404
def test_path_traversal_rejected(self, client, test_vault_dir):
r = client.get(
"/api/file/TestVault/chat", params={"path": "../../etc/passwd"}
)
assert r.status_code in (403, 500)
r2 = client.post(
"/api/file/TestVault/chat",
json={"path": "../../etc/passwd", "text": "x"},
)
assert r2.status_code in (403, 500)
@pytest.mark.parametrize("path", ["note1.md", "sub/deep/file.md"])
def test_history_per_path_isolated(client, test_vault_dir, path):
client.post("/api/file/TestVault/chat", json={"path": path, "text": "m"})
other = "note1.md" if path != "note1.md" else "note2.md"
r = client.get("/api/file/TestVault/chat", params={"path": other})
assert r.json()["messages"] == []
# --- #190 : chat général + pièces jointes -----------------------------------
class TestGlobalChat:
def test_history_empty_by_default(self, client, test_vault_dir):
r = client.get("/api/chat")
assert r.status_code == 200
assert r.json() == {"messages": [], "read": {}}
def test_post_and_get_roundtrip(self, client, test_vault_dir):
r = client.post("/api/chat", json={"text": "Bonjour à tous"})
assert r.status_code == 200
msg = r.json()["message"]
assert msg["text"] == "Bonjour à tous"
assert msg["user"] # authenticated user recorded
got = client.get("/api/chat").json()["messages"]
assert [m["id"] for m in got] == [msg["id"]]
def test_empty_text_rejected(self, client, test_vault_dir):
assert client.post("/api/chat", json={"text": " "}).status_code == 400
def test_general_chat_isolated_from_file_chats(self, client, test_vault_dir):
client.post("/api/chat", json={"text": "général"})
client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": "fichier"})
assert len(client.get("/api/chat").json()["messages"]) == 1
assert len(client.get("/api/file/TestVault/chat", params={"path": "note1.md"}).json()["messages"]) == 1
def test_unknown_attachment_rejected(self, client, test_vault_dir):
r = client.post("/api/chat", json={
"text": "pièce jointe",
"attachment": {"name": "nope.png", "url": "javascript:alert(1)", "kind": "image"},
})
assert r.status_code == 400
def test_external_url_attachment_accepted(self, client, test_vault_dir):
r = client.post("/api/chat", json={
"text": "un lien",
"attachment": {"name": "", "url": "https://example.com/x.png", "kind": "image"},
})
assert r.status_code == 200
assert r.json()["message"]["attachment"]["url"] == "https://example.com/x.png"
class TestAttachments:
def test_upload_and_serve_roundtrip(self, client, test_vault_dir):
files = {"file": ("shot.png", b"\x89PNG\r\n\x1a\nfake", "image/png")}
r = client.post("/api/chat/upload", files=files)
assert r.status_code == 200
att = r.json()["attachment"]
assert att["kind"] == "image"
assert att["mime"] == "image/png"
# served back with the right content type
r2 = client.get(f"/api/chat/attachment/{att['name']}")
assert r2.status_code == 200
assert r2.headers["content-type"].startswith("image/png")
def test_video_kind_detection(self, client, test_vault_dir):
files = {"file": ("clip.mp4", b"\x00\x00\x00\x18ftyp", "video/mp4")}
att = client.post("/api/chat/upload", files=files).json()["attachment"]
assert att["kind"] == "video"
def test_extension_not_allowed(self, client, test_vault_dir):
files = {"file": ("evil.exe", b"MZ...", "application/octet-stream")}
assert client.post("/api/chat/upload", files=files).status_code == 400
def test_empty_file_rejected(self, client, test_vault_dir):
files = {"file": ("empty.png", b"", "image/png")}
assert client.post("/api/chat/upload", files=files).status_code == 400
def test_attachment_traversal_rejected(self, client, test_vault_dir):
# A traversal/unknown name must never produce a media response: the
# route 404s, or the SPA catch-all answers HTML (200 + text/html).
for bad in ("../secret.png", "sub/evil.png", "..%2Fsecret.png", "nope.png"):
r = client.get(f"/api/chat/attachment/{bad}")
ct = r.headers.get("content-type", "")
assert not ct.startswith(("image/", "video/")), (bad, r.status_code, ct)
assert r.status_code in (404, 400, 422, 200), bad
def test_message_with_uploaded_attachment(self, client, test_vault_dir):
files = {"file": ("pic.webp", b"RIFF0000WEBP", "image/webp")}
att = client.post("/api/chat/upload", files=files).json()["attachment"]
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
assert r.status_code == 200
assert r.json()["message"]["attachment"]["kind"] == "image"
# --- #191 : suppression, messages privés, link preview ----------------------
class TestDelete:
def test_author_deletes_own_post(self, client, test_vault_dir):
msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"]
r = client.delete(f"/api/chat/{msg['id']}")
assert r.status_code == 200
assert client.get("/api/chat").json()["messages"] == []
def test_delete_unknown_404(self, client, test_vault_dir):
assert client.delete("/api/chat/nope").status_code == 404
def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir):
msg = client.post("/api/chat", json={"text": "x"}).json()["message"]
client.delete(f"/api/chat/{msg['id']}")
assert client.delete(f"/api/chat/{msg['id']}").status_code == 404
class TestPrivateChat:
"""DM flows need a real logged-in identity (auth enabled)."""
def test_dm_roundtrip(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h)
assert r.status_code == 200
assert r.json()["message"]["text"] == "salut"
def test_dm_unknown_user_404(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h)
assert r.status_code == 404
def test_dm_to_self_400(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h)
assert r.status_code == 400
def test_dm_isolated_from_general(self, admin_client, test_vault_dir):
h = _login(admin_client)
admin_client.post("/api/chat", json={"text": "public"}, headers=h)
admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h)
gen = admin_client.get("/api/chat", headers=h).json()["messages"]
assert [m["text"] for m in gen] == ["public"]
dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]
assert [m["text"] for m in dm] == ["prive"]
def test_dm_history(self, admin_client, test_vault_dir):
h = _login(admin_client)
admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h)
r = admin_client.get("/api/chat/dm/normaluser", headers=h)
assert [m["text"] for m in r.json()["messages"]] == ["hystorique"]
def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir):
h = _login(admin_client)
admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h)
assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin")
assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1
def test_dm_delete(self, admin_client, test_vault_dir):
h = _login(admin_client)
msg = admin_client.post(
"/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h
).json()["message"]
r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h)
assert r.status_code == 200
assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == []
def test_requires_auth(self, admin_client, test_vault_dir):
assert admin_client.get("/api/chat/dm/normaluser").status_code == 401
assert admin_client.get("/api/chat/users").status_code == 401
def test_user_list_for_picker(self, admin_client, test_vault_dir):
h = _login(admin_client)
r = admin_client.get("/api/chat/users", headers=h)
assert r.status_code == 200
names = [u["username"] for u in r.json()]
assert "normaluser" in names
assert "admin" not in names # oneself is not a DM target
assert all("password" not in u and "hash" not in str(u) for u in r.json())
class TestLinkPreview:
def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch):
monkeypatch.setattr(_store, "build_preview", lambda text: None)
msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"]
# the schema always serialises the key (null) — it must stay falsy
assert not msg.get("preview")
def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch):
monkeypatch.setattr(
_store, "build_preview",
lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"},
)
msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"]
assert msg["preview"]["title"] == "Titre"
def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch):
monkeypatch.setattr(
_store, "build_preview",
lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"},
)
client.post("/api/chat", json={"text": "https://y.test"})
got = client.get("/api/chat").json()["messages"]
assert got[-1]["preview"]["site"] == "y.test"
def test_build_preview_rejects_non_http(self):
assert _store.build_preview("javascript:alert(1)") is None
assert _store.build_preview("aucune URL") is None
def test_build_preview_blocks_private_hosts(self, monkeypatch):
# SSRF guard rejects loopback before any network round-trip
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
assert _store.build_preview("http://127.0.0.1/x") is None
def test_build_preview_happy_path_parses_og(self, monkeypatch):
"""The fetch itself (a bad kwarg here used to fail silently)."""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
# the SSRF guard resolves DNS for real — out of scope for this test
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Resp:
status_code = 200
text = (
"<html><head><title>Fallback</title>"
'<meta property="og:title" content="Titre OG">'
'<meta property="og:description" content="Desc">'
'<meta property="og:site_name" content="Site">'
"</head></html>"
)
called = {}
def _fake_get(url, **kw):
called["url"] = url
return _Resp()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://example.test/page")
assert called["url"] == "https://example.test/page"
assert preview == {
"url": "https://example.test/page",
"title": "Titre OG",
"description": "Desc",
"image": "",
"site": "Site",
}
def test_preview_image_is_proxied_same_origin(self, monkeypatch):
"""BUG-109: a remote og:image is blocked by ``img-src 'self'``.
A *relative* og:image must be resolved against the page and downloaded
into ``chat_uploads`` so the browser can load it same-origin.
"""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Page:
status_code = 200
text = (
'<meta property="og:title" content="Titre">'
'<meta property="og:image" content="/img/pic.png">'
)
class _Img:
status_code = 200
content = b"\x89PNG-fake-bytes"
headers = {"content-type": "image/png"}
def _fake_get(url, **kw):
return _Img() if url.endswith(".png") else _Page()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://site.test/a")
assert preview["title"] == "Titre"
assert preview["image"].startswith("/api/chat/attachment/")
assert preview["image"].endswith(".png")
stored = _store.UPLOAD_DIR / preview["image"].rsplit("/", 1)[1]
assert stored.exists()
assert stored.read_bytes() == b"\x89PNG-fake-bytes"
def test_preview_image_failure_keeps_the_card(self, monkeypatch):
"""A dead thumbnail degrades to an empty image, never a lost card."""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Page:
status_code = 200
text = (
'<meta property="og:title" content="Titre">'
'<meta property="og:image" content="https://cdn.test/broken.jpg">'
)
class _Dead:
status_code = 404
content = b""
headers = {}
def _fake_get(url, **kw):
return _Dead() if url.endswith(".jpg") else _Page()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://site.test/b")
assert preview["title"] == "Titre"
assert preview["image"] == ""
# --- #192 : accusé de réception ---------------------------------------------
class TestReadReceipts:
def test_mark_read_roundtrip(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
assert _store.get_read("V", "a.md") == {}
read = _store.mark_read("V", "a.md", "bruno")
assert read["bruno"] > 0
assert _store.get_read("V", "a.md") == read
def test_mark_read_preserves_messages(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
_store.add_message("V", "a.md", "alice", "salut")
_store.mark_read("V", "a.md", "bruno")
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["salut"]
assert "bruno" in _store.get_read("V", "a.md")
def test_post_read_records_for_the_caller(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
assert r.status_code == 200, r.text
read = r.json()["read"]
assert read["anonymous"] > 0 # auth disabled in tests → fake user
# the history endpoint exposes the same map (#192)
assert client.get("/api/chat").json()["read"] == read
def test_post_read_refuses_a_dm_we_are_not_in(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "__dm__", "path": "alice|bob"})
assert r.status_code == 403
def test_post_read_requires_vault_and_path(self, client, test_vault_dir):
assert client.post("/api/chat/read", json={}).status_code == 400
def test_post_read_follows_vault_acl(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "TestVault", "path": "note1.md"})
assert r.status_code == 200, r.text
assert "anonymous" in r.json()["read"]
def test_post_read_broadcasts_chat_read(self, client, test_vault_dir, monkeypatch):
from backend.routers import file_chat as _router
events = []
class _Spy:
async def broadcast(self, event, data):
events.append((event, data))
monkeypatch.setattr(_router, "sse_manager", _Spy())
client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
assert [e for e, _ in events] == ["chat_read"]
assert events[0][1]["user"] == "anonymous"
assert events[0][1]["vault"] == "__global__"