Compare commits

...
5 Commits
Author SHA1 Message Date
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
29 changed files with 2421 additions and 133 deletions
+57 -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.57.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.60.0**.
---
@@ -14,6 +14,62 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
+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.57.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.60.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.57.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.60.0).
---
*Projet : ObsiGate | Version : 2.57.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.60.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.57.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.60.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.57.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.60.0).
---
*Project: ObsiGate | Version: 2.57.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.60.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.57.0
2.60.0
+239 -4
View File
@@ -1,29 +1,59 @@
# backend/file_chat.py — historique de discussion par fichier (#169)
"""Per-file chat history persisted under ``data/chats/``.
# 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 time
import uuid
from pathlib import Path
from typing import Any
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
# #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
def _chat_file(vault: str, path: str) -> Path:
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
@@ -62,19 +92,42 @@ def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
return list(_read(vault, path).get("messages", []))
def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]:
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 = {
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
doc = _read(vault, path)
return _append(vault, path, doc, msg)
def _append(
vault: str,
path: str,
doc: dict[str, Any],
msg: dict[str, Any],
) -> dict[str, Any]:
"""Cap, persist and return *msg* (shared by file and global chats)."""
messages = list(doc.get("messages", []))
messages.append(msg)
if len(messages) > MAX_MESSAGES:
@@ -82,3 +135,185 @@ def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]:
doc["messages"] = messages
_write(_chat_file(vault, path), doc)
return msg
# --- #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."""
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 ""
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).
"""
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]
preview = {
"url": url,
"title": _og(body, "og:title") or _og(body, "og:site_name"),
"description": _og(body, "og:description"),
"image": _og(body, "og: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",
}
+187 -9
View File
@@ -1,11 +1,15 @@
# backend/routers/file_chat.py — chat par fichier (#169)
"""Per-file chat endpoints: history read + message post with SSE fan-out.
# 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`` — auth + vault access + path
traversal check (``resolve_safe_path`` raises ``ServiceError`` mapped by
the app-level handler). A posted message is broadcast on the existing
SSE channel (``chat_message``) so every connected client updates live
without a second WebSocket.
- ``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
@@ -13,12 +17,14 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
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
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, StatusResponse
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
@@ -63,3 +69,175 @@ async def api_file_chat_post(
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()}
@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)}
@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"}
+9 -1
View File
@@ -152,12 +152,20 @@ class BacklinksResponse(BaseModel):
class ChatMessageItem(BaseModel):
"""One chat message (``GET/POST /api/file/{vault}/chat``)."""
"""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):
+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.57.0"
version = "2.60.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.57.0"
version = "2.60.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.57.0",
"version": "2.60.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).
```
+18
View File
@@ -184,6 +184,24 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
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).
---
+4
View File
@@ -328,6 +328,10 @@ 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) |
---
+5 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.57.0 | **Dernière mise à jour :** 2026-10-08
> **Version :** 2.60.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)**
@@ -729,8 +729,10 @@
| 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) |
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | Unreleased | [features/file-chat-169.md](./features/file-chat-169.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) |
| 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) |
+97
View File
@@ -54,6 +54,103 @@
(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` (6,
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).
## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
+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>
<input
type="text"
id="sidebar-panel-chat-input"
class="file-chat-input"
maxlength="4000"
placeholder="Écrire un message…"
autocomplete="off"
aria-label="Écrire un message"
/>
<!-- #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() {
+507 -56
View File
@@ -1,29 +1,39 @@
// frontend/js/filechat.js — Chat intégré par fichier (#169)
// Panneau latéral : historique + saisie. Les messages entrants arrivent par
// SSE (event `chat_message` relayé depuis sync.js) ; si le panneau est fermé
// ou affiche un autre fichier, un toast notifie l'utilisateur.
// 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 } from "./utils.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
const DM_VAULT = "__dm__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
let _vault = null;
let _path = null;
let _title = "";
let _sending = false;
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;
}
// É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;
// ---------------------------------------------------------------------------
// Partagé
// ---------------------------------------------------------------------------
function _esc(s) {
return escapeHtml(String(s == null ? "" : s));
}
@@ -38,20 +48,110 @@ function _formatTime(ts) {
}
}
export function renderChatMessages(container, messages, currentUser) {
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)));
container.scrollTop = container.scrollHeight;
function _currentUser() {
const u = AuthManager.getUser();
return (u && u.username) || "";
}
function _messageEl(m, currentUser) {
// 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.
*/
export function _messageEl(m, currentUser, delUrl) {
const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
@@ -61,14 +161,65 @@ function _messageEl(m, currentUser) {
meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
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";
body.textContent = m.text || "";
_fillBody(body, m.text);
wrap.appendChild(meta);
wrap.appendChild(body);
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) {
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)));
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");
@@ -86,17 +237,21 @@ async function _load() {
}
}
function _currentUser() {
const u = AuthManager.getUser();
return (u && u.username) || "";
}
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";
@@ -104,7 +259,7 @@ function _renderShell(panel) {
close.setAttribute("aria-label", t("chat.close"));
close.textContent = "✕";
close.addEventListener("click", closeFileChat);
header.appendChild(title);
header.appendChild(titles);
header.appendChild(close);
const list = document.createElement("div");
@@ -120,8 +275,11 @@ function _renderShell(panel) {
input.setAttribute("aria-label", t("chat.placeholder"));
const send = document.createElement("button");
send.type = "submit";
send.className = "file-chat-send";
send.textContent = t("chat.send");
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) => {
@@ -132,11 +290,13 @@ function _renderShell(panel) {
panel.appendChild(header);
panel.appendChild(list);
panel.appendChild(form);
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
}
export async function openFileChat(vault, path) {
export async function openFileChat(vault, path, title) {
_vault = vault;
_path = path;
_title = title || path || "";
const panel = _panel();
_renderShell(panel);
panel.classList.remove("hidden");
@@ -150,13 +310,21 @@ export function closeFileChat() {
if (panel) panel.classList.add("hidden");
}
export function toggleFileChat(vault, path) {
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);
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) {
@@ -170,11 +338,7 @@ async function _send(input) {
body: JSON.stringify({ path: _path, text }),
});
input.value = "";
const list = _panel().querySelector(".file-chat-list");
const empty = list.querySelector(".file-chat-empty");
if (empty) empty.remove();
list.appendChild(_messageEl(data.message, _currentUser()));
list.scrollTop = list.scrollHeight;
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
} catch (_) {
showToast(t("chat.send_error"), "error");
} finally {
@@ -183,26 +347,313 @@ async function _send(input) {
}
}
// --- SSE relay (appelé par sync.js sur l'événement `chat_message`) ---------
// ---------------------------------------------------------------------------
// 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;
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl());
_clearUnread();
} 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());
}
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 = "";
_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");
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();
} 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) {
const empty = list.querySelector(".file-chat-empty");
if (empty) empty.remove();
// Évite le doublon si l'envoi local a déjà rendu ce message
const id = data.message.id;
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return;
list.appendChild(_messageEl(data.message, _currentUser()));
list.scrollTop = list.scrollHeight;
}
if (list) _appendMessage(list, data.message);
return;
}
if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.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("");
};
+11 -1
View File
@@ -120,7 +120,7 @@ export const IndexUpdateManager = (() => {
}
});
// #169 — Chat par fichier : relais du broadcast SSE vers le panneau
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
eventSource.addEventListener("chat_message", (e) => {
try {
const data = JSON.parse(e.data);
@@ -130,6 +130,16 @@ export const IndexUpdateManager = (() => {
}
});
// #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);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
+4 -2
View File
@@ -16,7 +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 } from './filechat.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';
@@ -6897,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; }
@@ -7227,7 +7229,7 @@ export function renderFile(data) {
// #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);
toggleFileChat(data.vault, data.path, data.title);
});
// Check bookmark status and color the button
(async () => {
+9
View File
@@ -319,6 +319,12 @@
"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",
@@ -326,8 +332,10 @@
"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.send": "Send",
"chat.send_error": "Failed to send the message",
"chat.tab": "General chat",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "All",
@@ -1707,6 +1715,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",
+9
View File
@@ -319,6 +319,12 @@
"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",
@@ -326,8 +332,10 @@
"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.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "Tous",
@@ -1707,6 +1715,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",
+221
View File
@@ -13887,3 +13887,224 @@ body.reading-mode .file-actions {
}
}
/* ---------------------------------------------------------------------------
#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.57.0",
"version": "2.60.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": {
+132 -2
View File
@@ -21,7 +21,14 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const dom = new JSDOM(
`<!DOCTYPE html><html><body><div id="toast-container"></div></body></html>`,
`<!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"><input id="sidebar-panel-chat-input" /></form>
</div>
<button id="sidebar-tab-chat"></button>
</body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true },
);
@@ -62,8 +69,16 @@ globalThis.fetch = (url) => {
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 } = mod;
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted,
} = mod;
let pass = 0;
const cases = [];
@@ -148,6 +163,121 @@ test("toggleFileChat: opens then closes on the same file", async () => {
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);
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
+245 -1
View File
@@ -8,10 +8,19 @@ 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``."""
"""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:
@@ -120,3 +129,238 @@ def test_history_per_path_isolated(client, test_vault_dir, path):
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": []}
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",
}