Claude commited on
Commit
6e796e2
·
unverified ·
1 Parent(s): 6a4e68b

Add file upload for RAG: PDF, PPTX, TXT, ZIP support with drag & drop

Browse files

- Upload zone on setup screen with drag & drop and multi-file select
- ZIP extraction: auto-extracts supported files from nested folders
- Dynamic RAG indexing without server restart
- Document list with delete capability
- PPTX support via python-pptx
- Doc count badge in chat header

https://claude.ai/code/session_015z3yZxNNfXF63JuQDuPbEG

Files changed (6) hide show
  1. app/main.py +38 -2
  2. app/rag.py +173 -12
  3. requirements.txt +1 -0
  4. static/app.js +166 -1
  5. static/index.html +13 -0
  6. static/style.css +112 -0
app/main.py CHANGED
@@ -3,13 +3,14 @@
3
  import re
4
  from contextlib import asynccontextmanager
5
  from pathlib import Path
 
6
 
7
- from fastapi import FastAPI
8
  from fastapi.responses import FileResponse
9
  from fastapi.staticfiles import StaticFiles
10
  from pydantic import BaseModel
11
 
12
- from app.rag import load_corpus, retrieve
13
  from app.llm import build_system_prompt, chat, analyze_session
14
 
15
 
@@ -25,6 +26,8 @@ app = FastAPI(title="AIM Learning Companion", lifespan=lifespan)
25
  STATIC_DIR = Path(__file__).parent.parent / "static"
26
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
27
 
 
 
28
 
29
  class ChatRequest(BaseModel):
30
  message: str
@@ -84,6 +87,39 @@ async def api_chat(req: ChatRequest):
84
  return ChatResponse(reply=reply, phase=detected_phase)
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  @app.post("/api/analyze", response_model=AnalysisResponse)
88
  async def api_analyze(req: AnalysisRequest):
89
  analysis = await analyze_session(req.history)
 
3
  import re
4
  from contextlib import asynccontextmanager
5
  from pathlib import Path
6
+ from typing import List
7
 
8
+ from fastapi import FastAPI, UploadFile, File
9
  from fastapi.responses import FileResponse
10
  from fastapi.staticfiles import StaticFiles
11
  from pydantic import BaseModel
12
 
13
+ from app.rag import load_corpus, retrieve, add_documents, list_documents, delete_document
14
  from app.llm import build_system_prompt, chat, analyze_session
15
 
16
 
 
26
  STATIC_DIR = Path(__file__).parent.parent / "static"
27
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
28
 
29
+ ALLOWED_EXTENSIONS = {".txt", ".pdf", ".pptx", ".ppt", ".zip"}
30
+
31
 
32
  class ChatRequest(BaseModel):
33
  message: str
 
87
  return ChatResponse(reply=reply, phase=detected_phase)
88
 
89
 
90
+ @app.post("/api/upload")
91
+ async def api_upload(files: List[UploadFile] = File(...)):
92
+ """Upload one or more files (PDF, PPTX, TXT, ZIP) to the RAG corpus."""
93
+ file_data = []
94
+ skipped = []
95
+
96
+ for f in files:
97
+ ext = Path(f.filename).suffix.lower() if f.filename else ""
98
+ if ext not in ALLOWED_EXTENSIONS:
99
+ skipped.append({"filename": f.filename, "reason": f"Type non supporté: {ext}"})
100
+ continue
101
+ content = await f.read()
102
+ file_data.append((f.filename, content))
103
+
104
+ results = add_documents(file_data) if file_data else []
105
+ return {"results": results, "skipped": skipped}
106
+
107
+
108
+ @app.get("/api/documents")
109
+ async def api_documents():
110
+ """List all documents in the corpus."""
111
+ return {"documents": list_documents()}
112
+
113
+
114
+ @app.delete("/api/documents/{filename}")
115
+ async def api_delete_document(filename: str):
116
+ """Delete a document from the corpus."""
117
+ ok = delete_document(filename)
118
+ if ok:
119
+ return {"status": "ok"}
120
+ return {"status": "error", "message": "Fichier non trouvé"}
121
+
122
+
123
  @app.post("/api/analyze", response_model=AnalysisResponse)
124
  async def api_analyze(req: AnalysisRequest):
125
  analysis = await analyze_session(req.history)
app/rag.py CHANGED
@@ -1,6 +1,9 @@
1
  """RAG layer: load corpus, chunk, embed, and retrieve."""
2
 
3
  import os
 
 
 
4
 
5
  import chromadb
6
  from sentence_transformers import SentenceTransformer
@@ -13,6 +16,9 @@ TOP_K = 3
13
 
14
  _model: SentenceTransformer | None = None
15
  _collection: chromadb.Collection | None = None
 
 
 
16
 
17
 
18
  def _get_model() -> SentenceTransformer:
@@ -22,6 +28,17 @@ def _get_model() -> SentenceTransformer:
22
  return _model
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
25
  def _approximate_token_split(text: str, size: int, overlap: int) -> list[str]:
26
  """Split text into chunks of approximately `size` words with `overlap`."""
27
  words = text.split()
@@ -36,7 +53,7 @@ def _approximate_token_split(text: str, size: int, overlap: int) -> list[str]:
36
 
37
 
38
  def _read_txt(path: str) -> str:
39
- with open(path, "r", encoding="utf-8") as f:
40
  return f.read()
41
 
42
 
@@ -50,15 +67,65 @@ def _read_pdf(path: str) -> str:
50
  return ""
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def load_corpus() -> None:
54
- """Load all .pdf and .txt files from corpus, chunk, embed, store in ChromaDB."""
55
  global _collection
56
 
57
- client = chromadb.Client(chromadb.config.Settings(
58
- persist_directory=CHROMA_DIR,
59
- anonymized_telemetry=False,
60
- is_persistent=True,
61
- ))
62
 
63
  try:
64
  client.delete_collection("corpus")
@@ -76,17 +143,16 @@ def load_corpus() -> None:
76
  all_meta: list[dict] = []
77
 
78
  if not os.path.isdir(CORPUS_DIR):
 
79
  return
80
 
81
  for filename in sorted(os.listdir(CORPUS_DIR)):
82
  filepath = os.path.join(CORPUS_DIR, filename)
83
- if filename.lower().endswith(".txt"):
84
- text = _read_txt(filepath)
85
- elif filename.lower().endswith(".pdf"):
86
- text = _read_pdf(filepath)
87
- else:
88
  continue
89
 
 
90
  if not text.strip():
91
  continue
92
 
@@ -107,6 +173,101 @@ def load_corpus() -> None:
107
  )
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def retrieve(query: str, top_k: int = TOP_K) -> list[str]:
111
  """Retrieve the top_k most relevant chunks for a query."""
112
  if _collection is None or _collection.count() == 0:
 
1
  """RAG layer: load corpus, chunk, embed, and retrieve."""
2
 
3
  import os
4
+ import shutil
5
+ import tempfile
6
+ import zipfile
7
 
8
  import chromadb
9
  from sentence_transformers import SentenceTransformer
 
16
 
17
  _model: SentenceTransformer | None = None
18
  _collection: chromadb.Collection | None = None
19
+ _client: chromadb.ClientAPI | None = None
20
+
21
+ SUPPORTED_EXTENSIONS = {".txt", ".pdf", ".pptx", ".ppt"}
22
 
23
 
24
  def _get_model() -> SentenceTransformer:
 
28
  return _model
29
 
30
 
31
+ def _get_client() -> chromadb.ClientAPI:
32
+ global _client
33
+ if _client is None:
34
+ _client = chromadb.Client(chromadb.config.Settings(
35
+ persist_directory=CHROMA_DIR,
36
+ anonymized_telemetry=False,
37
+ is_persistent=True,
38
+ ))
39
+ return _client
40
+
41
+
42
  def _approximate_token_split(text: str, size: int, overlap: int) -> list[str]:
43
  """Split text into chunks of approximately `size` words with `overlap`."""
44
  words = text.split()
 
53
 
54
 
55
  def _read_txt(path: str) -> str:
56
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
57
  return f.read()
58
 
59
 
 
67
  return ""
68
 
69
 
70
+ def _read_pptx(path: str) -> str:
71
+ try:
72
+ from pptx import Presentation
73
+ prs = Presentation(path)
74
+ texts = []
75
+ for slide in prs.slides:
76
+ for shape in slide.shapes:
77
+ if shape.has_text_frame:
78
+ for para in shape.text_frame.paragraphs:
79
+ text = para.text.strip()
80
+ if text:
81
+ texts.append(text)
82
+ return "\n".join(texts)
83
+ except Exception:
84
+ return ""
85
+
86
+
87
+ def _read_file(path: str) -> str:
88
+ """Read a file based on its extension."""
89
+ lower = path.lower()
90
+ if lower.endswith(".txt"):
91
+ return _read_txt(path)
92
+ elif lower.endswith(".pdf"):
93
+ return _read_pdf(path)
94
+ elif lower.endswith((".pptx", ".ppt")):
95
+ return _read_pptx(path)
96
+ return ""
97
+
98
+
99
+ def _extract_zip(zip_bytes: bytes) -> list[tuple[str, bytes]]:
100
+ """Extract supported files from a ZIP archive. Returns list of (filename, content)."""
101
+ results = []
102
+ with tempfile.TemporaryDirectory() as tmpdir:
103
+ zip_path = os.path.join(tmpdir, "archive.zip")
104
+ with open(zip_path, "wb") as f:
105
+ f.write(zip_bytes)
106
+
107
+ with zipfile.ZipFile(zip_path, "r") as zf:
108
+ zf.extractall(tmpdir)
109
+
110
+ for root, dirs, files in os.walk(tmpdir):
111
+ # Skip __MACOSX and hidden directories
112
+ dirs[:] = [d for d in dirs if not d.startswith((".", "__"))]
113
+ for fname in files:
114
+ if fname.startswith("."):
115
+ continue
116
+ ext = os.path.splitext(fname)[1].lower()
117
+ if ext in SUPPORTED_EXTENSIONS:
118
+ fpath = os.path.join(root, fname)
119
+ with open(fpath, "rb") as f:
120
+ results.append((fname, f.read()))
121
+ return results
122
+
123
+
124
  def load_corpus() -> None:
125
+ """Load all supported files from corpus, chunk, embed, store in ChromaDB."""
126
  global _collection
127
 
128
+ client = _get_client()
 
 
 
 
129
 
130
  try:
131
  client.delete_collection("corpus")
 
143
  all_meta: list[dict] = []
144
 
145
  if not os.path.isdir(CORPUS_DIR):
146
+ os.makedirs(CORPUS_DIR, exist_ok=True)
147
  return
148
 
149
  for filename in sorted(os.listdir(CORPUS_DIR)):
150
  filepath = os.path.join(CORPUS_DIR, filename)
151
+ ext = os.path.splitext(filename)[1].lower()
152
+ if ext not in SUPPORTED_EXTENSIONS:
 
 
 
153
  continue
154
 
155
+ text = _read_file(filepath)
156
  if not text.strip():
157
  continue
158
 
 
173
  )
174
 
175
 
176
+ def _add_single_file(filename: str, file_bytes: bytes) -> dict:
177
+ """Process a single file: save to corpus and embed."""
178
+ global _collection
179
+
180
+ os.makedirs(CORPUS_DIR, exist_ok=True)
181
+ filepath = os.path.join(CORPUS_DIR, filename)
182
+
183
+ with open(filepath, "wb") as f:
184
+ f.write(file_bytes)
185
+
186
+ text = _read_file(filepath)
187
+ if not text.strip():
188
+ os.remove(filepath)
189
+ return {"filename": filename, "status": "error", "message": "Texte non extractible"}
190
+
191
+ chunks = _approximate_token_split(text, CHUNK_SIZE, CHUNK_OVERLAP)
192
+ model = _get_model()
193
+
194
+ if _collection is None:
195
+ load_corpus()
196
+ return {"filename": filename, "status": "ok", "chunks": len(chunks)}
197
+
198
+ # Remove old chunks from same file if re-uploading
199
+ try:
200
+ existing = _collection.get(where={"source": filename})
201
+ if existing["ids"]:
202
+ _collection.delete(ids=existing["ids"])
203
+ except Exception:
204
+ pass
205
+
206
+ chunk_ids = [f"{filename}_{i}" for i in range(len(chunks))]
207
+ metas = [{"source": filename, "chunk_index": i} for i in range(len(chunks))]
208
+ embeddings = model.encode(chunks).tolist()
209
+
210
+ _collection.add(
211
+ ids=chunk_ids,
212
+ embeddings=embeddings,
213
+ documents=chunks,
214
+ metadatas=metas,
215
+ )
216
+
217
+ return {"filename": filename, "status": "ok", "chunks": len(chunks)}
218
+
219
+
220
+ def add_documents(files: list[tuple[str, bytes]]) -> list[dict]:
221
+ """Add one or more uploaded files. Handles ZIP extraction automatically."""
222
+ results = []
223
+ for filename, file_bytes in files:
224
+ if filename.lower().endswith(".zip"):
225
+ extracted = _extract_zip(file_bytes)
226
+ if not extracted:
227
+ results.append({"filename": filename, "status": "error",
228
+ "message": "Aucun fichier supporté trouvé dans le ZIP"})
229
+ continue
230
+ for inner_name, inner_bytes in extracted:
231
+ results.append(_add_single_file(inner_name, inner_bytes))
232
+ else:
233
+ results.append(_add_single_file(filename, file_bytes))
234
+ return results
235
+
236
+
237
+ def list_documents() -> list[dict]:
238
+ """List all documents in the corpus directory."""
239
+ docs = []
240
+ if not os.path.isdir(CORPUS_DIR):
241
+ return docs
242
+ for filename in sorted(os.listdir(CORPUS_DIR)):
243
+ ext = os.path.splitext(filename)[1].lower()
244
+ if ext in SUPPORTED_EXTENSIONS:
245
+ filepath = os.path.join(CORPUS_DIR, filename)
246
+ size = os.path.getsize(filepath)
247
+ docs.append({"filename": filename, "size": size})
248
+ return docs
249
+
250
+
251
+ def delete_document(filename: str) -> bool:
252
+ """Delete a document from corpus and its embeddings."""
253
+ global _collection
254
+ filepath = os.path.join(CORPUS_DIR, filename)
255
+ if not os.path.isfile(filepath):
256
+ return False
257
+
258
+ os.remove(filepath)
259
+
260
+ if _collection is not None:
261
+ try:
262
+ existing = _collection.get(where={"source": filename})
263
+ if existing["ids"]:
264
+ _collection.delete(ids=existing["ids"])
265
+ except Exception:
266
+ pass
267
+
268
+ return True
269
+
270
+
271
  def retrieve(query: str, top_k: int = TOP_K) -> list[str]:
272
  """Retrieve the top_k most relevant chunks for a query."""
273
  if _collection is None or _collection.count() == 0:
requirements.txt CHANGED
@@ -6,4 +6,5 @@ sentence-transformers==3.3.1
6
  pydantic==2.10.4
7
  python-multipart==0.0.20
8
  pypdf2==3.0.1
 
9
  python-dotenv==1.0.1
 
6
  pydantic==2.10.4
7
  python-multipart==0.0.20
8
  pypdf2==3.0.1
9
+ python-pptx==1.0.2
10
  python-dotenv==1.0.1
static/app.js CHANGED
@@ -13,7 +13,8 @@
13
  phase: 0,
14
  history: [], // {role, content}
15
  timestamps: [], // epoch ms for every message (user & assistant alternating)
16
- analysisResult: null
 
17
  };
18
 
19
  var PHASE_NAMES = [
@@ -35,6 +36,7 @@
35
 
36
  var modeBadge = document.getElementById("mode-badge");
37
  var topicBadge = document.getElementById("topic-badge");
 
38
  var phaseDots = document.getElementById("phase-dots");
39
  var phaseLabels = document.getElementById("phase-labels");
40
  var messagesEl = document.getElementById("messages");
@@ -52,6 +54,12 @@
52
  var btnExport = document.getElementById("btn-export");
53
  var btnNewSession = document.getElementById("btn-new-session");
54
 
 
 
 
 
 
 
55
  /* ===== Screen navigation ===== */
56
  function showScreen(screen) {
57
  setupScreen.classList.remove("active");
@@ -99,6 +107,133 @@
99
  if (on) messagesEl.scrollTop = messagesEl.scrollHeight;
100
  }
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  /* ===== API calls ===== */
103
  function sendMessage(text) {
104
  state.history.push({ role: "user", content: text });
@@ -232,10 +367,13 @@
232
  state.history = [];
233
  state.timestamps = [];
234
  state.analysisResult = null;
 
235
 
236
  topicInput.value = "";
237
  chatInput.value = "";
238
  messagesEl.querySelectorAll(".message").forEach(function (el) { el.remove(); });
 
 
239
 
240
  modeBtns.forEach(function (btn) {
241
  btn.classList.toggle("selected", btn.dataset.mode === "TUTOR");
@@ -245,9 +383,25 @@
245
  btnEnd.disabled = false;
246
  btnSend.disabled = false;
247
 
 
 
 
248
  showScreen(setupScreen);
249
  }
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  /* ===== Event listeners ===== */
252
 
253
  // Mode selection
@@ -273,6 +427,14 @@
273
  modeBadge.textContent = state.mode === "TUTOR" ? "Tuteur" : "Critique";
274
  topicBadge.textContent = topic;
275
 
 
 
 
 
 
 
 
 
276
  renderPhaseIndicator();
277
  showScreen(chatScreen);
278
  chatInput.focus();
@@ -309,4 +471,7 @@
309
  // New session from analysis screen
310
  btnNewSession.addEventListener("click", resetSession);
311
 
 
 
 
312
  })();
 
13
  phase: 0,
14
  history: [], // {role, content}
15
  timestamps: [], // epoch ms for every message (user & assistant alternating)
16
+ analysisResult: null,
17
+ uploadedDocs: [] // filenames uploaded this session
18
  };
19
 
20
  var PHASE_NAMES = [
 
36
 
37
  var modeBadge = document.getElementById("mode-badge");
38
  var topicBadge = document.getElementById("topic-badge");
39
+ var docsBadge = document.getElementById("docs-badge");
40
  var phaseDots = document.getElementById("phase-dots");
41
  var phaseLabels = document.getElementById("phase-labels");
42
  var messagesEl = document.getElementById("messages");
 
54
  var btnExport = document.getElementById("btn-export");
55
  var btnNewSession = document.getElementById("btn-new-session");
56
 
57
+ // Upload refs
58
+ var uploadZone = document.getElementById("upload-zone");
59
+ var fileInput = document.getElementById("file-input");
60
+ var uploadList = document.getElementById("upload-list");
61
+ var uploadStatus = document.getElementById("upload-status");
62
+
63
  /* ===== Screen navigation ===== */
64
  function showScreen(screen) {
65
  setupScreen.classList.remove("active");
 
107
  if (on) messagesEl.scrollTop = messagesEl.scrollHeight;
108
  }
109
 
110
+ /* ===== File Upload ===== */
111
+
112
+ function formatFileSize(bytes) {
113
+ if (bytes < 1024) return bytes + " o";
114
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " Ko";
115
+ return (bytes / (1024 * 1024)).toFixed(1) + " Mo";
116
+ }
117
+
118
+ function renderUploadList() {
119
+ uploadList.innerHTML = "";
120
+ state.uploadedDocs.forEach(function (doc) {
121
+ var item = document.createElement("div");
122
+ item.className = "upload-item";
123
+
124
+ var icon = doc.filename.toLowerCase().endsWith(".pdf") ? "PDF" :
125
+ doc.filename.toLowerCase().endsWith(".pptx") ? "PPT" :
126
+ doc.filename.toLowerCase().endsWith(".ppt") ? "PPT" : "TXT";
127
+
128
+ item.innerHTML =
129
+ '<span class="upload-item-icon">' + icon + '</span>' +
130
+ '<span class="upload-item-name">' + doc.filename + '</span>' +
131
+ '<span class="upload-item-chunks">' + doc.chunks + ' chunks</span>' +
132
+ '<button class="upload-item-delete" data-filename="' + doc.filename + '">X</button>';
133
+ uploadList.appendChild(item);
134
+ });
135
+
136
+ // Bind delete buttons
137
+ uploadList.querySelectorAll(".upload-item-delete").forEach(function (btn) {
138
+ btn.addEventListener("click", function () {
139
+ deleteDoc(btn.dataset.filename);
140
+ });
141
+ });
142
+ }
143
+
144
+ function uploadFiles(fileList) {
145
+ if (!fileList || fileList.length === 0) return;
146
+
147
+ var formData = new FormData();
148
+ for (var i = 0; i < fileList.length; i++) {
149
+ formData.append("files", fileList[i]);
150
+ }
151
+
152
+ uploadStatus.textContent = "Upload en cours...";
153
+ uploadStatus.className = "upload-status uploading";
154
+ uploadZone.classList.add("uploading");
155
+
156
+ fetch("/api/upload", {
157
+ method: "POST",
158
+ body: formData
159
+ })
160
+ .then(function (res) { return res.json(); })
161
+ .then(function (data) {
162
+ uploadZone.classList.remove("uploading");
163
+ var ok = 0;
164
+ var errors = [];
165
+
166
+ (data.results || []).forEach(function (r) {
167
+ if (r.status === "ok") {
168
+ ok++;
169
+ state.uploadedDocs.push({ filename: r.filename, chunks: r.chunks });
170
+ } else {
171
+ errors.push(r.filename + ": " + (r.message || "erreur"));
172
+ }
173
+ });
174
+
175
+ (data.skipped || []).forEach(function (s) {
176
+ errors.push(s.filename + ": " + s.reason);
177
+ });
178
+
179
+ if (ok > 0 && errors.length === 0) {
180
+ uploadStatus.textContent = ok + " fichier(s) ajoute(s) au corpus";
181
+ uploadStatus.className = "upload-status success";
182
+ } else if (ok > 0 && errors.length > 0) {
183
+ uploadStatus.textContent = ok + " OK, " + errors.length + " erreur(s): " + errors.join("; ");
184
+ uploadStatus.className = "upload-status warning";
185
+ } else {
186
+ uploadStatus.textContent = "Erreur: " + errors.join("; ");
187
+ uploadStatus.className = "upload-status error";
188
+ }
189
+
190
+ renderUploadList();
191
+ })
192
+ .catch(function () {
193
+ uploadZone.classList.remove("uploading");
194
+ uploadStatus.textContent = "Erreur de connexion. Reessaye.";
195
+ uploadStatus.className = "upload-status error";
196
+ });
197
+ }
198
+
199
+ function deleteDoc(filename) {
200
+ fetch("/api/documents/" + encodeURIComponent(filename), { method: "DELETE" })
201
+ .then(function (res) { return res.json(); })
202
+ .then(function () {
203
+ state.uploadedDocs = state.uploadedDocs.filter(function (d) {
204
+ return d.filename !== filename;
205
+ });
206
+ renderUploadList();
207
+ uploadStatus.textContent = filename + " supprime";
208
+ uploadStatus.className = "upload-status success";
209
+ });
210
+ }
211
+
212
+ // Upload zone events
213
+ uploadZone.addEventListener("click", function () {
214
+ fileInput.click();
215
+ });
216
+
217
+ fileInput.addEventListener("change", function () {
218
+ uploadFiles(fileInput.files);
219
+ fileInput.value = "";
220
+ });
221
+
222
+ uploadZone.addEventListener("dragover", function (e) {
223
+ e.preventDefault();
224
+ uploadZone.classList.add("dragover");
225
+ });
226
+
227
+ uploadZone.addEventListener("dragleave", function () {
228
+ uploadZone.classList.remove("dragover");
229
+ });
230
+
231
+ uploadZone.addEventListener("drop", function (e) {
232
+ e.preventDefault();
233
+ uploadZone.classList.remove("dragover");
234
+ uploadFiles(e.dataTransfer.files);
235
+ });
236
+
237
  /* ===== API calls ===== */
238
  function sendMessage(text) {
239
  state.history.push({ role: "user", content: text });
 
367
  state.history = [];
368
  state.timestamps = [];
369
  state.analysisResult = null;
370
+ state.uploadedDocs = [];
371
 
372
  topicInput.value = "";
373
  chatInput.value = "";
374
  messagesEl.querySelectorAll(".message").forEach(function (el) { el.remove(); });
375
+ uploadList.innerHTML = "";
376
+ uploadStatus.textContent = "";
377
 
378
  modeBtns.forEach(function (btn) {
379
  btn.classList.toggle("selected", btn.dataset.mode === "TUTOR");
 
383
  btnEnd.disabled = false;
384
  btnSend.disabled = false;
385
 
386
+ // Load existing documents
387
+ loadDocumentList();
388
+
389
  showScreen(setupScreen);
390
  }
391
 
392
+ /* ===== Load existing documents on page load ===== */
393
+ function loadDocumentList() {
394
+ fetch("/api/documents")
395
+ .then(function (res) { return res.json(); })
396
+ .then(function (data) {
397
+ state.uploadedDocs = (data.documents || []).map(function (d) {
398
+ return { filename: d.filename, chunks: "?" };
399
+ });
400
+ renderUploadList();
401
+ })
402
+ .catch(function () {});
403
+ }
404
+
405
  /* ===== Event listeners ===== */
406
 
407
  // Mode selection
 
427
  modeBadge.textContent = state.mode === "TUTOR" ? "Tuteur" : "Critique";
428
  topicBadge.textContent = topic;
429
 
430
+ // Show doc count badge
431
+ if (state.uploadedDocs.length > 0) {
432
+ docsBadge.textContent = state.uploadedDocs.length + " doc(s)";
433
+ docsBadge.style.display = "inline-block";
434
+ } else {
435
+ docsBadge.style.display = "none";
436
+ }
437
+
438
  renderPhaseIndicator();
439
  showScreen(chatScreen);
440
  chatInput.focus();
 
471
  // New session from analysis screen
472
  btnNewSession.addEventListener("click", resetSession);
473
 
474
+ // Load existing docs on startup
475
+ loadDocumentList();
476
+
477
  })();
static/index.html CHANGED
@@ -18,6 +18,18 @@
18
  <input type="text" id="topic-input" placeholder="Ex : L'intelligence artificielle en formation professionnelle">
19
  </div>
20
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  <div class="form-group">
22
  <label>Mode</label>
23
  <div class="mode-selector">
@@ -41,6 +53,7 @@
41
  <div class="chat-header-left">
42
  <span id="mode-badge" class="badge badge-mode"></span>
43
  <span id="topic-badge" class="badge badge-topic"></span>
 
44
  </div>
45
  <div class="chat-header-right">
46
  <button id="btn-end-session" class="btn-end">Terminer la session</button>
 
18
  <input type="text" id="topic-input" placeholder="Ex : L'intelligence artificielle en formation professionnelle">
19
  </div>
20
 
21
+ <div class="form-group">
22
+ <label>Documents de reference (optionnel)</label>
23
+ <div class="upload-zone" id="upload-zone">
24
+ <div class="upload-icon">+</div>
25
+ <div class="upload-text">Glisse tes fichiers ici ou clique pour selectionner</div>
26
+ <div class="upload-hint">PDF, PPTX, TXT ou ZIP — plusieurs fichiers possibles</div>
27
+ <input type="file" id="file-input" multiple accept=".pdf,.pptx,.ppt,.txt,.zip" hidden>
28
+ </div>
29
+ <div class="upload-list" id="upload-list"></div>
30
+ <div class="upload-status" id="upload-status"></div>
31
+ </div>
32
+
33
  <div class="form-group">
34
  <label>Mode</label>
35
  <div class="mode-selector">
 
53
  <div class="chat-header-left">
54
  <span id="mode-badge" class="badge badge-mode"></span>
55
  <span id="topic-badge" class="badge badge-topic"></span>
56
+ <span id="docs-badge" class="badge badge-docs" style="display:none"></span>
57
  </div>
58
  <div class="chat-header-right">
59
  <button id="btn-end-session" class="btn-end">Terminer la session</button>
static/style.css CHANGED
@@ -127,6 +127,118 @@ body {
127
  .btn-primary:hover { opacity: 0.9; }
128
  .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  /* ===== Chat Screen ===== */
131
  #chat-screen {
132
  height: 100vh;
 
127
  .btn-primary:hover { opacity: 0.9; }
128
  .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
129
 
130
+ /* ===== Upload Zone ===== */
131
+ .upload-zone {
132
+ width: 100%;
133
+ border: 2px dashed var(--border);
134
+ border-radius: var(--radius);
135
+ padding: 28px 20px;
136
+ text-align: center;
137
+ cursor: pointer;
138
+ transition: all 0.2s;
139
+ background: var(--bg-secondary);
140
+ }
141
+
142
+ .upload-zone:hover, .upload-zone.dragover {
143
+ border-color: var(--accent);
144
+ background: var(--bg-tertiary);
145
+ }
146
+
147
+ .upload-zone.uploading {
148
+ opacity: 0.6;
149
+ pointer-events: none;
150
+ }
151
+
152
+ .upload-icon {
153
+ font-size: 2rem;
154
+ color: var(--accent);
155
+ margin-bottom: 8px;
156
+ font-weight: 300;
157
+ }
158
+
159
+ .upload-text {
160
+ font-size: 0.92rem;
161
+ color: var(--text-primary);
162
+ margin-bottom: 4px;
163
+ }
164
+
165
+ .upload-hint {
166
+ font-size: 0.78rem;
167
+ color: var(--text-secondary);
168
+ }
169
+
170
+ .upload-list {
171
+ width: 100%;
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 6px;
175
+ }
176
+
177
+ .upload-item {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 10px;
181
+ padding: 10px 14px;
182
+ background: var(--bg-secondary);
183
+ border: 1px solid var(--border);
184
+ border-radius: var(--radius-sm);
185
+ font-size: 0.85rem;
186
+ }
187
+
188
+ .upload-item-icon {
189
+ background: var(--accent);
190
+ color: #fff;
191
+ padding: 2px 8px;
192
+ border-radius: 4px;
193
+ font-size: 0.7rem;
194
+ font-weight: 700;
195
+ flex-shrink: 0;
196
+ }
197
+
198
+ .upload-item-name {
199
+ flex: 1;
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ white-space: nowrap;
203
+ }
204
+
205
+ .upload-item-chunks {
206
+ color: var(--text-secondary);
207
+ font-size: 0.78rem;
208
+ flex-shrink: 0;
209
+ }
210
+
211
+ .upload-item-delete {
212
+ background: transparent;
213
+ border: 1px solid var(--border);
214
+ color: var(--text-secondary);
215
+ border-radius: 4px;
216
+ cursor: pointer;
217
+ padding: 2px 8px;
218
+ font-size: 0.75rem;
219
+ transition: all 0.2s;
220
+ }
221
+
222
+ .upload-item-delete:hover {
223
+ border-color: var(--danger);
224
+ color: var(--danger);
225
+ }
226
+
227
+ .upload-status {
228
+ font-size: 0.82rem;
229
+ min-height: 1.2em;
230
+ }
231
+
232
+ .upload-status.success { color: var(--success); }
233
+ .upload-status.warning { color: var(--warning); }
234
+ .upload-status.error { color: var(--danger); }
235
+ .upload-status.uploading { color: var(--accent-light); }
236
+
237
+ .badge-docs {
238
+ background: var(--success);
239
+ color: #fff;
240
+ }
241
+
242
  /* ===== Chat Screen ===== */
243
  #chat-screen {
244
  height: 100vh;