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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user