feat: Implement works filter UI for chat page (LRP-139, 140, 141, 143)
- Add works filter section HTML above Context RAG sidebar - Add CSS styles for works filter with checkboxes, badges, and collapse - Implement JavaScript for loading works from /api/get-works - Add localStorage persistence for selected works - Integrate selected_works parameter with /chat/send API call - Add Tout/Aucun buttons for quick selection - Add collapsible section with chevron toggle - Responsive design for mobile screens 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -645,6 +645,187 @@
|
|||||||
height: 15px;
|
height: 15px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar panel - container for both works filter and context */
|
||||||
|
.sidebar-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Works filter section */
|
||||||
|
.works-filter-section {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(125, 110, 88, 0.25);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-content {
|
||||||
|
padding: 0.75rem 1rem 1rem 1rem;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(125, 110, 88, 0.3);
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini:hover {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-count-badge {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(125, 110, 88, 0.15);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-item:hover {
|
||||||
|
background-color: rgba(125, 110, 88, 0.08);
|
||||||
|
border-color: rgba(125, 110, 88, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-item.selected {
|
||||||
|
background-color: rgba(125, 110, 88, 0.1);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-checkbox {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-title {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-author {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-accent-alt);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-count {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background-color: rgba(125, 110, 88, 0.1);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Works filter scrollbar */
|
||||||
|
.works-filter-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-content::-webkit-scrollbar-track {
|
||||||
|
background: rgba(125, 110, 88, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(125, 110, 88, 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(125, 110, 88, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update context sidebar to fill remaining space */
|
||||||
|
.sidebar-panel .context-sidebar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive - Mobile */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.sidebar-panel {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-section {
|
||||||
|
order: -1;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.works-filter-content {
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel .context-sidebar {
|
||||||
|
order: -1;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
@@ -707,8 +888,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Context sidebar -->
|
<!-- Works filter sidebar -->
|
||||||
<div class="context-sidebar" id="context-sidebar">
|
<div class="sidebar-panel" id="sidebar-panel">
|
||||||
|
<!-- Works filter section -->
|
||||||
|
<div class="works-filter-section" id="works-filter-section">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-title">
|
||||||
|
<span>📚</span>
|
||||||
|
<span>Filtrer par œuvres</span>
|
||||||
|
<span class="works-count-badge" id="works-count-badge">0/0</span>
|
||||||
|
</div>
|
||||||
|
<button class="collapse-btn" id="works-collapse-btn" title="Réduire">▼</button>
|
||||||
|
</div>
|
||||||
|
<div class="works-filter-content" id="works-filter-content">
|
||||||
|
<div class="works-filter-actions">
|
||||||
|
<button class="btn-mini" id="select-all-works">Tout</button>
|
||||||
|
<button class="btn-mini" id="select-none-works">Aucun</button>
|
||||||
|
</div>
|
||||||
|
<div class="works-list" id="works-list">
|
||||||
|
<div class="sidebar-empty">Chargement des œuvres...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Context sidebar -->
|
||||||
|
<div class="context-sidebar" id="context-sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="sidebar-title">
|
<div class="sidebar-title">
|
||||||
<span>📚</span>
|
<span>📚</span>
|
||||||
@@ -722,6 +926,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Markdown and syntax highlighting libraries -->
|
<!-- Markdown and syntax highlighting libraries -->
|
||||||
@@ -741,10 +946,159 @@
|
|||||||
const collapseBtn = document.getElementById('collapse-btn');
|
const collapseBtn = document.getElementById('collapse-btn');
|
||||||
const contextSidebar = document.getElementById('context-sidebar');
|
const contextSidebar = document.getElementById('context-sidebar');
|
||||||
|
|
||||||
|
// Works filter DOM elements
|
||||||
|
const worksFilterContent = document.getElementById('works-filter-content');
|
||||||
|
const worksCollapseBtn = document.getElementById('works-collapse-btn');
|
||||||
|
const worksCountBadge = document.getElementById('works-count-badge');
|
||||||
|
const worksList = document.getElementById('works-list');
|
||||||
|
const selectAllWorksBtn = document.getElementById('select-all-works');
|
||||||
|
const selectNoneWorksBtn = document.getElementById('select-none-works');
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let isGenerating = false;
|
let isGenerating = false;
|
||||||
let currentEventSource = null;
|
let currentEventSource = null;
|
||||||
|
|
||||||
|
// Works filter state
|
||||||
|
let availableWorks = [];
|
||||||
|
let selectedWorks = [];
|
||||||
|
|
||||||
|
// ========== WORKS FILTER FUNCTIONS ==========
|
||||||
|
|
||||||
|
async function loadAvailableWorks() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/get-works');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load works');
|
||||||
|
}
|
||||||
|
availableWorks = await response.json();
|
||||||
|
|
||||||
|
// Load saved selection from localStorage or select all by default
|
||||||
|
const savedSelection = localStorage.getItem('selectedWorks');
|
||||||
|
if (savedSelection) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedSelection);
|
||||||
|
// Filter to only keep works that still exist
|
||||||
|
const existingTitles = availableWorks.map(w => w.title);
|
||||||
|
selectedWorks = parsed.filter(title => existingTitles.includes(title));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing saved selection:', e);
|
||||||
|
selectedWorks = availableWorks.map(w => w.title);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: all works selected
|
||||||
|
selectedWorks = availableWorks.map(w => w.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If savedSelection resulted in empty (all works removed), select all
|
||||||
|
if (selectedWorks.length === 0 && availableWorks.length > 0) {
|
||||||
|
selectedWorks = availableWorks.map(w => w.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWorksList();
|
||||||
|
updateWorksCount();
|
||||||
|
saveSelectedWorksToStorage();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading works:', error);
|
||||||
|
worksList.innerHTML = '<div class="sidebar-empty">Erreur de chargement des œuvres</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorksList() {
|
||||||
|
worksList.innerHTML = '';
|
||||||
|
|
||||||
|
if (availableWorks.length === 0) {
|
||||||
|
worksList.innerHTML = '<div class="sidebar-empty">Aucune œuvre disponible</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
availableWorks.forEach(work => {
|
||||||
|
const isSelected = selectedWorks.includes(work.title);
|
||||||
|
|
||||||
|
const workItem = document.createElement('div');
|
||||||
|
workItem.className = `work-item${isSelected ? ' selected' : ''}`;
|
||||||
|
workItem.innerHTML = `
|
||||||
|
<input type="checkbox" class="work-checkbox" ${isSelected ? 'checked' : ''} data-title="${work.title.replace(/"/g, '"')}">
|
||||||
|
<div class="work-info">
|
||||||
|
<div class="work-title" title="${work.title.replace(/"/g, '"')}">${work.title}</div>
|
||||||
|
<div class="work-author">${work.author}</div>
|
||||||
|
</div>
|
||||||
|
<div class="work-count">${work.chunks_count} passages</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Click on work item toggles checkbox
|
||||||
|
workItem.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('work-checkbox')) return; // Let checkbox handle itself
|
||||||
|
const checkbox = workItem.querySelector('.work-checkbox');
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
toggleWorkSelection(work.title, checkbox.checked);
|
||||||
|
workItem.classList.toggle('selected', checkbox.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkbox change event
|
||||||
|
const checkbox = workItem.querySelector('.work-checkbox');
|
||||||
|
checkbox.addEventListener('change', (e) => {
|
||||||
|
toggleWorkSelection(work.title, e.target.checked);
|
||||||
|
workItem.classList.toggle('selected', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
worksList.appendChild(workItem);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWorkSelection(title, isSelected) {
|
||||||
|
if (isSelected) {
|
||||||
|
if (!selectedWorks.includes(title)) {
|
||||||
|
selectedWorks.push(title);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedWorks = selectedWorks.filter(t => t !== title);
|
||||||
|
}
|
||||||
|
updateWorksCount();
|
||||||
|
saveSelectedWorksToStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWorksCount() {
|
||||||
|
worksCountBadge.textContent = `${selectedWorks.length}/${availableWorks.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSelectedWorksToStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('selectedWorks', JSON.stringify(selectedWorks));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error saving to localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select All button
|
||||||
|
selectAllWorksBtn.addEventListener('click', () => {
|
||||||
|
selectedWorks = availableWorks.map(w => w.title);
|
||||||
|
renderWorksList();
|
||||||
|
updateWorksCount();
|
||||||
|
saveSelectedWorksToStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select None button
|
||||||
|
selectNoneWorksBtn.addEventListener('click', () => {
|
||||||
|
selectedWorks = [];
|
||||||
|
renderWorksList();
|
||||||
|
updateWorksCount();
|
||||||
|
saveSelectedWorksToStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Works filter collapse button
|
||||||
|
worksCollapseBtn.addEventListener('click', () => {
|
||||||
|
const isCollapsed = worksFilterContent.classList.contains('collapsed');
|
||||||
|
worksFilterContent.classList.toggle('collapsed');
|
||||||
|
worksCollapseBtn.textContent = isCollapsed ? '▼' : '▲';
|
||||||
|
worksCollapseBtn.title = isCollapsed ? 'Réduire' : 'Développer';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load works on page load
|
||||||
|
loadAvailableWorks();
|
||||||
|
|
||||||
|
// ========== END WORKS FILTER FUNCTIONS ==========
|
||||||
|
|
||||||
// Configure marked for markdown rendering
|
// Configure marked for markdown rendering
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
@@ -950,7 +1304,7 @@
|
|||||||
const typingId = addTypingIndicator();
|
const typingId = addTypingIndicator();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// POST /chat/send with chosen question
|
// POST /chat/send with chosen question and selected works filter
|
||||||
const response = await fetch('/chat/send', {
|
const response = await fetch('/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -959,7 +1313,8 @@
|
|||||||
provider: provider,
|
provider: provider,
|
||||||
model: model,
|
model: model,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
use_reformulation: false // Reformulation already done
|
use_reformulation: false, // Reformulation already done
|
||||||
|
selected_works: selectedWorks // Filter by selected works
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user