- 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 ».
209 lines
6.4 KiB
JavaScript
209 lines
6.4 KiB
JavaScript
// 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");
|
|
}
|