feat: chat intégré par fichier — panneau latéral, historique, temps réel SSE #169
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m11s

- 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 ».
This commit is contained in:
2026-10-08 17:03:29 -04:00
parent 36b4962efb
commit c94f065f80
25 changed files with 955 additions and 37 deletions
+208
View File
@@ -0,0 +1,208 @@
// 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.
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";
let _vault = null;
let _path = null;
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;
}
function _esc(s) {
return escapeHtml(String(s == null ? "" : s));
}
function _formatTime(ts) {
try {
return new Date(ts * 1000).toLocaleString(undefined, {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
});
} catch (_) {
return "";
}
}
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 _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;
}
async function _load() {
const panel = _panel();
const list = panel.querySelector(".file-chat-list");
try {
const data = await api(
`/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`,
);
renderChatMessages(list, data.messages || [], _currentUser());
} catch (_) {
list.innerHTML = "";
const err = document.createElement("div");
err.className = "file-chat-empty";
err.textContent = t("chat.load_error");
list.appendChild(err);
}
}
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 title = document.createElement("h3");
title.textContent = t("chat.title");
const close = document.createElement("button");
close.type = "button";
close.className = "file-chat-close";
close.title = t("chat.close");
close.setAttribute("aria-label", t("chat.close"));
close.textContent = "✕";
close.addEventListener("click", closeFileChat);
header.appendChild(title);
header.appendChild(close);
const list = document.createElement("div");
list.className = "file-chat-list";
const form = document.createElement("form");
form.className = "file-chat-form";
const input = document.createElement("input");
input.type = "text";
input.className = "file-chat-input";
input.placeholder = t("chat.placeholder");
input.maxLength = 4000;
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");
form.appendChild(input);
form.appendChild(send);
form.addEventListener("submit", (e) => {
e.preventDefault();
_send(input);
});
panel.appendChild(header);
panel.appendChild(list);
panel.appendChild(form);
}
export async function openFileChat(vault, path) {
_vault = vault;
_path = path;
const panel = _panel();
_renderShell(panel);
panel.classList.remove("hidden");
await _load();
const input = panel.querySelector(".file-chat-input");
if (input) input.focus();
}
export function closeFileChat() {
const panel = document.getElementById(PANEL_ID);
if (panel) panel.classList.add("hidden");
}
export function toggleFileChat(vault, path) {
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
closeFileChat();
return;
}
openFileChat(vault, path);
}
async function _send(input) {
if (_sending) return;
const text = (input.value || "").trim();
if (!text) return;
_sending = true;
try {
const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, {
method: "POST",
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;
} catch (_) {
showToast(t("chat.send_error"), "error");
} finally {
_sending = false;
input.focus();
}
}
// --- SSE relay (appelé par sync.js sur l'événement `chat_message`) ---------
export function onChatMessage(data) {
if (!data || !data.message) return;
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;
}
return;
}
if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
+10
View File
@@ -120,6 +120,16 @@ export const IndexUpdateManager = (() => {
}
});
// #169 — Chat par fichier : relais du broadcast SSE vers le panneau
eventSource.addEventListener("chat_message", (e) => {
try {
const data = JSON.parse(e.data);
import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {});
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
+8 -1
View File
@@ -16,6 +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 { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { createColourPicker } from './xlsx/color-picker.js';
import { buildCommandBar } from './xlsx/command-bar.js';
@@ -7222,6 +7223,12 @@ export function renderFile(data) {
// Bookmark button — check if already bookmarked
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
// #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);
});
// Check bookmark status and color the button
(async () => {
try {
@@ -7314,7 +7321,7 @@ export function renderFile(data) {
// spacer de groupe vide retire.
const navBtns = [];
if (data.is_markdown) navBtns.push(tocBtn);
navBtns.push(openNewWindowBtn, bookmarkBtn);
navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);
const editBtns = [];
if (!data.unsupported) editBtns.push(editBtn);
editBtns.push(sourceBtn);
+10
View File
@@ -319,6 +319,16 @@
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
"backups.title": "Backups",
"button.cancel": "Cancel",
"chat.button": "Chat",
"chat.button_title": "Discuss this file",
"chat.close": "Close the discussion panel",
"chat.empty": "No messages yet. Start the conversation!",
"chat.load_error": "Could not load the discussion",
"chat.new_message": "New message from {user}",
"chat.placeholder": "Write a message…",
"chat.send": "Send",
"chat.send_error": "Failed to send the message",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "All",
"common.back": "Back",
+10
View File
@@ -319,6 +319,16 @@
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
"backups.title": "Backups",
"button.cancel": "Annuler",
"chat.button": "Chat",
"chat.button_title": "Discuter de ce fichier",
"chat.close": "Fermer le panneau de discussion",
"chat.empty": "Aucun message pour l'instant. Lancez la discussion !",
"chat.load_error": "Impossible de charger la discussion",
"chat.new_message": "Nouveau message de {user}",
"chat.placeholder": "Écrire un message…",
"chat.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "Tous",
"common.back": "Retour",
+139
View File
@@ -13748,3 +13748,142 @@ body.reading-mode .file-actions {
color: var(--text-muted);
font-size: 0.85rem;
}
/* ---------------------------------------------------------------------------
#169 — Chat intégré par fichier (panneau latéral)
--------------------------------------------------------------------------- */
.file-chat-panel {
position: fixed;
top: 0;
right: 0;
width: 340px;
max-width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary, #0d1117);
border-left: 1px solid var(--border, #21262d);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
z-index: 1500;
}
.file-chat-panel.hidden {
display: none;
}
.file-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #21262d);
}
.file-chat-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-chat-close {
border: none;
background: transparent;
color: var(--text-muted, #8b949e);
font-size: 1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.file-chat-close:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
color: var(--text-primary, #e6edf3);
}
.file-chat-list {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.file-chat-empty {
color: var(--text-muted, #8b949e);
font-size: 0.85rem;
text-align: center;
padding: 24px 8px;
}
.file-chat-msg {
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border, #21262d);
border-radius: 10px;
padding: 8px 10px;
max-width: 92%;
}
.file-chat-msg.mine {
align-self: flex-end;
background: var(--bg-hover, rgba(88, 166, 255, 0.12));
border-color: var(--accent, #58a6ff);
}
.file-chat-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.72rem;
color: var(--text-muted, #8b949e);
margin-bottom: 4px;
}
.file-chat-user {
font-weight: 600;
color: var(--accent, #58a6ff);
}
.file-chat-body {
font-size: 0.88rem;
color: var(--text-primary, #e6edf3);
white-space: pre-wrap;
word-break: break-word;
}
.file-chat-form {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border, #21262d);
}
.file-chat-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* anti-zoom iOS */
border: 1px solid var(--border, #21262d);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
color: var(--text-primary, #e6edf3);
}
.file-chat-input:focus {
outline: none;
border-color: var(--accent, #58a6ff);
}
.file-chat-send {
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--accent, #58a6ff);
color: #fff;
flex-shrink: 0;
}
.file-chat-send:disabled {
opacity: 0.6;
cursor: default;
}
@media (max-width: 768px) {
.file-chat-panel {
width: 100vw;
border-left: none;
}
}