- 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 ».
66 lines
2.6 KiB
Python
66 lines
2.6 KiB
Python
# backend/routers/file_chat.py — chat par fichier (#169)
|
|
"""Per-file 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
|
|
|
from backend import file_chat as _store
|
|
from backend.auth.middleware import check_vault_access, require_auth
|
|
from backend.indexer import get_vault_data
|
|
from backend.schemas import ChatHistoryResponse, ChatMessageResponse
|
|
from backend.services.paths import resolve_safe_path
|
|
from backend.sse import sse_manager
|
|
|
|
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
|
|
|
|
|
|
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
|
|
"""Authz + traversal guard shared by both verbs."""
|
|
if not check_vault_access(vault_name, current_user):
|
|
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
|
vault_data = get_vault_data(vault_name)
|
|
if not vault_data:
|
|
raise HTTPException(404, "Vault not found")
|
|
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
|
|
|
|
|
|
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
|
|
async def api_file_chat_history(
|
|
vault_name: str,
|
|
path: str,
|
|
current_user: dict[str, Any] = Depends(require_auth),
|
|
):
|
|
"""Return the chat history for a file (chronological)."""
|
|
_check(vault_name, path, current_user)
|
|
return {"messages": _store.get_messages(vault_name, path)}
|
|
|
|
|
|
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
|
|
async def api_file_chat_post(
|
|
vault_name: str,
|
|
body: dict[str, Any] = Body(...),
|
|
current_user: dict[str, Any] = Depends(require_auth),
|
|
):
|
|
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
|
|
path = str(body.get("path") or "")
|
|
text = str(body.get("text") or "")
|
|
if not path:
|
|
raise HTTPException(400, "path is required")
|
|
if not text.strip():
|
|
raise HTTPException(400, "text is required")
|
|
_check(vault_name, path, current_user)
|
|
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
|
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
|
return {"message": msg, "status": "ok"}
|