feat: chat intégré par fichier — panneau latéral, historique, temps réel SSE #169
- 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:
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user