- Ajout de TTS>=0.22.0 aux dépendances - Création du module utils/tts_generator.py avec Coqui XTTS v2 * Support GPU avec mixed precision (FP16) * Lazy loading avec singleton pattern * Chunking automatique pour textes longs * Support multilingue (fr, en, es, de, etc.) - Ajout de la route /chat/export-audio dans flask_app.py - Ajout du bouton Audio dans chat.html (côté Word/PDF) - Génération audio WAV téléchargeable depuis les réponses Optimisé pour GPU 4070 (8GB VRAM) : utilise 4-6GB, génération rapide Qualité : voix naturelle française avec prosodie expressive 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1411 lines
44 KiB
HTML
1411 lines
44 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;
|
|
}
|
|
}
|
|
|
|
/* Remove header margin for full-width layout */
|
|
.site-header {
|
|
margin-left: 0 !important;
|
|
}
|
|
|
|
/* Chat-specific styles */
|
|
.chat-container {
|
|
display: grid;
|
|
grid-template-columns: 60% 40%;
|
|
gap: 1.5rem;
|
|
height: calc(100vh - 300px);
|
|
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;
|
|
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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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');
|
|
|
|
// State
|
|
let isGenerating = false;
|
|
let currentEventSource = null;
|
|
|
|
// 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, '"')}">
|
|
Utiliser l'originale
|
|
</button>
|
|
<button class="reformulation-btn btn-reformulated" data-question="${reformulated.replace(/"/g, '"')}">
|
|
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
|
|
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
|
|
})
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
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) {
|
|
try {
|
|
const response = await fetch('/chat/export-audio', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
assistant_response: assistantResponse,
|
|
language: 'fr'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'TTS 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_audio_${new Date().getTime()}.wav`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
} catch (error) {
|
|
console.error('TTS error:', error);
|
|
alert(`Erreur TTS: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
sendBtn.disabled = true;
|
|
</script>
|
|
{% endblock %}
|