feat: chat — suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre #190
- **A — Suivi du document** : l'en-tête du panneau chat affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document. - **B — Chat général** : onglet « Chat » (dernier) dans la sidebar, conversation centrale stockée dans le store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi. - **C — Ordre des onglets** : la barre de filtre passe sous les onglets et sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est actif (`switchSidebarTab` + `routeFilter`). - Transport : broadcast SSE `chat_message` réutilisé (`vault __global__` route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket. - i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur. - Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5 nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules. - Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
This commit is contained in:
+96
-40
@@ -812,46 +812,6 @@
|
||||
role="navigation"
|
||||
aria-label="Navigation des vaults"
|
||||
>
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="sidebar-tabs"
|
||||
@@ -928,6 +888,61 @@
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-tab"
|
||||
id="sidebar-tab-chat"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="sidebar-panel-chat"
|
||||
data-tab="chat"
|
||||
title="Chat"
|
||||
>
|
||||
<i
|
||||
data-lucide="message-circle"
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar filter (#190 C : sous les onglets ; sert de
|
||||
recherche dans l'onglet Chat) -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vaults panel -->
|
||||
@@ -1050,6 +1065,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General chat panel (#190 B) -->
|
||||
<div
|
||||
class="sidebar-tab-panel sidebar-chat-panel"
|
||||
id="sidebar-panel-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
></div>
|
||||
<div id="sidebar-panel-chat-pending" class="sidebar-chat-pending"></div>
|
||||
<form
|
||||
id="sidebar-panel-chat-form"
|
||||
class="file-chat-form sidebar-chat-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-panel-chat-attach"
|
||||
class="file-chat-attach"
|
||||
title="Joindre une image ou une vidéo"
|
||||
aria-label="Joindre une image ou une vidéo"
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-panel-chat-input"
|
||||
class="file-chat-input"
|
||||
maxlength="4000"
|
||||
placeholder="Écrire un message…"
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
/>
|
||||
<button type="submit" class="file-chat-send">Envoyer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User section (bottom of the sidebar, #112) -->
|
||||
<div class="sidebar-user" id="sidebar-user" hidden>
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,7 @@ import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.js';
|
||||
import { initSidebarChat } from './filechat.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -61,6 +62,7 @@ async function init() {
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
initSidebarChat(); // #190 B — formulaire du chat général (sidebar)
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
|
||||
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
|
||||
import { openSidebarChat, filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -305,6 +306,7 @@ function switchSidebarTab(tab) {
|
||||
recent: t("sidebar.filter_recent"),
|
||||
saved: t("sidebar.filter_saved"),
|
||||
ai: t("sidebar.filter_ai"),
|
||||
chat: t("sidebar.filter_chat"),
|
||||
};
|
||||
filterInput.placeholder = placeholders[tab] || "";
|
||||
}
|
||||
@@ -315,6 +317,7 @@ function switchSidebarTab(tab) {
|
||||
else if (tab === "recent") filterRecentFiles(query);
|
||||
else if (tab === "saved") filterSavedSearches(query);
|
||||
else if (tab === "ai") filterAIHistory(query);
|
||||
else if (tab === "chat") _filterChat(query);
|
||||
}
|
||||
// Auto-load recent files when switching to the recent tab
|
||||
if (tab === "recent") {
|
||||
@@ -333,6 +336,11 @@ function switchSidebarTab(tab) {
|
||||
if (filterInput) _aiQuery = filterInput.value.trim();
|
||||
loadAISessionList();
|
||||
}
|
||||
// #190 B — Auto-load the general chat when entering its tab (fresh history;
|
||||
// openSidebarChat clears the unread badge).
|
||||
if (tab === "chat") {
|
||||
openSidebarChat();
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpModal() {
|
||||
|
||||
+335
-55
@@ -1,29 +1,36 @@
|
||||
// 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.
|
||||
// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190)
|
||||
//
|
||||
// Deux surfaces, un module :
|
||||
// 1. Panneau latéral du document (#169) — historique + saisie, suivi du
|
||||
// document ouvert (followFileChat le re-cible à chaque renderFile).
|
||||
// 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces
|
||||
// jointes (image/vidéo/URL), indicateur de messages non lus, recherche
|
||||
// via la barre de filtre de la sidebar.
|
||||
//
|
||||
// Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé
|
||||
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
|
||||
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";
|
||||
const GLOBAL_VAULT = "__global__";
|
||||
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts}
|
||||
|
||||
let _vault = null;
|
||||
let _path = null;
|
||||
let _title = "";
|
||||
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;
|
||||
}
|
||||
// État du chat général (onglet sidebar)
|
||||
let _generalMessages = [];
|
||||
let _generalLoaded = false;
|
||||
let _pendingAttach = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Partagé
|
||||
// ---------------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return escapeHtml(String(s == null ? "" : s));
|
||||
}
|
||||
@@ -38,6 +45,71 @@ function _formatTime(ts) {
|
||||
}
|
||||
}
|
||||
|
||||
function _currentUser() {
|
||||
const u = AuthManager.getUser();
|
||||
return (u && u.username) || "";
|
||||
}
|
||||
|
||||
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
|
||||
function _fillBody(body, text) {
|
||||
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
|
||||
parts.forEach((part) => {
|
||||
if (!part) return;
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
const a = document.createElement("a");
|
||||
a.href = part;
|
||||
a.textContent = part;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.className = "file-chat-link";
|
||||
body.appendChild(a);
|
||||
} else {
|
||||
body.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _attachEl(att) {
|
||||
if (!att || !att.url) return null;
|
||||
if (att.kind === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.src = att.url;
|
||||
img.alt = att.name || "image";
|
||||
img.loading = "lazy";
|
||||
img.className = "file-chat-image";
|
||||
return img;
|
||||
}
|
||||
if (att.kind === "video") {
|
||||
const v = document.createElement("video");
|
||||
v.src = att.url;
|
||||
v.controls = true;
|
||||
v.preload = "metadata";
|
||||
v.className = "file-chat-video";
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export 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";
|
||||
_fillBody(body, m.text);
|
||||
wrap.appendChild(meta);
|
||||
if (m.text) wrap.appendChild(body);
|
||||
const att = _attachEl(m.attachment);
|
||||
if (att) wrap.appendChild(att);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderChatMessages(container, messages, currentUser) {
|
||||
container.innerHTML = "";
|
||||
if (!messages || messages.length === 0) {
|
||||
@@ -51,22 +123,29 @@ export function renderChatMessages(container, messages, 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;
|
||||
function _appendMessage(list, m) {
|
||||
const empty = list.querySelector(".file-chat-empty");
|
||||
if (empty) empty.remove();
|
||||
const id = m.id;
|
||||
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
|
||||
list.appendChild(_messageEl(m, _currentUser()));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Panneau du document (#169)
|
||||
// ---------------------------------------------------------------------------
|
||||
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;
|
||||
}
|
||||
|
||||
async function _load() {
|
||||
@@ -86,17 +165,21 @@ async function _load() {
|
||||
}
|
||||
}
|
||||
|
||||
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 titles = document.createElement("div");
|
||||
titles.className = "file-chat-titles";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = t("chat.title");
|
||||
titles.appendChild(title);
|
||||
// #190 A — le document ciblé est affiché dans l'en-tête
|
||||
const target = document.createElement("span");
|
||||
target.className = "file-chat-target";
|
||||
target.textContent = _title || _path || "";
|
||||
target.title = _path || "";
|
||||
titles.appendChild(target);
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "file-chat-close";
|
||||
@@ -104,7 +187,7 @@ function _renderShell(panel) {
|
||||
close.setAttribute("aria-label", t("chat.close"));
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", closeFileChat);
|
||||
header.appendChild(title);
|
||||
header.appendChild(titles);
|
||||
header.appendChild(close);
|
||||
|
||||
const list = document.createElement("div");
|
||||
@@ -134,9 +217,10 @@ function _renderShell(panel) {
|
||||
panel.appendChild(form);
|
||||
}
|
||||
|
||||
export async function openFileChat(vault, path) {
|
||||
export async function openFileChat(vault, path, title) {
|
||||
_vault = vault;
|
||||
_path = path;
|
||||
_title = title || path || "";
|
||||
const panel = _panel();
|
||||
_renderShell(panel);
|
||||
panel.classList.remove("hidden");
|
||||
@@ -150,13 +234,21 @@ export function closeFileChat() {
|
||||
if (panel) panel.classList.add("hidden");
|
||||
}
|
||||
|
||||
export function toggleFileChat(vault, path) {
|
||||
export function toggleFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
||||
closeFileChat();
|
||||
return;
|
||||
}
|
||||
openFileChat(vault, path);
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
// #190 A — appelé à chaque renderFile : le panneau ouvert suit le document.
|
||||
export function followFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (!panel || panel.classList.contains("hidden")) return;
|
||||
if (_vault === vault && _path === path) return;
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
async function _send(input) {
|
||||
@@ -170,11 +262,7 @@ async function _send(input) {
|
||||
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;
|
||||
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
} finally {
|
||||
@@ -183,24 +271,216 @@ async function _send(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE relay (appelé par sync.js sur l'événement `chat_message`) ---------
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Chat général, onglet sidebar (#190 B)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _sidebarList() {
|
||||
return document.getElementById("sidebar-panel-chat-list");
|
||||
}
|
||||
|
||||
/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
|
||||
export async function openSidebarChat() {
|
||||
const list = _sidebarList();
|
||||
if (!list) return;
|
||||
try {
|
||||
const data = await api("/api/chat");
|
||||
_generalMessages = (data.messages || []).slice();
|
||||
_generalLoaded = true;
|
||||
renderChatMessages(list, _generalMessages, _currentUser());
|
||||
_clearUnread();
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
const err = document.createElement("div");
|
||||
err.className = "file-chat-empty";
|
||||
err.textContent = t("chat.load_error");
|
||||
list.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
|
||||
export function filterChatMessages(query) {
|
||||
const list = _sidebarList();
|
||||
if (!list || !_generalLoaded) return;
|
||||
const q = String(query || "").toLowerCase().trim();
|
||||
const shown = q
|
||||
? _generalMessages.filter((m) =>
|
||||
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
|
||||
)
|
||||
: _generalMessages;
|
||||
renderChatMessages(list, shown, _currentUser());
|
||||
}
|
||||
|
||||
function _sendInput() {
|
||||
return document.getElementById("sidebar-panel-chat-input");
|
||||
}
|
||||
|
||||
function _renderPending() {
|
||||
const holder = document.getElementById("sidebar-panel-chat-pending");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
if (!_pendingAttach) return;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "file-chat-pending";
|
||||
chip.textContent =
|
||||
(_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe");
|
||||
const x = document.createElement("button");
|
||||
x.type = "button";
|
||||
x.textContent = "✕";
|
||||
x.setAttribute("aria-label", t("chat.attach_remove"));
|
||||
x.addEventListener("click", () => {
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
});
|
||||
chip.appendChild(x);
|
||||
holder.appendChild(chip);
|
||||
}
|
||||
|
||||
/** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */
|
||||
export async function sendGeneralMessage() {
|
||||
const input = _sendInput();
|
||||
if (!input) return;
|
||||
const text = (input.value || "").trim();
|
||||
if (!text && !_pendingAttach) return;
|
||||
const body = { text };
|
||||
if (_pendingAttach) body.attachment = _pendingAttach;
|
||||
try {
|
||||
const data = await api("/api/chat", { method: "POST", body: JSON.stringify(body) });
|
||||
input.value = "";
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
const list = _sidebarList();
|
||||
if (list) {
|
||||
_generalMessages.push(data.message);
|
||||
_appendMessage(list, data.message);
|
||||
}
|
||||
input.focus();
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */
|
||||
export async function pickAttachment() {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*,video/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.remove();
|
||||
if (!file) return resolve(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await api("/api/chat/upload", { method: "POST", body: form });
|
||||
_pendingAttach = res.attachment;
|
||||
_renderPending();
|
||||
resolve(_pendingAttach);
|
||||
} catch (_) {
|
||||
showToast(t("chat.attach_error"), "error");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages non lus (#190 B) --------------------------------------------
|
||||
function _readUnread() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _clearUnread() {
|
||||
const u = _readUnread();
|
||||
u.general = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
|
||||
} catch (_) { /* quota */ }
|
||||
_renderBadge(0);
|
||||
}
|
||||
|
||||
function _renderBadge(count) {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
if (!tab) return;
|
||||
let badge = tab.querySelector(".chat-unread-badge");
|
||||
if (!count) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "chat-unread-badge";
|
||||
tab.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
}
|
||||
|
||||
/** Compte les messages reçus depuis la dernière visite. */
|
||||
export function refreshUnreadBadge(messages) {
|
||||
const list = messages || _generalMessages;
|
||||
if (!list.length) return _renderBadge(0);
|
||||
const seen = _readUnread().general || 0;
|
||||
const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length;
|
||||
_renderBadge(count);
|
||||
}
|
||||
|
||||
/** Initialisation : formulaire + raccourcis clavier. */
|
||||
export function initSidebarChat() {
|
||||
const form = document.getElementById("sidebar-panel-chat-form");
|
||||
if (!form) return;
|
||||
const input = document.getElementById("sidebar-panel-chat-input");
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
});
|
||||
const attach = document.getElementById("sidebar-panel-chat-attach");
|
||||
if (attach) attach.addEventListener("click", () => pickAttachment());
|
||||
if (input) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE relay (appelé par sync.js sur l'événement `chat_message`)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function onChatMessage(data) {
|
||||
if (!data || !data.message) return;
|
||||
const isGeneral = data.vault === GLOBAL_VAULT;
|
||||
|
||||
if (isGeneral) {
|
||||
_generalMessages.push(data.message);
|
||||
const list = _sidebarList();
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
const visible = panel && panel.classList.contains("active");
|
||||
if (list && visible) {
|
||||
_appendMessage(list, data.message);
|
||||
_clearUnread();
|
||||
} else if (data.message.user !== _currentUser()) {
|
||||
refreshUnreadBadge(_generalMessages);
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat d'un document (#169)
|
||||
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;
|
||||
}
|
||||
if (list) _appendMessage(list, data.message);
|
||||
return;
|
||||
}
|
||||
if (data.message.user === _currentUser()) return; // son propre message
|
||||
|
||||
@@ -6,6 +6,7 @@ import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRe
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault context switching
|
||||
@@ -704,13 +705,14 @@ function initSidebarFilter() {
|
||||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||||
|
||||
// Route the query to the active tab's own filter (#98/#99).
|
||||
// Route the query to the active tab's own filter (#98/#99, chat #190 C).
|
||||
const routeFilter = async (q) => {
|
||||
const tab = state.activeSidebarTab;
|
||||
if (tab === "vaults") await performTreeSearch(q);
|
||||
else if (tab === "recent") filterRecentFiles(q);
|
||||
else if (tab === "saved") filterSavedSearches(q);
|
||||
else if (tab === "ai") filterAIHistory(q);
|
||||
else if (tab === "chat") _filterChat(q);
|
||||
else filterTagCloud(q);
|
||||
};
|
||||
const routeClear = async () => {
|
||||
@@ -719,6 +721,7 @@ function initSidebarFilter() {
|
||||
else if (tab === "recent") filterRecentFiles("");
|
||||
else if (tab === "saved") filterSavedSearches("");
|
||||
else if (tab === "ai") filterAIHistory("");
|
||||
else if (tab === "chat") _filterChat("");
|
||||
else filterTagCloud("");
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +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 { toggleFileChat, followFileChat } 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';
|
||||
@@ -6897,6 +6897,8 @@ export function renderFile(data) {
|
||||
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
|
||||
// instead of being wiped mid-session by a tab switch / sidebar click.
|
||||
if (isInlineEditorActive()) detachInlineEditor();
|
||||
// #190 A — an open chat panel follows the document being rendered.
|
||||
followFileChat(data.vault, data.path, data.title);
|
||||
// #108 — release the image viewer's document-level shortcuts before swapping
|
||||
// the content area (otherwise they leak on every re-render).
|
||||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||||
@@ -7227,7 +7229,7 @@ export function renderFile(data) {
|
||||
// #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);
|
||||
toggleFileChat(data.vault, data.path, data.title);
|
||||
});
|
||||
// Check bookmark status and color the button
|
||||
(async () => {
|
||||
|
||||
@@ -319,6 +319,8 @@
|
||||
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Cancel",
|
||||
"chat.attach_error": "Attachment upload failed",
|
||||
"chat.attach_remove": "Remove attachment",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuss this file",
|
||||
"chat.close": "Close the discussion panel",
|
||||
@@ -328,6 +330,7 @@
|
||||
"chat.placeholder": "Write a message…",
|
||||
"chat.send": "Send",
|
||||
"chat.send_error": "Failed to send the message",
|
||||
"chat.tab": "General chat",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "All",
|
||||
@@ -1707,6 +1710,7 @@
|
||||
"sidebar.file_icons": "Icons",
|
||||
"sidebar.filter_instant": "Instant search",
|
||||
"sidebar.filter_ai": "Filter AI history...",
|
||||
"sidebar.filter_chat": "Search the chat...",
|
||||
"sidebar.filter_recent": "Filter recent files...",
|
||||
"sidebar.filter_saved": "Filter saved searches...",
|
||||
"sidebar.filter_path": "Path filters",
|
||||
|
||||
@@ -319,6 +319,8 @@
|
||||
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Annuler",
|
||||
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
|
||||
"chat.attach_remove": "Retirer la pièce jointe",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuter de ce fichier",
|
||||
"chat.close": "Fermer le panneau de discussion",
|
||||
@@ -328,6 +330,7 @@
|
||||
"chat.placeholder": "Écrire un message…",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.send_error": "Échec de l'envoi du message",
|
||||
"chat.tab": "Chat général",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "Tous",
|
||||
@@ -1707,6 +1710,7 @@
|
||||
"sidebar.file_icons": "Icônes",
|
||||
"sidebar.filter_instant": "Recherche instantanée",
|
||||
"sidebar.filter_ai": "Filtrer l'historique IA...",
|
||||
"sidebar.filter_chat": "Rechercher dans le chat...",
|
||||
"sidebar.filter_recent": "Filtrer les fichiers récents...",
|
||||
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
|
||||
"sidebar.filter_path": "Filtres de chemin",
|
||||
|
||||
@@ -13887,3 +13887,114 @@ body.reading-mode .file-actions {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
|
||||
--------------------------------------------------------------------------- */
|
||||
.file-chat-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-chat-target {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-tab {
|
||||
position: relative;
|
||||
}
|
||||
.chat-unread-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
border-radius: 8px;
|
||||
background: #e5534b;
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebar-chat-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-chat-panel.active {
|
||||
display: flex;
|
||||
}
|
||||
.sidebar-chat-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.sidebar-chat-pending {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.sidebar-chat-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border, #21262d);
|
||||
}
|
||||
.file-chat-attach {
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
color: var(--text-muted, #8b949e);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-attach:hover {
|
||||
color: var(--accent, #58a6ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px dashed var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
.file-chat-pending button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-chat-image {
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
.file-chat-video {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
.file-chat-link {
|
||||
color: var(--accent, #58a6ff);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user