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:
2026-01-04 14:46:48 +01:00
parent 7e8367863d
commit 82da123ef7

View File

@@ -645,6 +645,187 @@
height: 15px;
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>
<div class="chat-container">
@@ -707,8 +888,31 @@
</div>
</div>
<!-- Context sidebar -->
<div class="context-sidebar" id="context-sidebar">
<!-- Works filter 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-title">
<span>📚</span>
@@ -722,6 +926,7 @@
</div>
</div>
</div>
</div>
</div>
<!-- Markdown and syntax highlighting libraries -->
@@ -741,10 +946,159 @@
const collapseBtn = document.getElementById('collapse-btn');
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
let isGenerating = false;
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, '&quot;')}">
<div class="work-info">
<div class="work-title" title="${work.title.replace(/"/g, '&quot;')}">${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
marked.setOptions({
breaks: true,
@@ -950,7 +1304,7 @@
const typingId = addTypingIndicator();
try {
// POST /chat/send with chosen question
// POST /chat/send with chosen question and selected works filter
const response = await fetch('/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -959,7 +1313,8 @@
provider: provider,
model: model,
limit: 5,
use_reformulation: false // Reformulation already done
use_reformulation: false, // Reformulation already done
selected_works: selectedWorks // Filter by selected works
})
});