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.
This commit is contained in:
2026-10-08 20:40:54 -04:00
parent d0e10d4cc6
commit ae06436f91
26 changed files with 1002 additions and 127 deletions
+24 -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.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.58.0**.
---
@@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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
---
+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.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.58.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.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.58.0).
---
*Projet : ObsiGate | Version : 2.57.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.58.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.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.58.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.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.58.0).
---
*Project: ObsiGate | Version: 2.57.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.58.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.57.1
2.58.0
+111 -4
View File
@@ -1,10 +1,15 @@
# 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
@@ -24,6 +29,18 @@ 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"
# #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",
}
def _chat_file(vault: str, path: str) -> Path:
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
@@ -62,19 +79,38 @@ 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,
) -> 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.
"""
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
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 +118,74 @@ 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
# --- #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,
) -> dict[str, Any]:
"""Append a message to the general chat. Returns the stored message."""
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment)
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,
}
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",
}
+81 -8
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,7 +17,8 @@ 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
@@ -63,3 +68,71 @@ 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")
msg = _store.add_global_message(
current_user.get("username", ""), text, _attachment(body)
)
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)
+5 -1
View File
@@ -152,12 +152,16 @@ 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}",
)
class ChatHistoryResponse(BaseModel):
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.57.1"
version = "2.58.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.57.1"
version = "2.58.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.1",
"version": "2.58.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+10
View File
@@ -184,6 +184,16 @@ 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.
---
+1
View File
@@ -329,6 +329,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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 | #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) |
---
+2 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.57.1 | **Dernière mise à jour :** 2026-10-08
> **Version :** 2.58.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)**
@@ -731,6 +731,7 @@
| 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 | 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 | Unreleased | [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) |
+36
View File
@@ -54,6 +54,42 @@
(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).
## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
+96 -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,47 @@
</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"
>
<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: 16px; height: 16px"
></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"
/>
<button type="submit" class="file-chat-send">Envoyer</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() {
+335 -55
View File
@@ -1,29 +1,36 @@
// 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";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: 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 général (onglet sidebar)
let _generalMessages = [];
let _generalLoaded = false;
let _pendingAttach = null;
// ---------------------------------------------------------------------------
// Partagé
// ---------------------------------------------------------------------------
function _esc(s) {
return escapeHtml(String(s == null ? "" : s));
}
@@ -38,6 +45,71 @@ function _formatTime(ts) {
}
}
function _currentUser() {
const u = AuthManager.getUser();
return (u && u.username) || "";
}
// 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;
}
export function _messageEl(m, currentUser) {
const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
wrap.dataset.id = m.id || "";
const meta = document.createElement("div");
meta.className = "file-chat-meta";
meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
const body = document.createElement("div");
body.className = "file-chat-body";
_fillBody(body, m.text);
wrap.appendChild(meta);
if (m.text) wrap.appendChild(body);
const att = _attachEl(m.attachment);
if (att) wrap.appendChild(att);
return wrap;
}
export function renderChatMessages(container, messages, currentUser) {
container.innerHTML = "";
if (!messages || messages.length === 0) {
@@ -51,22 +123,29 @@ export function renderChatMessages(container, messages, currentUser) {
container.scrollTop = container.scrollHeight;
}
function _messageEl(m, currentUser) {
const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
wrap.dataset.id = m.id || "";
const meta = document.createElement("div");
meta.className = "file-chat-meta";
meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
const body = document.createElement("div");
body.className = "file-chat-body";
body.textContent = m.text || "";
wrap.appendChild(meta);
wrap.appendChild(body);
return wrap;
function _appendMessage(list, m) {
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()));
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() {
@@ -86,17 +165,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 +187,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");
@@ -134,9 +217,10 @@ function _renderShell(panel) {
panel.appendChild(form);
}
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 +234,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 +262,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,24 +271,216 @@ async function _send(input) {
}
}
// --- SSE relay (appelé par sync.js sur l'événement `chat_message`) ---------
// ---------------------------------------------------------------------------
// 2. Chat général, onglet sidebar (#190 B)
// ---------------------------------------------------------------------------
function _sidebarList() {
return document.getElementById("sidebar-panel-chat-list");
}
/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
export async function openSidebarChat() {
const list = _sidebarList();
if (!list) return;
try {
const data = await api("/api/chat");
_generalMessages = (data.messages || []).slice();
_generalLoaded = true;
renderChatMessages(list, _generalMessages, _currentUser());
_clearUnread();
} catch (_) {
list.innerHTML = "";
const err = document.createElement("div");
err.className = "file-chat-empty";
err.textContent = t("chat.load_error");
list.appendChild(err);
}
}
/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
export function filterChatMessages(query) {
const list = _sidebarList();
if (!list || !_generalLoaded) return;
const q = String(query || "").toLowerCase().trim();
const shown = q
? _generalMessages.filter((m) =>
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
)
: _generalMessages;
renderChatMessages(list, shown, _currentUser());
}
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("/api/chat", { method: "POST", body: JSON.stringify(body) });
input.value = "";
_pendingAttach = null;
_renderPending();
const list = _sidebarList();
if (list) {
_generalMessages.push(data.message);
_appendMessage(list, data.message);
}
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) --------------------------------------------
function _readUnread() {
try {
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
} catch (_) {
return {};
}
}
function _clearUnread() {
const u = _readUnread();
u.general = Math.floor(Date.now() / 1000);
try {
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
} catch (_) { /* quota */ }
_renderBadge(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);
}
/** Compte les messages reçus depuis la dernière visite. */
export function refreshUnreadBadge(messages) {
const list = messages || _generalMessages;
if (!list.length) return _renderBadge(0);
const seen = _readUnread().general || 0;
const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length;
_renderBadge(count);
}
/** 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;
const isGeneral = data.vault === GLOBAL_VAULT;
if (isGeneral) {
_generalMessages.push(data.message);
const list = _sidebarList();
const panel = document.getElementById("sidebar-panel-chat");
const visible = panel && panel.classList.contains("active");
if (list && visible) {
_appendMessage(list, data.message);
_clearUnread();
} else if (data.message.user !== _currentUser()) {
refreshUnreadBadge(_generalMessages);
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
+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("");
};
+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 () => {
+4
View File
@@ -319,6 +319,8 @@
"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.attach_remove": "Remove attachment",
"chat.button": "Chat",
"chat.button_title": "Discuss this file",
"chat.close": "Close the discussion panel",
@@ -328,6 +330,7 @@
"chat.placeholder": "Write a message…",
"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 +1710,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",
+4
View File
@@ -319,6 +319,8 @@
"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.attach_remove": "Retirer la pièce jointe",
"chat.button": "Chat",
"chat.button_title": "Discuter de ce fichier",
"chat.close": "Fermer le panneau de discussion",
@@ -328,6 +330,7 @@
"chat.placeholder": "Écrire un message…",
"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 +1710,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",
+111
View File
@@ -13887,3 +13887,114 @@ body.reading-mode .file-actions {
}
}
/* ---------------------------------------------------------------------------
#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.1",
"version": "2.58.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": {
+65 -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 },
);
@@ -63,7 +70,10 @@ const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).hr
await initI18n();
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,
} = mod;
let pass = 0;
const cases = [];
@@ -148,6 +158,59 @@ 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"));
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
+88 -1
View File
@@ -10,8 +10,10 @@ from backend import file_chat as _store
@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 +122,88 @@ 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"