Files
linear-coding-agent/generations/library_rag/templates/chat.html
David Blanc Brioir feb215dae0 revert: Remove max-height from works-list (causes double scrollbar)
- Removed max-height: 300px from .works-list
- Keeps only the Unicode encoding fix (→ to ->)
- Avoids having two scrollbars in the works filter section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-04 16:50:29 +01:00

1837 lines
58 KiB
HTML

{% extends "base.html" %}
{% block title %}Conversation RAG{% endblock %}
{% block content %}
<style>
/* Override wrapper max-width for full-screen chat */
.wrapper {
max-width: 100% !important;
padding-left: 80px !important; /* Space for hamburger button */
padding-right: 1.5rem !important;
}
@media (min-width: 992px) {
.wrapper {
padding-left: 80px !important;
padding-right: 2rem !important;
}
}
/* Keep header fixed but adjust for chat full-width layout */
.site-header {
padding-left: calc(1rem + 48px + 1rem) !important; /* Clear hamburger menu */
}
@media (min-width: 992px) {
.site-header {
padding-left: calc(1rem + 48px + 1rem) !important;
}
}
/* Chat-specific styles */
.chat-container {
display: grid;
grid-template-columns: 60% 40%;
gap: 1.5rem;
height: calc(100vh - 90px - 1.5rem);
min-height: 600px;
width: 100%;
}
@media (max-width: 992px) {
.chat-container {
grid-template-columns: 1fr;
height: auto;
}
.context-sidebar {
order: -1;
max-height: 300px;
}
}
/* Chat area */
.chat-main {
display: flex;
flex-direction: column;
height: 100%;
background-color: rgba(255, 255, 255, 0.06);
border-radius: 12px;
border: 1px solid rgba(125, 110, 88, 0.25);
overflow: hidden;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid rgba(125, 110, 88, 0.15);
background-color: var(--color-bg-secondary);
}
.chat-title {
font-family: var(--font-title);
font-size: 1.3rem;
font-weight: 600;
color: var(--color-text-strong);
display: flex;
align-items: center;
gap: 0.5rem;
}
.model-selector-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
}
.model-selector-label {
font-family: var(--font-title);
font-size: 0.85rem;
font-weight: 500;
color: var(--color-accent-alt);
text-transform: uppercase;
letter-spacing: 0.05em;
}
#model-selector {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid rgba(125, 110, 88, 0.3);
background-color: #fff;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-text-main);
cursor: pointer;
min-width: 200px;
}
/* Messages area */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.message {
display: flex;
flex-direction: column;
max-width: 85%;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-user {
align-self: flex-end;
}
.message-assistant {
align-self: flex-start;
}
.message-content {
padding: 1rem 1.25rem;
border-radius: 12px;
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.6;
}
.message-user .message-content {
background-color: #e3f2fd;
color: var(--color-text-main);
border: 1px solid rgba(33, 150, 243, 0.2);
}
.message-assistant .message-content {
background-color: rgba(255, 255, 255, 0.5);
color: var(--color-text-main);
border: 1px solid rgba(125, 110, 88, 0.2);
}
.message-label {
font-family: var(--font-title);
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-accent-alt);
margin-bottom: 0.4rem;
padding-left: 0.25rem;
}
/* Loading indicator */
.typing-indicator {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.75rem 1rem;
}
.typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-accent);
animation: typing 1.4s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
}
/* Input area */
.chat-input-area {
padding: 1.25rem 1.75rem;
border-top: 1px solid rgba(125, 110, 88, 0.15);
background-color: var(--color-bg-secondary);
}
.input-wrapper {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
#chat-input {
flex: 1;
padding: 0.85rem 1rem;
border-radius: 8px;
border: 1px solid rgba(125, 110, 88, 0.3);
background-color: #fff;
font-family: var(--font-body);
font-size: 1rem;
color: var(--color-text-main);
resize: vertical;
min-height: 50px;
max-height: 150px;
}
#chat-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(125, 110, 88, 0.1);
}
#send-btn {
padding: 0.85rem 1.75rem;
white-space: nowrap;
}
#send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.character-count {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--color-accent);
text-align: right;
margin-top: 0.25rem;
}
/* Context sidebar */
.context-sidebar {
display: flex;
flex-direction: column;
height: 100%;
background-color: rgba(255, 255, 255, 0.06);
border-radius: 12px;
border: 1px solid rgba(125, 110, 88, 0.25);
overflow: hidden;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(125, 110, 88, 0.15);
background-color: var(--color-bg-secondary);
}
.sidebar-title {
font-family: var(--font-title);
font-size: 1rem;
font-weight: 600;
color: var(--color-text-strong);
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-btn {
background: none;
border: none;
color: var(--color-accent);
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem;
transition: transform 0.3s ease;
}
.collapse-btn:hover {
transform: scale(1.1);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.sidebar-empty {
text-align: center;
padding: 2rem 1rem;
color: var(--color-accent-alt);
font-family: var(--font-body);
font-size: 0.9rem;
font-style: italic;
}
.context-chunk {
background-color: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 0;
margin-bottom: 0.75rem;
border: 1px solid rgba(125, 110, 88, 0.15);
border-left: 3px solid var(--color-accent-alt);
transition: box-shadow 0.2s ease;
}
.context-chunk:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.chunk-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem;
cursor: pointer;
user-select: none;
}
.chunk-header:hover {
background-color: rgba(125, 110, 88, 0.03);
}
.chunk-badges {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
flex: 1;
}
.chunk-chevron {
font-size: 1rem;
color: var(--color-accent);
transition: transform 0.3s ease;
flex-shrink: 0;
margin-left: 0.5rem;
}
.chunk-chevron.expanded {
transform: rotate(180deg);
}
.chunk-body {
padding: 0 1rem 1rem 1rem;
display: block;
}
.chunk-text {
font-family: var(--font-body);
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-main);
margin-bottom: 0.5rem;
white-space: pre-wrap;
}
.chunk-text.collapsed {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.chunk-meta {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--color-accent);
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(125, 110, 88, 0.1);
}
/* Reformulation notice */
.reformulation-notice {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
margin: 0.75rem auto;
max-width: 85%;
background-color: rgba(125, 110, 88, 0.08);
border-left: 3px solid var(--color-accent);
border-radius: 8px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.reformulation-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.reformulation-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.reformulation-title {
font-family: var(--font-title);
font-size: 0.95rem;
font-weight: 600;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.reformulation-comparison {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reformulation-item {
background-color: rgba(255, 255, 255, 0.5);
border-radius: 6px;
padding: 0.85rem;
border: 1px solid rgba(125, 110, 88, 0.15);
}
.reformulation-item.selected {
border: 2px solid var(--color-accent);
background-color: rgba(125, 110, 88, 0.05);
}
.reformulation-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.reformulation-label {
font-family: var(--font-title);
font-size: 0.75rem;
font-weight: 500;
color: var(--color-accent-alt);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.reformulation-badge {
font-size: 0.7rem;
font-weight: 600;
color: #fff;
background-color: var(--color-accent);
padding: 0.2rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
}
.reformulation-text {
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-text-main);
line-height: 1.5;
}
.reformulation-item.original .reformulation-text {
font-style: normal;
}
.reformulation-item.reformulated .reformulation-text {
font-style: italic;
}
.reformulation-actions {
display: flex;
gap: 0.75rem;
margin-top: 0.75rem;
}
.reformulation-btn {
flex: 1;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-family: var(--font-title);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.reformulation-btn.btn-original {
background-color: rgba(125, 110, 88, 0.15);
color: var(--color-text-strong);
border: 1px solid rgba(125, 110, 88, 0.3);
}
.reformulation-btn.btn-original:hover {
background-color: rgba(125, 110, 88, 0.25);
}
.reformulation-btn.btn-reformulated {
background-color: var(--color-accent);
color: white;
}
.reformulation-btn.btn-reformulated:hover {
background-color: #6d5f4c;
}
.reformulation-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Empty state */
.chat-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
color: var(--color-accent-alt);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.empty-text {
font-family: var(--font-title);
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 0.5rem;
}
.empty-hint {
font-family: var(--font-body);
font-size: 0.9rem;
opacity: 0.7;
}
/* Scrollbar styling */
.chat-messages::-webkit-scrollbar,
.sidebar-content::-webkit-scrollbar {
width: 8px;
}
.chat-messages::-webkit-scrollbar-track,
.sidebar-content::-webkit-scrollbar-track {
background: rgba(125, 110, 88, 0.05);
border-radius: 4px;
}
.chat-messages::-webkit-scrollbar-thumb,
.sidebar-content::-webkit-scrollbar-thumb {
background: rgba(125, 110, 88, 0.3);
border-radius: 4px;
}
.chat-messages::-webkit-scrollbar-thumb:hover,
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: rgba(125, 110, 88, 0.5);
}
/* Export buttons - compact size */
.export-word-btn,
.export-pdf-btn,
.export-audio-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.85rem 1.75rem;
margin-top: 0.75rem;
margin-right: 0.5rem;
border-radius: 8px;
border: 1.5px solid var(--color-accent);
background-color: var(--color-accent);
color: var(--color-bg-main);
font-family: var(--font-body);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
max-width: fit-content;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.export-word-btn:hover,
.export-pdf-btn:hover,
.export-audio-btn:hover {
background-color: var(--color-accent-alt);
border-color: var(--color-accent-alt);
color: var(--color-bg-main);
}
.export-word-btn svg,
.export-pdf-btn svg,
.export-audio-btn svg {
width: 15px;
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">
<!-- Main chat area -->
<div class="chat-main">
<!-- Header with model selector -->
<div class="chat-header">
<div class="chat-title">
<span>💬</span>
<span>Conversation RAG</span>
</div>
<div class="model-selector-wrapper">
<label class="model-selector-label" for="model-selector">Modèle</label>
<select id="model-selector" class="form-control">
<optgroup label="OpenAI API (Recommandé)">
<option value="openai:gpt-5.2" selected>ChatGPT 5.2 - Dernier modèle ⭐</option>
<option value="openai:gpt-4o">GPT-4o</option>
<option value="openai:gpt-4o-mini">GPT-4o Mini</option>
<option value="openai:o1-preview">o1-preview (Raisonnement)</option>
</optgroup>
<optgroup label="Anthropic API">
<option value="anthropic:claude-sonnet-4-5-20250929">Claude Sonnet 4.5</option>
<option value="anthropic:claude-opus-4-5-20251101">Claude Opus 4.5</option>
</optgroup>
<optgroup label="Mistral API">
<option value="mistral:mistral-small-latest">Mistral Small</option>
<option value="mistral:mistral-large-latest">Mistral Large</option>
</optgroup>
<optgroup label="Ollama (Local - Gratuit)">
<option value="ollama:qwen2.5:7b">Qwen 2.5 7B</option>
<option value="ollama:deepseek-r1:14b">DeepSeek R1 14B</option>
</optgroup>
</select>
</div>
</div>
<!-- Messages area -->
<div class="chat-messages" id="chat-messages">
<!-- Empty state -->
<div class="chat-empty-state" id="empty-state">
<div class="empty-icon">💭</div>
<div class="empty-text">Bienvenue dans la Conversation RAG</div>
<div class="empty-hint">Posez une question pour commencer la recherche sémantique</div>
</div>
</div>
<!-- Input area -->
<div class="chat-input-area">
<div class="input-wrapper">
<textarea
id="chat-input"
placeholder="Posez votre question sur la philosophie..."
rows="2"
></textarea>
<button id="send-btn" class="btn btn-primary">
Envoyer
</button>
</div>
<div class="character-count" id="char-count">0 / 2000</div>
</div>
</div>
<!-- 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>
<span>Contexte RAG</span>
</div>
<button class="collapse-btn" id="collapse-btn" title="Réduire"></button>
</div>
<div class="sidebar-content" id="sidebar-content">
<div class="sidebar-empty">
Posez une question pour voir le contexte utilisé
</div>
</div>
</div>
</div>
</div>
<!-- Markdown and syntax highlighting libraries -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script>
// DOM elements
const chatMessages = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const charCount = document.getElementById('char-count');
const emptyState = document.getElementById('empty-state');
const modelSelector = document.getElementById('model-selector');
const sidebarContent = document.getElementById('sidebar-content');
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,
gfm: true,
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
}
});
// Character counter
chatInput.addEventListener('input', () => {
const length = chatInput.value.length;
charCount.textContent = `${length} / 2000`;
sendBtn.disabled = length === 0 || length > 2000 || isGenerating;
});
// Auto-resize textarea
chatInput.addEventListener('input', () => {
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 150) + 'px';
});
// Send message on Enter (without Shift)
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Send button click
sendBtn.addEventListener('click', sendMessage);
// Collapse sidebar
collapseBtn.addEventListener('click', () => {
const isCollapsed = sidebarContent.style.display === 'none';
sidebarContent.style.display = isCollapsed ? 'block' : 'none';
collapseBtn.textContent = isCollapsed ? '▼' : '▲';
collapseBtn.title = isCollapsed ? 'Réduire' : 'Développer';
});
async function sendMessage() {
const question = chatInput.value.trim();
if (!question || isGenerating) return;
// Parse model selector (format: "provider:model")
const selectedValue = modelSelector.value;
const [provider, model] = selectedValue.split(':');
if (!provider || !model) {
addErrorMessage('Erreur: Format de modèle invalide');
return;
}
// Hide empty state
if (emptyState) {
emptyState.remove();
}
// Add user message to chat
addMessage('user', question);
// Clear input
chatInput.value = '';
chatInput.style.height = 'auto';
charCount.textContent = '0 / 2000';
// Disable send button
isGenerating = true;
sendBtn.disabled = true;
sendBtn.textContent = 'Reformulation...';
// Show typing indicator
const typingId = addTypingIndicator();
try {
// Step 1: POST /chat/reformulate to get reformulated question
const reformulateResponse = await fetch('/chat/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: question,
provider: provider,
model: model
})
});
if (!reformulateResponse.ok) {
const error = await reformulateResponse.json();
throw new Error(error.error || 'Erreur de reformulation');
}
const reformulateData = await reformulateResponse.json();
const original = reformulateData.original;
const reformulated = reformulateData.reformulated;
// Remove typing indicator
removeTypingIndicator(typingId);
// Step 2: Display reformulation with choice buttons
displayReformulationChoice(original, reformulated, provider, model);
// Re-enable send button
isGenerating = false;
sendBtn.disabled = false;
sendBtn.textContent = 'Envoyer';
} catch (error) {
console.error('Error:', error);
removeTypingIndicator(typingId);
addErrorMessage(`Erreur: ${error.message}`);
isGenerating = false;
sendBtn.disabled = false;
sendBtn.textContent = 'Envoyer';
}
}
function displayReformulationChoice(original, reformulated, provider, model) {
// Create comparison with choice buttons
const reformulationDiv = document.createElement('div');
reformulationDiv.className = 'reformulation-notice';
reformulationDiv.innerHTML = `
<div class="reformulation-header">
<div class="reformulation-icon">🔄</div>
<div class="reformulation-title">Reformulation de la question</div>
</div>
<div class="reformulation-comparison">
<div class="reformulation-item original">
<div class="reformulation-item-header">
<div class="reformulation-label">Question Originale</div>
</div>
<div class="reformulation-text">${original}</div>
</div>
<div class="reformulation-item reformulated">
<div class="reformulation-item-header">
<div class="reformulation-label">Version Reformulée</div>
</div>
<div class="reformulation-text">${reformulated}</div>
</div>
</div>
<div class="reformulation-actions">
<button class="reformulation-btn btn-original" data-question="${original.replace(/"/g, '&quot;')}">
Utiliser l'originale
</button>
<button class="reformulation-btn btn-reformulated" data-question="${reformulated.replace(/"/g, '&quot;')}">
Utiliser la reformulation (Recommandé)
</button>
</div>
`;
chatMessages.appendChild(reformulationDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Add click handlers to buttons
const buttons = reformulationDiv.querySelectorAll('.reformulation-btn');
buttons.forEach(btn => {
btn.addEventListener('click', async () => {
const chosenQuestion = btn.getAttribute('data-question');
const isReformulated = btn.classList.contains('btn-reformulated');
// Store context for export
window.currentChatContext = {
question: chosenQuestion,
isReformulated: isReformulated,
originalQuestion: isReformulated ? original : null
};
// Disable both buttons
buttons.forEach(b => b.disabled = true);
// Mark chosen version
if (isReformulated) {
reformulationDiv.querySelector('.reformulation-item.reformulated').classList.add('selected');
reformulationDiv.querySelector('.reformulation-item.reformulated .reformulation-item-header').innerHTML += `
<div class="reformulation-badge">✓ Utilisée</div>
`;
} else {
reformulationDiv.querySelector('.reformulation-item.original').classList.add('selected');
reformulationDiv.querySelector('.reformulation-item.original .reformulation-item-header').innerHTML += `
<div class="reformulation-badge">✓ Utilisée</div>
`;
}
// Remove action buttons
reformulationDiv.querySelector('.reformulation-actions').remove();
// Start RAG search with chosen question
await startRAGSearch(chosenQuestion, provider, model);
});
});
}
async function startRAGSearch(question, provider, model) {
// Disable send button
isGenerating = true;
sendBtn.disabled = true;
sendBtn.textContent = 'Génération...';
// Show typing indicator
const typingId = addTypingIndicator();
try {
// POST /chat/send with chosen question and selected works filter
const response = await fetch('/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question: question,
provider: provider,
model: model,
limit: 5,
use_reformulation: false, // Reformulation already done
selected_works: selectedWorks // Filter by selected works
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur HTTP');
}
const data = await response.json();
const sessionId = data.session_id;
// SSE stream with chat context
startSSEStream(sessionId, typingId, window.currentChatContext || {});
} catch (error) {
console.error('Error:', error);
removeTypingIndicator(typingId);
addErrorMessage(`Erreur: ${error.message}`);
isGenerating = false;
sendBtn.disabled = false;
sendBtn.textContent = 'Envoyer';
}
}
function startSSEStream(sessionId, typingId, chatContext = {}) {
// Close previous EventSource if exists
if (currentEventSource) {
currentEventSource.close();
}
const eventSource = new EventSource(`/chat/stream/${sessionId}`);
currentEventSource = eventSource;
let assistantMessageDiv = null;
let assistantContentDiv = null;
let exportWordBtn = null;
let exportPdfBtn = null;
let exportAudioBtn = null;
let exportContainer = null;
let accumulatedText = '';
// Get chat context (question, reformulation status)
const currentQuestion = chatContext.question || '';
const isReformulated = chatContext.isReformulated || false;
const originalQuestion = chatContext.originalQuestion || '';
eventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'context') {
// Display RAG context in sidebar
displayContext(data.chunks);
}
else if (data.type === 'token') {
// Remove typing indicator on first token
if (typingId && document.getElementById(typingId)) {
removeTypingIndicator(typingId);
}
// Create assistant message on first token
if (!assistantMessageDiv) {
const result = createAssistantMessage();
assistantMessageDiv = result.messageDiv;
assistantContentDiv = result.contentDiv;
exportWordBtn = result.exportWordBtn;
exportPdfBtn = result.exportPdfBtn;
exportAudioBtn = result.exportAudioBtn;
exportContainer = result.exportContainer;
}
// Accumulate text
accumulatedText += data.content;
// Render markdown incrementally
assistantContentDiv.innerHTML = marked.parse(accumulatedText);
// Apply syntax highlighting to code blocks
assistantContentDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
}
else if (data.type === 'complete') {
// Generation complete
// Show export buttons
if (exportContainer && accumulatedText) {
exportContainer.style.display = 'block';
// Add click handler for Word export
exportWordBtn.addEventListener('click', async () => {
await exportToWord(
currentQuestion,
accumulatedText,
isReformulated,
originalQuestion
);
});
// Add click handler for PDF export
exportPdfBtn.addEventListener('click', async () => {
await exportToPdf(
currentQuestion,
accumulatedText,
isReformulated,
originalQuestion
);
});
// Add click handler for Audio export
exportAudioBtn.addEventListener('click', async () => {
await exportToAudio(accumulatedText, exportAudioBtn);
});
}
eventSource.close();
currentEventSource = null;
isGenerating = false;
sendBtn.disabled = false;
sendBtn.textContent = 'Envoyer';
}
else if (data.type === 'error') {
// Error occurred
removeTypingIndicator(typingId);
addErrorMessage(`Erreur: ${data.message}`);
eventSource.close();
currentEventSource = null;
isGenerating = false;
sendBtn.disabled = false;
sendBtn.textContent = 'Envoyer';
}
} catch (e) {
console.error('Parse error:', e);
}
};
eventSource.onerror = function(error) {
console.error('SSE error:', error);
removeTypingIndicator(typingId);
addErrorMessage('Erreur de connexion au serveur');
eventSource.close();
currentEventSource = null;
isGenerating = false;
sendBtn.disabled = false;
sendBtn.textContent = 'Envoyer';
};
}
function addMessage(role, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${role}`;
const label = document.createElement('div');
label.className = 'message-label';
label.textContent = role === 'user' ? 'Vous' : 'Assistant';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.textContent = content;
messageDiv.appendChild(label);
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function createAssistantMessage() {
const messageDiv = document.createElement('div');
messageDiv.className = 'message message-assistant';
const label = document.createElement('div');
label.className = 'message-label';
label.textContent = 'Assistant';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
// Add export buttons container
const exportContainer = document.createElement('div');
exportContainer.style.display = 'none'; // Hidden until response is complete
// Add export Word button
const exportWordBtn = document.createElement('button');
exportWordBtn.className = 'export-word-btn';
exportWordBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
<path d="M14 2v6h6"/>
<path d="M10.5 13.5l1.5 3 1.5-3"/>
</svg>
Word
`;
// Add export PDF button
const exportPdfBtn = document.createElement('button');
exportPdfBtn.className = 'export-pdf-btn';
exportPdfBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
<path d="M14 2v6h6"/>
</svg>
PDF
`;
// Add export Audio button
const exportAudioBtn = document.createElement('button');
exportAudioBtn.className = 'export-audio-btn';
exportAudioBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
Audio
`;
exportContainer.appendChild(exportWordBtn);
exportContainer.appendChild(exportPdfBtn);
exportContainer.appendChild(exportAudioBtn);
messageDiv.appendChild(label);
messageDiv.appendChild(contentDiv);
messageDiv.appendChild(exportContainer);
chatMessages.appendChild(messageDiv);
return { messageDiv, contentDiv, exportWordBtn, exportPdfBtn, exportAudioBtn, exportContainer };
}
function addErrorMessage(message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message message-assistant';
const label = document.createElement('div');
label.className = 'message-label';
label.textContent = 'Erreur';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.style.color = '#f44336';
contentDiv.style.borderColor = '#f44336';
contentDiv.textContent = message;
messageDiv.appendChild(label);
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function addTypingIndicator() {
const messageDiv = document.createElement('div');
messageDiv.className = 'message message-assistant';
messageDiv.id = 'typing-indicator';
const label = document.createElement('div');
label.className = 'message-label';
label.textContent = 'Assistant';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
const typingDiv = document.createElement('div');
typingDiv.className = 'typing-indicator';
typingDiv.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
contentDiv.appendChild(typingDiv);
messageDiv.appendChild(label);
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
return 'typing-indicator';
}
function removeTypingIndicator(id) {
const indicator = document.getElementById(id);
if (indicator) {
indicator.remove();
}
}
function displayContext(chunks) {
sidebarContent.innerHTML = '';
if (!chunks || chunks.length === 0) {
sidebarContent.innerHTML = '<div class="sidebar-empty">Aucun contexte trouvé</div>';
return;
}
chunks.forEach((chunk, index) => {
const chunkDiv = document.createElement('div');
chunkDiv.className = 'context-chunk';
// Header (clickable)
const headerDiv = document.createElement('div');
headerDiv.className = 'chunk-header';
const badgesDiv = document.createElement('div');
badgesDiv.className = 'chunk-badges';
badgesDiv.innerHTML = `
<span class="badge badge-author">${chunk.author || 'Auteur inconnu'}</span>
<span class="badge badge-work">${chunk.work || 'Œuvre inconnue'}</span>
<span class="badge badge-similarity">⚡ ${chunk.similarity || 0}%</span>
`;
const chevron = document.createElement('span');
chevron.className = 'chunk-chevron';
chevron.textContent = '►';
headerDiv.appendChild(badgesDiv);
headerDiv.appendChild(chevron);
// Body (expandable)
const bodyDiv = document.createElement('div');
bodyDiv.className = 'chunk-body';
const textDiv = document.createElement('div');
textDiv.className = 'chunk-text collapsed'; // Collapsed by default (3 lines)
textDiv.textContent = chunk.text || '';
const metaDiv = document.createElement('div');
metaDiv.className = 'chunk-meta';
metaDiv.textContent = chunk.section || '';
bodyDiv.appendChild(textDiv);
bodyDiv.appendChild(metaDiv);
// Click handler for expand/collapse
headerDiv.addEventListener('click', () => {
const isExpanded = textDiv.classList.contains('collapsed');
if (isExpanded) {
// Expand - show full text
textDiv.classList.remove('collapsed');
chevron.classList.add('expanded');
chevron.textContent = '▼';
} else {
// Collapse - show only 3 lines
textDiv.classList.add('collapsed');
chevron.classList.remove('expanded');
chevron.textContent = '►';
}
});
chunkDiv.appendChild(headerDiv);
chunkDiv.appendChild(bodyDiv);
sidebarContent.appendChild(chunkDiv);
});
}
async function exportToWord(userQuestion, assistantResponse, isReformulated = false, originalQuestion = '') {
try {
const response = await fetch('/chat/export-word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_question: userQuestion,
assistant_response: assistantResponse,
is_reformulated: isReformulated,
original_question: originalQuestion
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Export failed');
}
// Download file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_export_${new Date().getTime()}.docx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Export error:', error);
alert(`Erreur d'export: ${error.message}`);
}
}
async function exportToPdf(userQuestion, assistantResponse, isReformulated = false, originalQuestion = '') {
try {
const response = await fetch('/chat/export-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_question: userQuestion,
assistant_response: assistantResponse,
is_reformulated: isReformulated,
original_question: originalQuestion
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Export failed');
}
// Download file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_export_${new Date().getTime()}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Export error:', error);
alert(`Erreur d'export: ${error.message}`);
}
}
async function exportToAudio(assistantResponse, buttonElement) {
try {
// Save original button state
const originalHTML = buttonElement.innerHTML;
const originalDisabled = buttonElement.disabled;
// Update button to show loading state
buttonElement.disabled = true;
buttonElement.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="animation: spin 1s linear infinite;">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
Génération...
`;
// Start async audio generation
const startResponse = await fetch('/chat/generate-audio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
assistant_response: assistantResponse,
language: 'fr'
})
});
if (!startResponse.ok) {
const error = await startResponse.json();
throw new Error(error.error || 'Failed to start TTS job');
}
const { job_id } = await startResponse.json();
// Poll for job status
const checkStatus = async () => {
const statusResponse = await fetch(`/chat/audio-status/${job_id}`);
if (!statusResponse.ok) {
throw new Error('Failed to check TTS status');
}
const status = await statusResponse.json();
if (status.status === 'completed') {
// Download the audio file
const downloadUrl = `/chat/download-audio/${job_id}`;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = status.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Restore button state
buttonElement.innerHTML = originalHTML;
buttonElement.disabled = originalDisabled;
} else if (status.status === 'failed') {
throw new Error(status.error || 'TTS generation failed');
} else {
// Still processing, check again in 1 second
setTimeout(checkStatus, 1000);
}
};
// Start polling
await checkStatus();
} catch (error) {
console.error('TTS error:', error);
alert(`Erreur TTS: ${error.message}`);
// Restore button state on error
if (buttonElement) {
buttonElement.disabled = false;
buttonElement.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 5L6 9H2v6h4l5 4V5z"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
Audio
`;
}
}
}
// Initialize
sendBtn.disabled = true;
</script>
{% endblock %}