- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
`resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
`chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
(6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
`docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
85 lines
2.8 KiB
Python
85 lines
2.8 KiB
Python
# backend/file_chat.py — historique de discussion par fichier (#169)
|
|
"""Per-file 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
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
|
|
|
|
|
|
def _chat_file(vault: str, path: str) -> Path:
|
|
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
|
|
CHAT_DIR.mkdir(parents=True, exist_ok=True)
|
|
key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32]
|
|
return CHAT_DIR / f"{key}.json"
|
|
|
|
|
|
def _read(vault: str, path: str) -> dict[str, Any]:
|
|
"""Load the raw chat document (empty structure when missing/corrupt)."""
|
|
file = _chat_file(vault, path)
|
|
if not file.exists():
|
|
return {"vault": vault, "path": path, "messages": []}
|
|
try:
|
|
doc = json.loads(file.read_text(encoding="utf-8"))
|
|
if not isinstance(doc.get("messages"), list):
|
|
raise TypeError("messages is not a list") # caught by the handler below
|
|
return doc
|
|
except Exception as e:
|
|
logger.error("Failed to read chat for %s/%s: %s", vault, path, e)
|
|
return {"vault": vault, "path": path, "messages": []}
|
|
|
|
|
|
def _write(file: Path, doc: dict[str, Any]) -> None:
|
|
"""Atomically persist *doc* (tmp file + rename)."""
|
|
try:
|
|
tmp = file.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
shutil.move(str(tmp), str(file))
|
|
except Exception as e:
|
|
logger.error("Failed to write chat %s: %s", file.name, e)
|
|
|
|
|
|
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
|
"""Return the chat history for *(vault, path)* (chronological)."""
|
|
return list(_read(vault, path).get("messages", []))
|
|
|
|
|
|
def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]:
|
|
"""Append a message and persist it. Returns the stored message.
|
|
|
|
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
|
|
"""
|
|
text = (text or "").strip()[:MAX_TEXT]
|
|
msg = {
|
|
"id": uuid.uuid4().hex[:12],
|
|
"user": user or "anonyme",
|
|
"text": text,
|
|
"ts": time.time(),
|
|
}
|
|
doc = _read(vault, path)
|
|
messages = list(doc.get("messages", []))
|
|
messages.append(msg)
|
|
if len(messages) > MAX_MESSAGES:
|
|
messages = messages[-MAX_MESSAGES:]
|
|
doc["messages"] = messages
|
|
_write(_chat_file(vault, path), doc)
|
|
return msg
|