feat: chat — saisie auto-agrandissante, accusé de réception ✓✓, vignette de tuile #192 (BUG-109)
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m1s

This commit is contained in:
2026-10-08 23:28:08 -04:00
parent f00a8bea8f
commit 54caa48d1f
22 changed files with 645 additions and 52 deletions
+51 -4
View File
@@ -24,7 +24,7 @@ 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, StatusResponse
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
@@ -49,7 +49,7 @@ async def api_file_chat_history(
):
"""Return the chat history for a file (chronological)."""
_check(vault_name, path, current_user)
return {"messages": _store.get_messages(vault_name, path)}
return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
@@ -93,7 +93,51 @@ def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
@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()}
return {
"messages": _store.get_global_messages(),
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
}
# --- #192 : accusé de réception ---------------------------------------------
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
"""Authorization for marking a conversation read (#192).
General chat is open to any member, a DM only to the two participants,
a file chat follows the vault ACL.
"""
if vault == _store.GLOBAL_VAULT:
return
if vault == _store.DM_VAULT:
if current_user.get("username") not in str(path).split("|"):
raise HTTPException(403, "Accès refusé à cette conversation privée")
return
_check(vault, path, current_user)
@router.post("/api/chat/read", response_model=ChatReadResponse)
async def api_chat_read(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Record that the caller has seen a conversation (#192).
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
✓✓ live on every connected client.
"""
vault = str(body.get("vault") or "")
path = str(body.get("path") or "")
if not vault or not path:
raise HTTPException(400, "vault and path are required")
_check_read(vault, path, current_user)
username = current_user.get("username", "")
read = _store.mark_read(vault, path, username)
await sse_manager.broadcast(
"chat_read",
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
)
return {"read": read, "status": "ok"}
@router.post("/api/chat", response_model=ChatMessageResponse)
@@ -199,7 +243,10 @@ def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
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)}
return {
"messages": _store.get_dm_messages(current_user["username"], peer),
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
}
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)