riteshraut commited on
Commit
457467f
·
1 Parent(s): 2bfc3e4

fix/Update in the ui

Browse files
Files changed (3) hide show
  1. app.py +308 -170
  2. rag_processor.py +1 -1
  3. templates/index.html +816 -495
app.py CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import os
2
  import uuid
3
- from flask import Flask, request, render_template, session, jsonify, Response
 
4
  from werkzeug.utils import secure_filename
5
  from rag_processor import create_rag_chain
6
  from typing import Sequence, Any, List
@@ -9,12 +12,15 @@ import re
9
  import io
10
  from gtts import gTTS
11
  from langchain_core.documents import Document
12
- from langchain_community.document_loaders import TextLoader, Docx2txtLoader
 
13
  from langchain.text_splitter import RecursiveCharacterTextSplitter
14
  from langchain_huggingface import HuggingFaceEmbeddings
15
  from langchain_community.vectorstores import FAISS
16
- from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
17
- from langchain.retrievers.document_compressors.base import BaseDocumentCompressor
 
 
18
  from langchain_community.retrievers import BM25Retriever
19
  from langchain_community.chat_message_histories import ChatMessageHistory
20
  from langchain.storage import InMemoryStore
@@ -23,15 +29,15 @@ from sentence_transformers.cross_encoder import CrossEncoder
23
  app = Flask(__name__)
24
  app.config['SECRET_KEY'] = os.urandom(24)
25
 
26
- # --- FIX: Use STRING keys for the dictionary ---
27
  # Maps temperature strings (from the form) to the mode labels
28
  TEMPERATURE_LABELS = {
29
- "0.2": "Precise",
30
- "0.4": "Confident",
31
- "0.6": "Balanced",
32
- "0.8": "Flexible",
33
- "1.0": "Creative"
34
- }
 
35
 
36
  class LocalReranker(BaseDocumentCompressor):
37
  model: Any
@@ -40,153 +46,196 @@ class LocalReranker(BaseDocumentCompressor):
40
  class Config:
41
  arbitrary_types_allowed = True
42
 
43
- def compress_documents(
44
- self, documents: Sequence[Document], query: str, callbacks=None
45
- ) -> Sequence[Document]:
46
  if not documents:
47
  return []
48
  pairs = [[query, doc.page_content] for doc in documents]
49
  scores = self.model.predict(pairs, show_progress_bar=False)
50
  doc_scores = list(zip(documents, scores))
51
- sorted_doc_scores = sorted(doc_scores, key=lambda x: x[1], reverse=True)
 
52
  top_docs = []
53
- for doc, score in sorted_doc_scores[: self.top_n]:
54
- doc.metadata["rerank_score"] = float(score)
55
  top_docs.append(doc)
56
  return top_docs
57
 
 
58
  def create_optimized_parent_child_chunks(all_docs):
59
  if not all_docs:
60
- print("❌ CHUNKING: No input documents provided!")
61
- return [], [], []
62
-
63
- parent_splitter = RecursiveCharacterTextSplitter(
64
- chunk_size=900, chunk_overlap=200, separators=["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " ", ""]
65
- )
66
- child_splitter = RecursiveCharacterTextSplitter(
67
- chunk_size=350, chunk_overlap=80, separators=["\n", ". ", "! ", "? ", "; ", ", ", " ", ""]
68
- )
69
  parent_docs = parent_splitter.split_documents(all_docs)
70
  doc_ids = [str(uuid.uuid4()) for _ in parent_docs]
71
  child_docs = []
72
 
73
- for i, parent_doc in enumerate(parent_docs):
74
  parent_id = doc_ids[i]
75
  children = child_splitter.split_documents([parent_doc])
76
- for j, child in enumerate(children):
77
- child.metadata.update({
78
- "doc_id": parent_id, "chunk_index": j, "total_chunks": len(children),
79
- "is_first_chunk": j == 0, "is_last_chunk": j == len(children) - 1,
80
- })
 
 
81
  if len(children) > 1:
82
- if j == 0: child.page_content = "[Beginning] " + child.page_content
83
- elif j == len(children) - 1: child.page_content = "[Continues...] " + child.page_content
 
 
 
 
84
  child_docs.append(child)
85
-
86
- print(f"✅ CHUNKING: Created {len(parent_docs)} parent and {len(child_docs)} child chunks.")
87
- return parent_docs, child_docs, doc_ids
88
 
89
- def get_context_aware_parents(docs: List[Document], store: InMemoryStore) -> List[Document]:
90
- if not docs: return []
91
- parent_scores, child_content_by_parent = {}, {}
 
 
 
 
 
 
 
92
  for doc in docs:
93
- parent_id = doc.metadata.get("doc_id")
94
  if parent_id:
95
- parent_scores[parent_id] = parent_scores.get(parent_id, 0) + 1
96
- if parent_id not in child_content_by_parent: child_content_by_parent[parent_id] = []
 
 
97
  child_content_by_parent[parent_id].append(doc.page_content)
98
-
99
  parent_ids = list(parent_scores.keys())
100
  parents = store.mget(parent_ids)
101
  enhanced_parents = []
102
-
103
- for i, parent in enumerate(parents):
104
  if parent is not None:
105
  parent_id = parent_ids[i]
106
  if parent_id in child_content_by_parent:
107
- child_excerpts = "\n".join(child_content_by_parent[parent_id][:3])
108
- enhanced_content = f"{parent.page_content}\n\nRelevant excerpts:\n{child_excerpts}"
109
- enhanced_parent = Document(
110
- page_content=enhanced_content,
111
- metadata={**parent.metadata, "child_relevance_score": parent_scores[parent_id], "matching_children": len(child_content_by_parent[parent_id])}
112
- )
 
 
113
  enhanced_parents.append(enhanced_parent)
114
  else:
115
- print(f"❌ PARENT_FETCH: Parent {parent_ids[i]} not found in store!")
 
116
 
117
- enhanced_parents.sort(key=lambda p: p.metadata.get("child_relevance_score", 0), reverse=True)
 
118
  return enhanced_parents
119
 
120
- is_hf_spaces = bool(os.getenv("SPACE_ID") or os.getenv("SPACES_ZERO_GPU"))
 
 
121
  app.config['UPLOAD_FOLDER'] = '/tmp/uploads' if is_hf_spaces else 'uploads'
122
 
123
  try:
124
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
125
- print(f"📁 Upload folder ready: {app.config['UPLOAD_FOLDER']}")
126
  except Exception as e:
127
- print(f"❌ Failed to create upload folder, falling back to /tmp: {e}")
 
128
  app.config['UPLOAD_FOLDER'] = '/tmp/uploads'
129
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
130
 
131
  session_data = {}
132
  message_histories = {}
133
 
134
- print("🔄 Loading embedding model...")
135
  try:
136
- EMBEDDING_MODEL = HuggingFaceEmbeddings(
137
- model_name="sentence-transformers/all-MiniLM-L6-v2",
138
- model_kwargs={'device': 'cpu'},
139
- encode_kwargs={'normalize_embeddings': True}
140
- )
141
- print("✅ Embedding model loaded.")
142
- except Exception as e: print(f"❌ FATAL: Could not load embedding model. Error: {e}"); raise e
143
-
144
- print("🔄 Loading reranker model...")
 
145
  try:
146
- RERANKER_MODEL = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device='cpu')
147
- print("✅ Reranker model loaded.")
148
- except Exception as e: print(f"❌ FATAL: Could not load reranker model. Error: {e}"); raise e
 
 
 
 
 
149
 
150
  def load_pdf_with_fallback(filepath):
151
  try:
152
  docs = []
153
  with fitz.open(filepath) as pdf_doc:
154
- for page_num, page in enumerate(pdf_doc):
155
  text = page.get_text()
156
- if text.strip(): docs.append(Document(page_content=text, metadata={"source": os.path.basename(filepath), "page": page_num + 1}))
 
 
 
157
  if docs:
158
- print(f"✅ Loaded PDF: {os.path.basename(filepath)} - {len(docs)} pages")
 
159
  return docs
160
- else: raise ValueError("No text content found in PDF.")
161
- except Exception as e: print(f"❌ PyMuPDF failed for {filepath}: {e}"); raise
 
 
 
 
 
 
 
162
 
163
- LOADER_MAPPING = {".txt": TextLoader, ".pdf": load_pdf_with_fallback, ".docx": Docx2txtLoader}
164
 
165
  def get_session_history(session_id: str) -> ChatMessageHistory:
166
- if session_id not in message_histories: message_histories[session_id] = ChatMessageHistory()
 
167
  return message_histories[session_id]
168
 
 
169
  @app.route('/health', methods=['GET'])
170
- def health_check(): return jsonify({'status': 'healthy'}), 200
 
 
171
 
172
  @app.route('/', methods=['GET'])
173
- def index(): return render_template('index.html')
 
 
174
 
175
  @app.route('/upload', methods=['POST'])
176
  def upload_files():
177
  files = request.files.getlist('file')
178
-
179
- # Get temperature as a string for the dictionary key
180
  temperature_str = request.form.get('temperature', '0.2')
181
- temperature = float(temperature_str) # Convert to float for the LLM
182
- model_name = request.form.get('model_name', 'moonshotai/kimi-k2-instruct')
183
- print(f"⚙️ UPLOAD: Model: {model_name}, Temp: {temperature}")
 
184
 
185
  if not files or all(f.filename == '' for f in files):
186
- return jsonify({'status': 'error', 'message': 'No selected files.'}), 400
 
187
 
188
- all_docs, processed_files, failed_files = [], [], []
189
- print(f"📁 Processing {len(files)} file(s)...")
190
  for file in files:
191
  if file and file.filename:
192
  filename = secure_filename(file.filename)
@@ -194,128 +243,217 @@ def upload_files():
194
  try:
195
  file.save(filepath)
196
  file_ext = os.path.splitext(filename)[1].lower()
197
- if file_ext not in LOADER_MAPPING: raise ValueError("Unsupported file format.")
 
198
  loader_func = LOADER_MAPPING[file_ext]
199
- docs = loader_func(filepath) if file_ext == ".pdf" else loader_func(filepath).load()
200
- if not docs: raise ValueError("No content extracted.")
 
 
201
  all_docs.extend(docs)
202
  processed_files.append(filename)
203
  except Exception as e:
204
- print(f"✗ Error processing {filename}: {e}")
205
  failed_files.append(f"{filename} ({e})")
206
 
207
  if not all_docs:
208
- return jsonify({'status': 'error', 'message': f"Failed to process all files. Reasons: {', '.join(failed_files)}"}), 400
 
 
209
 
210
- print(f"✅ UPLOAD: Processed {len(processed_files)} files.")
211
  try:
212
- print("🔄 Starting RAG pipeline setup...")
213
- parent_docs, child_docs, doc_ids = create_optimized_parent_child_chunks(all_docs)
214
- if not child_docs: raise ValueError("No child documents created during chunking.")
215
-
216
- vectorstore = FAISS.from_documents(child_docs, EMBEDDING_MODEL)
217
- store = InMemoryStore(); store.mset(list(zip(doc_ids, parent_docs)))
218
- print(f"✅ Indexed {len(child_docs)} document chunks.")
219
 
220
- bm25_retriever = BM25Retriever.from_documents(child_docs); bm25_retriever.k = 12
221
- faiss_retriever = vectorstore.as_retriever(search_kwargs={"k": 12})
222
- ensemble_retriever = EnsembleRetriever(retrievers=[bm25_retriever, faiss_retriever], weights=[0.6, 0.4])
 
 
 
 
 
 
 
 
223
  reranker = LocalReranker(model=RERANKER_MODEL, top_n=5)
224
- def get_parents(docs: List[Document]) -> List[Document]: return get_context_aware_parents(docs, store)
225
- compression_retriever = ContextualCompressionRetriever(base_compressor=reranker, base_retriever=ensemble_retriever)
 
 
 
 
 
226
  final_retriever = compression_retriever | get_parents
227
 
228
  session_id = str(uuid.uuid4())
229
- rag_chain, api_key_manager = create_rag_chain(
230
- retriever=final_retriever, get_session_history_func=get_session_history,
231
- model_name=model_name, temperature=temperature
232
- )
233
- # Store the float temperature
234
- session_data[session_id] = {'chain': rag_chain, 'model_name': model_name, 'temperature': temperature, 'api_key_manager': api_key_manager}
235
-
 
 
 
 
236
  success_msg = f"Processed: {', '.join(processed_files)}"
237
- if failed_files: success_msg += f". Failed: {', '.join(failed_files)}"
238
-
239
- # Get the mode label using the STRING key
240
- mode_label = TEMPERATURE_LABELS.get(temperature_str, temperature_str)
241
-
242
- print(f"✅ UPLOAD COMPLETE: Session {session_id} is ready.")
243
- # Return all info needed by the frontend
 
244
  return jsonify({
245
- 'status': 'success',
246
- 'filename': success_msg,
247
  'session_id': session_id,
248
  'model_name': model_name,
249
- 'mode': mode_label
250
- })
251
  except Exception as e:
252
- import traceback; traceback.print_exc()
253
- return jsonify({'status': 'error', 'message': f'RAG setup failed: {e}'}), 500
 
 
254
 
255
- @app.route('/chat', methods=['POST'])
 
256
  def chat():
257
- data = request.get_json()
258
- question, session_id = data.get('question'), data.get('session_id') or session.get('session_id')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- if not question: return jsonify({'status': 'error', 'message': 'No question provided.'}), 400
261
  if not session_id or session_id not in session_data:
262
- print(f"❌ CHAT: Invalid session {session_id}.")
263
- return jsonify({'status': 'error', 'message': 'Invalid session. Please upload documents first.'}), 400
264
-
 
 
 
 
 
 
265
  try:
266
  session_info = session_data[session_id]
267
  rag_chain = session_info['chain']
268
-
269
- # --- START: BUGFIX & FEATURE UPDATE ---
270
-
271
- # 1. Get model name from session
272
  model_name = session_info['model_name']
273
-
274
- # 2. Get temperature (float) and convert to string for lookup
275
  temperature_float = session_info['temperature']
276
  temperature_str = str(temperature_float)
277
-
278
- # 3. Get the correct mode label
279
  mode_label = TEMPERATURE_LABELS.get(temperature_str, temperature_str)
280
-
281
- # --- END: BUGFIX & FEATURE UPDATE ---
282
-
283
- print(f"💬 CHAT: Invoking chain for session {session_id}...")
284
- answer = rag_chain.invoke({"question": question}, config={"configurable": {"session_id": session_id}})
285
- print(f"✅ CHAT: Answer generated.")
286
-
287
- # Return all info needed by the frontend
288
- return jsonify({
289
- 'answer': answer,
290
- 'model_name': model_name,
291
- 'mode': mode_label
292
- })
293
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  except Exception as e:
295
- import traceback; traceback.print_exc()
296
- return jsonify({'status': 'error', 'message': f'Error during chat: {e}'}), 500
 
 
 
 
 
 
 
 
 
297
 
298
  def clean_markdown_for_tts(text: str) -> str:
299
- text = re.sub(r'\*(\*?)(.*?)\1\*', r'\2', text); text = re.sub(r'\_(.*?)\_', r'\1', text)
300
- text = re.sub(r'`(.*?)`', r'\1', text); text = re.sub(r'^\s*#{1,6}\s+', '', text, flags=re.MULTILINE)
301
- text = re.sub(r'^\s*[\*\-]\s+', '', text, flags=re.MULTILINE); text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
302
- text = re.sub(r'^\s*>\s?', '', text, flags=re.MULTILINE); text = re.sub(r'^\s*[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
303
- text = re.sub(r'\n+', ' ', text)
 
 
 
304
  return text.strip()
305
 
 
306
  @app.route('/tts', methods=['POST'])
307
  def text_to_speech():
308
- data = request.get_json(); text = data.get('text')
309
- if not text: return jsonify({'status': 'error', 'message': 'No text provided.'}), 400
 
 
 
310
  try:
311
- clean_text = clean_markdown_for_tts(text); tts = gTTS(clean_text, lang='en')
312
- mp3_fp = io.BytesIO(); tts.write_to_fp(mp3_fp); mp3_fp.seek(0)
 
 
 
 
 
 
313
  return Response(mp3_fp, mimetype='audio/mpeg')
314
  except Exception as e:
315
- print(f"❌ TTS Error: {e}")
316
- return jsonify({'status': 'error', 'message': 'Failed to generate audio.'}), 500
 
 
317
 
318
  if __name__ == '__main__':
319
- port = int(os.environ.get("PORT", 7860))
320
- print(f"🚀 Starting Flask app on port {port}")
321
- app.run(host="0.0.0.0", port=port, debug=False, threaded=False)
 
 
1
+ #!/usr/bin/python
2
+ # -*- coding: utf-8 -*-
3
  import os
4
  import uuid
5
+ from flask import Flask, request, render_template, session, jsonify, \
6
+ Response, stream_with_context # Added stream_with_context
7
  from werkzeug.utils import secure_filename
8
  from rag_processor import create_rag_chain
9
  from typing import Sequence, Any, List
 
12
  import io
13
  from gtts import gTTS
14
  from langchain_core.documents import Document
15
+ from langchain_community.document_loaders import TextLoader, \
16
+ Docx2txtLoader
17
  from langchain.text_splitter import RecursiveCharacterTextSplitter
18
  from langchain_huggingface import HuggingFaceEmbeddings
19
  from langchain_community.vectorstores import FAISS
20
+ from langchain.retrievers import EnsembleRetriever, \
21
+ ContextualCompressionRetriever
22
+ from langchain.retrievers.document_compressors.base import \
23
+ BaseDocumentCompressor
24
  from langchain_community.retrievers import BM25Retriever
25
  from langchain_community.chat_message_histories import ChatMessageHistory
26
  from langchain.storage import InMemoryStore
 
29
  app = Flask(__name__)
30
  app.config['SECRET_KEY'] = os.urandom(24)
31
 
 
32
  # Maps temperature strings (from the form) to the mode labels
33
  TEMPERATURE_LABELS = {
34
+ '0.2': 'Precise',
35
+ '0.4': 'Confident',
36
+ '0.6': 'Balanced',
37
+ '0.8': 'Flexible',
38
+ '1.0': 'Creative',
39
+ }
40
+
41
 
42
  class LocalReranker(BaseDocumentCompressor):
43
  model: Any
 
46
  class Config:
47
  arbitrary_types_allowed = True
48
 
49
+ def compress_documents(self, documents: Sequence[Document], query: str,
50
+ callbacks=None) -> Sequence[Document]:
 
51
  if not documents:
52
  return []
53
  pairs = [[query, doc.page_content] for doc in documents]
54
  scores = self.model.predict(pairs, show_progress_bar=False)
55
  doc_scores = list(zip(documents, scores))
56
+ sorted_doc_scores = sorted(doc_scores, key=lambda x: x[1],
57
+ reverse=True)
58
  top_docs = []
59
+ for (doc, score) in sorted_doc_scores[:self.top_n]:
60
+ doc.metadata['rerank_score'] = float(score)
61
  top_docs.append(doc)
62
  return top_docs
63
 
64
+
65
  def create_optimized_parent_child_chunks(all_docs):
66
  if not all_docs:
67
+ print ('❌ CHUNKING: No input documents provided!')
68
+ return ([], [], [])
69
+
70
+ parent_splitter = RecursiveCharacterTextSplitter(chunk_size=900,
71
+ chunk_overlap=200, separators=['\n\n', '\n', '. ', '! ',
72
+ '? ', '; ', ', ', ' ', ''])
73
+ child_splitter = RecursiveCharacterTextSplitter(chunk_size=350,
74
+ chunk_overlap=80, separators=['\n', '. ', '! ', '? ', '; ',
75
+ ', ', ' ', ''])
76
  parent_docs = parent_splitter.split_documents(all_docs)
77
  doc_ids = [str(uuid.uuid4()) for _ in parent_docs]
78
  child_docs = []
79
 
80
+ for (i, parent_doc) in enumerate(parent_docs):
81
  parent_id = doc_ids[i]
82
  children = child_splitter.split_documents([parent_doc])
83
+ for (j, child) in enumerate(children):
84
+ child.metadata.update({'doc_id': parent_id,
85
+ 'chunk_index': j,
86
+ 'total_chunks': len(children),
87
+ 'is_first_chunk': j == 0,
88
+ 'is_last_chunk': j == len(children)
89
+ - 1})
90
  if len(children) > 1:
91
+ if j == 0:
92
+ child.page_content = '[Beginning] ' \
93
+ + child.page_content
94
+ elif j == len(children) - 1:
95
+ child.page_content = '[Continues...] ' \
96
+ + child.page_content
97
  child_docs.append(child)
 
 
 
98
 
99
+ print (f"✅ CHUNKING: Created {len(parent_docs)} parent and {len(child_docs)} child chunks."
100
+ )
101
+ return (parent_docs, child_docs, doc_ids)
102
+
103
+
104
+ def get_context_aware_parents(docs: List[Document],
105
+ store: InMemoryStore) -> List[Document]:
106
+ if not docs:
107
+ return []
108
+ (parent_scores, child_content_by_parent) = ({}, {})
109
  for doc in docs:
110
+ parent_id = doc.metadata.get('doc_id')
111
  if parent_id:
112
+ parent_scores[parent_id] = parent_scores.get(parent_id, 0) \
113
+ + 1
114
+ if parent_id not in child_content_by_parent:
115
+ child_content_by_parent[parent_id] = []
116
  child_content_by_parent[parent_id].append(doc.page_content)
117
+
118
  parent_ids = list(parent_scores.keys())
119
  parents = store.mget(parent_ids)
120
  enhanced_parents = []
121
+
122
+ for (i, parent) in enumerate(parents):
123
  if parent is not None:
124
  parent_id = parent_ids[i]
125
  if parent_id in child_content_by_parent:
126
+ child_excerpts = '\n'.join(child_content_by_parent[parent_id][:3])
127
+ enhanced_content = \
128
+ f"{parent.page_content}\n\nRelevant excerpts:\n{child_excerpts}"
129
+ enhanced_parent = \
130
+ Document(page_content=enhanced_content,
131
+ metadata={**parent.metadata,
132
+ 'child_relevance_score': parent_scores[parent_id],
133
+ 'matching_children': len(child_content_by_parent[parent_id])})
134
  enhanced_parents.append(enhanced_parent)
135
  else:
136
+ print (f"❌ PARENT_FETCH: Parent {parent_ids[i]} not found in store!"
137
+ )
138
 
139
+ enhanced_parents.sort(key=lambda p: p.metadata.get('child_relevance_score',
140
+ 0), reverse=True)
141
  return enhanced_parents
142
 
143
+
144
+ is_hf_spaces = bool(os.getenv('SPACE_ID') or os.getenv('SPACES_ZERO_GPU'
145
+ ))
146
  app.config['UPLOAD_FOLDER'] = '/tmp/uploads' if is_hf_spaces else 'uploads'
147
 
148
  try:
149
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
150
+ print (f"📁 Upload folder ready: {app.config['UPLOAD_FOLDER']}")
151
  except Exception as e:
152
+ print (f"❌ Failed to create upload folder, falling back to /tmp: {e}"
153
+ )
154
  app.config['UPLOAD_FOLDER'] = '/tmp/uploads'
155
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
156
 
157
  session_data = {}
158
  message_histories = {}
159
 
160
+ print ('🔄 Loading embedding model...')
161
  try:
162
+ EMBEDDING_MODEL = \
163
+ HuggingFaceEmbeddings(model_name='sentence-transformers/all-MiniLM-L6-v2'
164
+ , model_kwargs={'device': 'cpu'},
165
+ encode_kwargs={'normalize_embeddings': True})
166
+ print ('✅ Embedding model loaded.')
167
+ except Exception as e:
168
+ print (f"❌ FATAL: Could not load embedding model. Error: {e}")
169
+ raise e
170
+
171
+ print ('🔄 Loading reranker model...')
172
  try:
173
+ RERANKER_MODEL = \
174
+ CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2',
175
+ device='cpu')
176
+ print ('✅ Reranker model loaded.')
177
+ except Exception as e:
178
+ print (f"❌ FATAL: Could not load reranker model. Error: {e}")
179
+ raise e
180
+
181
 
182
  def load_pdf_with_fallback(filepath):
183
  try:
184
  docs = []
185
  with fitz.open(filepath) as pdf_doc:
186
+ for (page_num, page) in enumerate(pdf_doc):
187
  text = page.get_text()
188
+ if text.strip():
189
+ docs.append(Document(page_content=text,
190
+ metadata={'source': os.path.basename(filepath),
191
+ 'page': page_num + 1}))
192
  if docs:
193
+ print (f"✅ Loaded PDF: {os.path.basename(filepath)} - {len(docs)} pages"
194
+ )
195
  return docs
196
+ else:
197
+ raise ValueError('No text content found in PDF.')
198
+ except Exception as e:
199
+ print (f"❌ PyMuPDF failed for {filepath}: {e}")
200
+ raise
201
+
202
+
203
+ LOADER_MAPPING = {'.txt': TextLoader, '.pdf': load_pdf_with_fallback,
204
+ '.docx': Docx2txtLoader}
205
 
 
206
 
207
  def get_session_history(session_id: str) -> ChatMessageHistory:
208
+ if session_id not in message_histories:
209
+ message_histories[session_id] = ChatMessageHistory()
210
  return message_histories[session_id]
211
 
212
+
213
  @app.route('/health', methods=['GET'])
214
+ def health_check():
215
+ return (jsonify({'status': 'healthy'}), 200)
216
+
217
 
218
  @app.route('/', methods=['GET'])
219
+ def index():
220
+ return render_template('index.html')
221
+
222
 
223
  @app.route('/upload', methods=['POST'])
224
  def upload_files():
225
  files = request.files.getlist('file')
226
+
 
227
  temperature_str = request.form.get('temperature', '0.2')
228
+ temperature = float(temperature_str)
229
+ model_name = request.form.get('model_name',
230
+ 'moonshotai/kimi-k2-instruct')
231
+ print (f"⚙️ UPLOAD: Model: {model_name}, Temp: {temperature}")
232
 
233
  if not files or all(f.filename == '' for f in files):
234
+ return (jsonify({'status': 'error',
235
+ 'message': 'No selected files.'}), 400)
236
 
237
+ (all_docs, processed_files, failed_files) = ([], [], [])
238
+ print (f"📁 Processing {len(files)} file(s)...")
239
  for file in files:
240
  if file and file.filename:
241
  filename = secure_filename(file.filename)
 
243
  try:
244
  file.save(filepath)
245
  file_ext = os.path.splitext(filename)[1].lower()
246
+ if file_ext not in LOADER_MAPPING:
247
+ raise ValueError('Unsupported file format.')
248
  loader_func = LOADER_MAPPING[file_ext]
249
+ docs = loader_func(filepath) if file_ext == '.pdf' \
250
+ else loader_func(filepath).load()
251
+ if not docs:
252
+ raise ValueError('No content extracted.')
253
  all_docs.extend(docs)
254
  processed_files.append(filename)
255
  except Exception as e:
256
+ print (f"✗ Error processing {filename}: {e}")
257
  failed_files.append(f"{filename} ({e})")
258
 
259
  if not all_docs:
260
+ return (jsonify({'status': 'error',
261
+ 'message': f"Failed to process all files. Reasons: {', '.join(failed_files)}"
262
+ }), 400)
263
 
264
+ print (f"✅ UPLOAD: Processed {len(processed_files)} files.")
265
  try:
266
+ print ('🔄 Starting RAG pipeline setup...')
267
+ (parent_docs, child_docs, doc_ids) = \
268
+ create_optimized_parent_child_chunks(all_docs)
269
+ if not child_docs:
270
+ raise ValueError('No child documents created during chunking.')
 
 
271
 
272
+ vectorstore = FAISS.from_documents(child_docs, EMBEDDING_MODEL)
273
+ store = InMemoryStore()
274
+ store.mset(list(zip(doc_ids, parent_docs)))
275
+ print (f"✅ Indexed {len(child_docs)} document chunks.")
276
+
277
+ bm25_retriever = BM25Retriever.from_documents(child_docs)
278
+ bm25_retriever.k = 12
279
+ faiss_retriever = vectorstore.as_retriever(search_kwargs={'k': 12})
280
+ ensemble_retriever = \
281
+ EnsembleRetriever(retrievers=[bm25_retriever,
282
+ faiss_retriever], weights=[0.6, 0.4])
283
  reranker = LocalReranker(model=RERANKER_MODEL, top_n=5)
284
+
285
+ def get_parents(docs: List[Document]) -> List[Document]:
286
+ return get_context_aware_parents(docs, store)
287
+
288
+ compression_retriever = \
289
+ ContextualCompressionRetriever(base_compressor=reranker,
290
+ base_retriever=ensemble_retriever)
291
  final_retriever = compression_retriever | get_parents
292
 
293
  session_id = str(uuid.uuid4())
294
+ (rag_chain, api_key_manager) = \
295
+ create_rag_chain(retriever=final_retriever,
296
+ get_session_history_func=get_session_history,
297
+ model_name=model_name,
298
+ temperature=temperature)
299
+
300
+ session_data[session_id] = {'chain': rag_chain,
301
+ 'model_name': model_name,
302
+ 'temperature': temperature,
303
+ 'api_key_manager': api_key_manager}
304
+
305
  success_msg = f"Processed: {', '.join(processed_files)}"
306
+ if failed_files:
307
+ success_msg += f". Failed: {', '.join(failed_files)}"
308
+
309
+ mode_label = TEMPERATURE_LABELS.get(temperature_str,
310
+ temperature_str)
311
+
312
+ print (f"✅ UPLOAD COMPLETE: Session {session_id} is ready.")
313
+
314
  return jsonify({
315
+ 'status': 'success',
316
+ 'filename': success_msg,
317
  'session_id': session_id,
318
  'model_name': model_name,
319
+ 'mode': mode_label,
320
+ })
321
  except Exception as e:
322
+ import traceback
323
+ traceback.print_exc()
324
+ return (jsonify({'status': 'error',
325
+ 'message': f'RAG setup failed: {e}'}), 500)
326
 
327
+ # --- CORRECTED: Added 'GET' to methods and handle request args ---
328
+ @app.route('/chat', methods=['POST', 'GET'])
329
  def chat():
330
+ # Handle GET request (used by EventSource)
331
+ if request.method == 'GET':
332
+ question = request.args.get('question')
333
+ session_id = request.args.get('session_id')
334
+ print(f"Received GET request for chat: session={session_id}, question={question[:50]}...") # Log GET request
335
+ # Handle POST request (if you ever need it again)
336
+ elif request.method == 'POST':
337
+ data = request.get_json()
338
+ question = data.get('question')
339
+ session_id = data.get('session_id') or session.get('session_id')
340
+ print(f"Received POST request for chat: session={session_id}, question={question[:50]}...") # Log POST request
341
+ else:
342
+ return (jsonify({'status': 'error', 'message': 'Method not allowed'}), 405)
343
+
344
+ # --- Validation ---
345
+ if not question:
346
+ error_msg = "Error: No question provided."
347
+ print(f"❌ CHAT Validation Error: {error_msg}")
348
+ if request.method == 'GET':
349
+ # For SSE, need to yield an error event, not return plain text
350
+ def error_stream():
351
+ yield f'data: {{"error": "{error_msg}"}}\n\n'
352
+ return Response(stream_with_context(error_stream()), mimetype='text/event-stream', status=400)
353
+ return jsonify({'status': 'error','message': error_msg}), 400
354
 
 
355
  if not session_id or session_id not in session_data:
356
+ error_msg = "Error: Invalid session. Please upload documents first."
357
+ print(f"❌ CHAT Validation Error: Invalid session {session_id}.")
358
+ if request.method == 'GET':
359
+ def error_stream():
360
+ yield f'data: {{"error": "{error_msg}"}}\n\n'
361
+ return Response(stream_with_context(error_stream()), mimetype='text/event-stream', status=400)
362
+ return jsonify({'status': 'error', 'message': error_msg }), 400
363
+
364
+ # --- Process Request ---
365
  try:
366
  session_info = session_data[session_id]
367
  rag_chain = session_info['chain']
 
 
 
 
368
  model_name = session_info['model_name']
 
 
369
  temperature_float = session_info['temperature']
370
  temperature_str = str(temperature_float)
 
 
371
  mode_label = TEMPERATURE_LABELS.get(temperature_str, temperature_str)
372
+
373
+ print (f"💬 CHAT: Streaming response for session {session_id} (Model: {model_name}, Temp: {temperature_float})...")
374
+
375
+ def generate_chunks():
376
+ full_response = ''
377
+ try:
378
+ stream_iterator = rag_chain.stream({'question': question},
379
+ config={'configurable': {'session_id': session_id}})
380
+
381
+ for chunk in stream_iterator:
382
+ if isinstance(chunk, str): # Ensure it's a string chunk
383
+ full_response += chunk
384
+ token_escaped = chunk.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
385
+ model_name_escaped = model_name.replace('"', '\\"')
386
+ mode_label_escaped = mode_label.replace('"', '\\"')
387
+ yield f'data: {{"token": "{token_escaped}", "model_name": "{model_name_escaped}", "mode": "{mode_label_escaped}"}}\n\n'
388
+ else:
389
+ # Handle potential other types if stream yields non-strings
390
+ print(f"⚠️ Received non-string chunk: {type(chunk)}")
391
+
392
+
393
+ print ('✅ CHAT: Streaming finished successfully.')
394
+ # Optionally update session history or store full response if needed later
395
+ # get_session_history(session_id).add_ai_message(full_response)
396
+
397
+ except Exception as e:
398
+ print(f"❌ CHAT Error during streaming generation: {e}")
399
+ import traceback
400
+ traceback.print_exc()
401
+ error_msg = f"Error during response generation: {str(e)}".replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
402
+ yield f'data: {{"error": "{error_msg}"}}\n\n'
403
+
404
+ # Return the streaming response
405
+ return Response(stream_with_context(generate_chunks()), mimetype='text/event-stream')
406
+
407
  except Exception as e:
408
+ # Catch errors during setup before streaming starts
409
+ print(f"❌ CHAT Setup Error: {e}")
410
+ import traceback
411
+ traceback.print_exc()
412
+ error_msg = f"Error setting up chat stream: {str(e)}"
413
+ if request.method == 'GET':
414
+ def error_stream():
415
+ yield f'data: {{"error": "{error_msg.replace("\"", "\\\"").replace("n", "\\n")}"}}\n\n'
416
+ return Response(stream_with_context(error_stream()), mimetype='text/event-stream', status=500)
417
+ return (jsonify({'status': 'error', 'message': error_msg}), 500)
418
+
419
 
420
  def clean_markdown_for_tts(text: str) -> str:
421
+ # --- Simplified cleaning for TTS ---
422
+ text = re.sub(r'\[.*?\]\(.*?\)', '', text) # Remove links
423
+ text = re.sub(r'[`*_#]', '', text) # Remove formatting chars
424
+ text = re.sub(r'^\s*[\-\*\+]\s+', '', text, flags=re.MULTILINE) # Remove list markers
425
+ text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE) # Remove numbered list markers
426
+ text = re.sub(r'^\s*>\s?', '', text, flags=re.MULTILINE) # Remove blockquote markers
427
+ text = re.sub(r'\n+', ' ', text) # Replace newlines with spaces
428
+ text = re.sub(r'\s{2,}', ' ', text) # Collapse multiple spaces
429
  return text.strip()
430
 
431
+
432
  @app.route('/tts', methods=['POST'])
433
  def text_to_speech():
434
+ data = request.get_json()
435
+ text = data.get('text')
436
+ if not text:
437
+ return (jsonify({'status': 'error',
438
+ 'message': 'No text provided.'}), 400)
439
  try:
440
+ clean_text = clean_markdown_for_tts(text)
441
+ if not clean_text:
442
+ return (jsonify({'status': 'error', 'message': 'No speakable text found.'}), 400)
443
+
444
+ tts = gTTS(clean_text, lang='en')
445
+ mp3_fp = io.BytesIO()
446
+ tts.write_to_fp(mp3_fp)
447
+ mp3_fp.seek(0)
448
  return Response(mp3_fp, mimetype='audio/mpeg')
449
  except Exception as e:
450
+ print (f"❌ TTS Error: {e}")
451
+ return (jsonify({'status': 'error',
452
+ 'message': 'Failed to generate audio.'}), 500)
453
+
454
 
455
  if __name__ == '__main__':
456
+ port = int(os.environ.get('PORT', 7860))
457
+ print (f"🚀 Starting Flask app on port {port}")
458
+ # Use threaded=True for better handling of concurrent requests during streaming
459
+ app.run(host='0.0.0.0', port=port, debug=False, threaded=True)
rag_processor.py CHANGED
@@ -231,7 +231,7 @@ RESPONSE GUIDELINES:
231
  - Add blank lines between sections for readability
232
 
233
  **Source Citation:**
234
- - Always cite information using: [Source: filename, Page: X]
235
  - Place citations at the end of your final answer only
236
  - Do not cite sources within the body of your answer
237
  - Multiple sources: [Source: doc1.pdf, Page: 3; doc2.pdf, Page: 7]
 
231
  - Add blank lines between sections for readability
232
 
233
  **Source Citation:**
234
+ - Always cite information using: [Source: filename, Page: X] and cite it at the end of the entire answer only
235
  - Place citations at the end of your final answer only
236
  - Do not cite sources within the body of your answer
237
  - Multiple sources: [Source: doc1.pdf, Page: 3; doc2.pdf, Page: 7]
templates/index.html CHANGED
@@ -1,500 +1,821 @@
1
  <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>CogniChat - Chat with your Documents</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <link rel="preconnect" href="https://fonts.googleapis.com">
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Google+Sans:wght@400;500;700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
11
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
12
- <style>
13
- :root {
14
- --background: #f0f4f9;
15
- --foreground: #1f1f1f;
16
- --primary: #1a73e8;
17
- --primary-hover: #1867cf;
18
- --card: #ffffff;
19
- --card-border: #dadce0;
20
- --input-bg: #e8f0fe;
21
- --user-bubble: #d9e7ff;
22
- --bot-bubble: #f1f3f4;
23
- --select-bg: #ffffff;
24
- --select-border: #dadce0;
25
- --select-text: #1f1f1f;
26
- }
27
-
28
- .dark {
29
- --background: #111827;
30
- --foreground: #e5e7eb;
31
- --primary: #3b82f6;
32
- --primary-hover: #60a5fa;
33
- --card: #1f2937;
34
- --card-border: #4b5563;
35
- --input-bg: #374151;
36
- --user-bubble: #374151;
37
- --bot-bubble: #374151;
38
- --select-bg: #374151;
39
- --select-border: #6b7280;
40
- --select-text: #f3f4f6;
41
- }
42
-
43
- body {
44
- font-family: 'Inter', 'Google Sans', 'Roboto', sans-serif;
45
- background-color: var(--background);
46
- color: var(--foreground);
47
- overflow: hidden;
48
- }
49
-
50
- #chat-window::-webkit-scrollbar { width: 8px; }
51
- #chat-window::-webkit-scrollbar-track { background: transparent; }
52
- #chat-window::-webkit-scrollbar-thumb { background-color: #4b5563; border-radius: 20px; }
53
- .dark #chat-window::-webkit-scrollbar-thumb { background-color: #5f6368; }
54
-
55
- .drop-zone--over {
56
- border-color: var(--primary);
57
- box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
58
- }
59
-
60
- .loader {
61
- width: 48px;
62
- height: 48px;
63
- border: 3px solid var(--card-border);
64
- border-radius: 50%;
65
- display: inline-block;
66
- position: relative;
67
- box-sizing: border-box;
68
- animation: rotation 1s linear infinite;
69
- }
70
- .loader::after {
71
- content: '';
72
- box-sizing: border-box;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  position: absolute;
74
- left: 50%;
75
- top: 50%;
76
- transform: translate(-50%, -50%);
77
- width: 56px;
78
- height: 56px;
79
- border-radius: 50%;
80
- border: 3px solid;
81
- border-color: var(--primary) transparent;
82
- }
83
-
84
- @keyframes rotation {
85
- 0% { transform: rotate(0deg); }
86
- 100% { transform: rotate(360deg); }
87
- }
88
-
89
- .typing-indicator span {
90
- height: 10px;
91
- width: 10px;
92
- background-color: #9E9E9E;
93
- border-radius: 50%;
94
- display: inline-block;
95
- animation: bounce 1.4s infinite ease-in-out both;
96
- }
97
- .typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
98
- .typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
99
- @keyframes bounce {
100
- 0%, 80%, 100% { transform: scale(0); }
101
- 40% { transform: scale(1.0); }
102
- }
103
-
104
- .markdown-content p { margin-bottom: 1rem; line-height: 1.75; }
105
- .markdown-content h1, .markdown-content h2, .markdown-content h3 { font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.75rem; line-height: 1.3; }
106
- .markdown-content h1 { font-size: 1.5em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3rem;}
107
- .markdown-content h2 { font-size: 1.25em; }
108
- .markdown-content h3 { font-size: 1.1em; }
109
- .markdown-content ul, .markdown-content ol { padding-left: 1.75rem; margin-bottom: 1rem; }
110
- .markdown-content li { margin-bottom: 0.5rem; }
111
- .markdown-content a { color: var(--primary); text-decoration: none; font-weight: 500; }
112
- .markdown-content pre { position: relative; background-color: #2e2f32; border: 1px solid var(--card-border); border-radius: 0.5rem; margin-bottom: 1rem; font-size: 0.9em;}
113
- .markdown-content pre code { background: none; padding: 1rem; display: block; overflow-x: auto; }
114
- .markdown-content pre .copy-code-btn { position: absolute; top: 0.5rem; right: 0.5rem; background-color: #3c4043; border: 1px solid #5f6368; color: #e8eaed; padding: 0.3rem 0.6rem; border-radius: 0.25rem; cursor: pointer; opacity: 0; transition: opacity 0.2s; font-size: 0.8em;}
115
- .markdown-content pre:hover .copy-code-btn { opacity: 1; }
116
-
117
- .tts-button-loader {
118
- width: 16px;
119
- height: 16px;
120
- border: 2px solid currentColor;
121
- border-radius: 50%;
122
- display: inline-block;
123
- box-sizing: border-box;
124
- animation: rotation 0.8s linear infinite;
125
- border-bottom-color: transparent;
126
- }
127
-
128
- .select-wrapper {
129
- position: relative;
130
- }
131
- .select-wrapper select {
132
- background-color: var(--select-bg);
133
- border: 1px solid var(--select-border);
134
- color: var(--select-text);
135
- padding: 0.75rem 2.5rem 0.75rem 1rem;
136
- border-radius: 0.75rem;
137
- font-size: 0.875rem;
138
- width: 100%;
139
- appearance: none;
140
- -webkit-appearance: none;
141
- transition: all 0.2s ease-in-out;
142
  cursor: pointer;
143
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
144
- background-position: right 0.75rem center;
145
- background-repeat: no-repeat;
146
- background-size: 1.25em 1.25em;
147
- }
148
- </style>
149
- </head>
150
- <body class="w-screen h-screen dark">
151
- <main id="main-content" class="h-full flex flex-col transition-opacity duration-500">
152
- <div id="chat-container" class="hidden flex-1 flex flex-col w-full mx-auto overflow-hidden">
153
-
154
- <!-- --- CORRECT HEADER (Center/Right Layout) --- -->
155
- <header class="p-4 border-b border-[var(--card-border)] flex-shrink-0 flex justify-between items-center w-full">
156
- <div class="w-1/4"></div>
157
- <div class="w-1/2 text-center">
158
- <h1 class="text-xl font-medium tracking-wide">CogniChat</h1>
159
- <p id="chat-filename" class="text-xs text-gray-400 mt-1 truncate"></p>
160
- </div>
161
- <div id="chat-session-info" class="w-1/4 text-right text-xs">
162
- <!-- This will be populated by JavaScript -->
163
- </div>
164
- </header>
165
- <!-- --- END HEADER --- -->
166
-
167
- <div id="chat-window" class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-10">
168
- <div id="chat-content" class="max-w-4xl mx-auto space-y-8"></div>
169
- </div>
170
- <div class="p-4 flex-shrink-0 bg-opacity-50 backdrop-blur-md border-t border-[var(--card-border)]">
171
- <form id="chat-form" class="max-w-4xl mx-auto bg-[var(--card)] rounded-full p-2 flex items-center shadow-lg border border-[var(--card-border)] focus-within:ring-2 focus-within:ring-[var(--primary)] transition-all">
172
- <input type="text" id="chat-input" placeholder="Ask a question about your documents..." class="flex-grow bg-transparent focus:outline-none px-4 text-sm" autocomplete="off">
173
- <button type="submit" id="chat-submit-btn" class="bg-[var(--primary)] hover:bg-[var(--primary-hover)] text-white p-2.5 rounded-full transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" title="Send">
174
- <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z" clip-rule="evenodd"></path></svg>
175
- </button>
176
- </form>
177
- </div>
178
- </div>
179
-
180
- <div id="upload-container" class="flex-1 flex flex-col items-center justify-center p-8 transition-opacity duration-300">
181
- <div class="text-center max-w-xl w-full">
182
- <h1 class="text-5xl font-bold mb-3 tracking-tight">CogniChat</h1>
183
- <p class="text-lg text-gray-400 mb-8">Upload your documents to start a conversation.</p>
184
- <div class="mb-8 p-5 bg-[var(--card)] rounded-2xl border border-[var(--card-border)] shadow-lg">
185
- <div class="flex flex-col sm:flex-row items-center gap-6">
186
- <div class="w-full sm:w-1/2">
187
- <div class="flex items-center gap-2 mb-2">
188
- <svg class="w-5 h-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" /></svg>
189
- <label for="model-select" class="block text-sm font-medium text-gray-300">Model</label>
190
- </div>
191
- <div class="select-wrapper">
192
- <select id="model-select" name="model_name">
193
- <option value="moonshotai/kimi-k2-instruct" selected>Kimi Instruct</option>
194
- <option value="openai/gpt-oss-20b">GPT OSS 20b</option>
195
- <option value="llama-3.3-70b-versatile">Llama 3.3 70b</option>
196
- <option value="llama-3.1-8b-instant">Llama 3.1 8b Instant</option>
197
- </select>
198
- </div>
199
- </div>
200
- <div class="w-full sm:w-1/2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  <div class="flex items-center gap-2 mb-2">
202
- <svg class="w-5 h-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.5 16a3.5 3.5 0 100-7 3.5 3.5 0 000 7zM12 5.5a3.5 3.5 0 11-7 0 3.5 3.5 0 017 0zM14.5 16a3.5 3.5 0 100-7 3.5 3.5 0 000 7z" clip-rule="evenodd" /></svg>
203
- <label for="temperature-select" class="block text-sm font-medium text-gray-300">Mode</label>
204
- </div>
205
  <div class="select-wrapper">
206
- <select id="temperature-select" name="temperature">
207
- <option value="0.2" selected>0.2 - Precise</option>
208
- <option value="0.4">0.4 - Confident</option>
209
- <option value="0.6">0.6 - Balanced</option>
210
- <option value="0.8">0.8 - Flexible</option>
211
- <option value="1.0">1.0 - Creative</option>
212
- </select>
213
- </div>
214
- </div>
215
- </div>
216
- <p class="text-xs text-gray-500 mt-4 text-center">Higher creativity modes may reduce factual accuracy.</p>
217
- </div>
218
- <div id="drop-zone" class="w-full text-center border-2 border-dashed border-[var(--card-border)] rounded-2xl p-10 transition-all duration-300 cursor-pointer hover:bg-[var(--card)] hover:border-[var(--primary)]">
219
- <div class="flex flex-col items-center justify-center pointer-events-none">
220
- <svg class="mx-auto h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16.5V9.75m0 0l3-3m-3 3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"></path></svg>
221
- <p class="mt-4 text-sm font-medium text-gray-400">Drag & drop files or <span class="text-[var(--primary)] font-semibold">click to upload</span></p>
222
- <p class="text-xs text-gray-400 mt-1">Supports PDF, DOCX, TXT</p>
223
- <p id="file-name" class="mt-2 text-xs text-gray-500"></p>
224
- </div>
225
- <input id="file-upload" type="file" class="hidden" accept=".pdf,.txt,.docx" multiple>
226
- </div>
227
- </div>
228
- </div>
229
-
230
- <div id="loading-overlay" class="hidden fixed inset-0 bg-[var(--background)] bg-opacity-80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
231
- <div class="loader"></div>
232
- <p id="loading-text" class="mt-6 text-sm font-medium"></p>
233
- <p id="loading-subtext" class="mt-2 text-xs text-gray-400"></p>
234
- </div>
235
- </main>
236
-
237
- <script>
238
- document.addEventListener('DOMContentLoaded', () => {
239
- const uploadContainer = document.getElementById('upload-container');
240
- const chatContainer = document.getElementById('chat-container');
241
- const dropZone = document.getElementById('drop-zone');
242
- const fileUploadInput = document.getElementById('file-upload');
243
- const fileNameSpan = document.getElementById('file-name');
244
- const loadingOverlay = document.getElementById('loading-overlay');
245
- const loadingText = document.getElementById('loading-text');
246
- const loadingSubtext = document.getElementById('loading-subtext');
247
- const chatForm = document.getElementById('chat-form');
248
- const chatInput = document.getElementById('chat-input');
249
- const chatSubmitBtn = document.getElementById('chat-submit-btn');
250
- const chatWindow = document.getElementById('chat-window');
251
- const chatContent = document.getElementById('chat-content');
252
- const modelSelect = document.getElementById('model-select');
253
- const temperatureSelect = document.getElementById('temperature-select');
254
- const chatFilename = document.getElementById('chat-filename');
255
- const chatSessionInfo = document.getElementById('chat-session-info');
256
-
257
- let sessionId = sessionStorage.getItem('cognichat_session_id');
258
-
259
- dropZone.addEventListener('click', () => fileUploadInput.click());
260
-
261
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
262
- dropZone.addEventListener(eventName, e => {e.preventDefault(); e.stopPropagation();});
263
- });
264
- ['dragenter', 'dragover'].forEach(eventName => dropZone.addEventListener(eventName, () => dropZone.classList.add('drop-zone--over')));
265
- ['dragleave', 'drop'].forEach(eventName => dropZone.addEventListener(eventName, () => dropZone.classList.remove('drop-zone--over')));
266
-
267
- dropZone.addEventListener('drop', (e) => {
268
- if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
269
- });
270
- fileUploadInput.addEventListener('change', (e) => {
271
- if (e.target.files.length > 0) handleFiles(e.target.files);
272
- });
273
-
274
- async function handleFiles(files) {
275
- const formData = new FormData();
276
- let fileNames = Array.from(files).map(f => f.name);
277
- for (const file of files) { formData.append('file', file); }
278
-
279
- formData.append('model_name', modelSelect.value);
280
- formData.append('temperature', temperatureSelect.value);
281
-
282
- fileNameSpan.textContent = `Selected: ${fileNames.join(', ')}`;
283
- await uploadAndProcessFiles(formData);
284
- }
285
-
286
- async function uploadAndProcessFiles(formData) {
287
- loadingOverlay.classList.remove('hidden');
288
- loadingText.textContent = `Processing document(s)...`;
289
- loadingSubtext.textContent = "Creating a knowledge base may take a minute. Please hold on tight!";
290
-
291
- try {
292
- const response = await fetch('/upload', { method: 'POST', body: formData });
293
- const result = await response.json();
294
- if (!response.ok) throw new Error(result.message || 'Unknown error occurred.');
295
-
296
- sessionId = result.session_id;
297
- sessionStorage.setItem('cognichat_session_id', sessionId);
298
-
299
- chatFilename.innerHTML = `Chatting with: <strong>${result.filename}</strong>`;
300
- chatFilename.title = result.filename;
301
-
302
- chatSessionInfo.innerHTML = `
303
- <span class="text-gray-500 dark:text-gray-500 italic block hover:text-gray-300 transition-colors cursor-pointer" onclick="location.reload()">
304
- Refresh to change settings
305
- </span>`;
306
-
307
- const modelOption = modelSelect.querySelector(`option[value="${result.model_name}"]`);
308
- const simpleModelName = modelOption ? modelOption.textContent : result.model_name;
309
-
310
- const modelInfo = {
311
- model: result.model_name,
312
- mode: result.mode,
313
- simpleModelName: simpleModelName
314
- };
315
-
316
- uploadContainer.classList.add('hidden');
317
- chatContainer.classList.remove('hidden');
318
- appendMessage("I've analyzed your documents. What would you like to know?", "bot", modelInfo);
319
-
320
- } catch (error) {
321
- console.error('Upload error:', error);
322
- alert(`Error: ${error.message}`);
323
- } finally {
324
- loadingOverlay.classList.add('hidden');
325
- fileNameSpan.textContent = '';
326
- fileUploadInput.value = '';
327
- }
328
- }
329
-
330
- chatForm.addEventListener('submit', async (e) => {
331
- e.preventDefault();
332
- const question = chatInput.value.trim();
333
- if (!question) return;
334
-
335
- appendMessage(question, 'user');
336
- chatInput.value = '';
337
- chatInput.disabled = true;
338
- chatSubmitBtn.disabled = true;
339
-
340
- const typingIndicator = showTypingIndicator();
341
-
342
- try {
343
- const response = await fetch('/chat', {
344
- method: 'POST',
345
- headers: { 'Content-Type': 'application/json' },
346
- body: JSON.stringify({ question, session_id: sessionId }),
347
- });
348
- if (!response.ok) throw new Error(`Server error: ${response.statusText}`);
349
-
350
- const result = await response.json();
351
-
352
- const modelOption = modelSelect.querySelector(`option[value="${result.model_name}"]`);
353
- const simpleModelName = modelOption ? modelOption.textContent : result.model_name;
354
- const modelInfo = {
355
- model: result.model_name,
356
- mode: result.mode,
357
- simpleModelName: simpleModelName
358
- };
359
-
360
- typingIndicator.remove();
361
- const botMessageContainer = appendMessage('', 'bot', modelInfo);
362
- const contentDiv = botMessageContainer.querySelector('.markdown-content');
363
- contentDiv.innerHTML = marked.parse(result.answer);
364
- contentDiv.querySelectorAll('pre').forEach(addCopyButton);
365
- scrollToBottom();
366
- addTextToSpeechControls(botMessageContainer, result.answer);
367
- } catch (error) {
368
- console.error('Chat error:', error);
369
- typingIndicator.remove();
370
- appendMessage(`Error: ${error.message}`, 'bot');
371
- } finally {
372
- chatInput.disabled = false;
373
- chatSubmitBtn.disabled = false;
374
- chatInput.focus();
375
- }
376
- });
377
-
378
- // --- FINAL, CORRECT appendMessage function ---
379
- function appendMessage(text, sender, modelInfo = null) {
380
- const messageWrapper = document.createElement('div');
381
- const iconSVG = sender === 'user'
382
- ? `<div class="bg-blue-200 dark:bg-gray-700 p-2.5 rounded-full flex-shrink-0 mt-1"><svg class="w-5 h-5 text-blue-700 dark:text-blue-300" viewBox="0 0 24 24"><path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"></path></svg></div>`
383
- : `<div class="bg-gray-200 dark:bg-gray-700 rounded-full flex-shrink-0 mt-1 text-xl flex items-center justify-center w-10 h-10">✨</div>`;
384
-
385
- let senderHTML;
386
- if (sender === 'user') {
387
- senderHTML = '<p class="font-medium text-sm mb-1">You</p>';
388
- } else {
389
- let modelInfoHTML = '';
390
- if (modelInfo && modelInfo.simpleModelName) {
391
- modelInfoHTML = `
392
- <span class="ml-2 text-xs font-normal text-gray-400">
393
- (Model: ${modelInfo.simpleModelName} • Mode: ${modelInfo.mode})
394
- </span>
395
- `;
396
- }
397
- senderHTML = `<div class="font-medium text-sm mb-1 flex items-center">CogniChat ${modelInfoHTML}</div>`;
398
- }
399
-
400
- messageWrapper.className = `flex items-start gap-4`;
401
- messageWrapper.innerHTML = `
402
- ${iconSVG}
403
- <div class="flex-1 pt-1">
404
- ${senderHTML}
405
- <div class="text-base markdown-content">${text ? marked.parse(text) : ''}</div>
406
- <div class="tts-controls mt-2"></div>
407
- </div>
408
- `;
409
- chatContent.appendChild(messageWrapper);
410
- scrollToBottom();
411
- return messageWrapper.querySelector('.flex-1');
412
- }
413
-
414
- function showTypingIndicator() {
415
- const indicator = document.createElement('div');
416
- indicator.id = 'typing-indicator';
417
- indicator.className = `flex items-start gap-4`;
418
- indicator.innerHTML = `<div class="bg-gray-200 dark:bg-gray-700 rounded-full flex-shrink-0 mt-1 text-xl flex items-center justify-center w-10 h-10">✨</div><div class="flex-1 pt-1"><p class="font-medium text-sm mb-1">CogniChat is thinking...</p><div class="typing-indicator"><span></span><span></span><span></span></div></div>`;
419
- chatContent.appendChild(indicator);
420
- scrollToBottom();
421
- return indicator;
422
- }
423
-
424
- function scrollToBottom() { chatWindow.scrollTo({ top: chatWindow.scrollHeight, behavior: 'smooth' }); }
425
-
426
- function addCopyButton(pre) {
427
- const button = document.createElement('button');
428
- button.className = 'copy-code-btn';
429
- button.textContent = 'Copy';
430
- pre.appendChild(button);
431
- button.addEventListener('click', () => {
432
- navigator.clipboard.writeText(pre.querySelector('code').innerText)
433
- .then(() => {
434
- button.textContent = 'Copied!';
435
- setTimeout(() => button.textContent = 'Copy', 2000);
436
- });
437
- });
438
- }
439
-
440
- // (TTS functions remain unchanged)
441
- let currentAudio, currentPlayingButton;
442
- const playIconSVG = `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
443
- const pauseIconSVG = `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`;
444
- function addTextToSpeechControls(messageBubble, text) {
445
- if (!text.trim()) return;
446
- const speakButton = document.createElement('button');
447
- speakButton.className = 'speak-btn mt-2 px-3 py-1.5 bg-blue-700 text-white rounded-full text-sm font-medium hover:bg-blue-800 transition-colors flex items-center gap-2 disabled:opacity-50';
448
- speakButton.title = 'Listen to this message';
449
- speakButton.innerHTML = `${playIconSVG} <span>Listen</span>`;
450
- messageBubble.querySelector('.tts-controls').appendChild(speakButton);
451
- speakButton.addEventListener('click', () => handleTTS(text, speakButton));
452
- }
453
-
454
- async function handleTTS(text, button) {
455
- if (button === currentPlayingButton) {
456
- if (currentAudio && !currentAudio.paused) {
457
- currentAudio.pause();
458
- button.innerHTML = `${playIconSVG} <span>Listen</span>`;
459
- } else if (currentAudio && currentAudio.paused) {
460
- currentAudio.play();
461
- button.innerHTML = `${pauseIconSVG} <span>Pause</span>`;
462
- }
463
- return;
464
- }
465
- resetAllSpeakButtons();
466
- currentPlayingButton = button;
467
- button.innerHTML = `<div class="tts-button-loader"></div> <span>Loading...</span>`;
468
- button.disabled = true;
469
-
470
- try {
471
- const response = await fetch('/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
472
- if (!response.ok) throw new Error('Failed to generate audio.');
473
- const blob = await response.blob();
474
- currentAudio = new Audio(URL.createObjectURL(blob));
475
- currentAudio.play();
476
- button.innerHTML = `${pauseIconSVG} <span>Pause</span>`;
477
- currentAudio.onended = resetAllSpeakButtons;
478
- } catch (error) {
479
- console.error('TTS Error:', error);
480
- resetAllSpeakButtons();
481
- } finally {
482
- button.disabled = false;
483
- }
484
- }
485
-
486
- function resetAllSpeakButtons() {
487
- document.querySelectorAll('.speak-btn').forEach(btn => {
488
- btn.innerHTML = `${playIconSVG} <span>Listen</span>`;
489
- btn.disabled = false;
490
- });
491
- if (currentAudio) {
492
- currentAudio.pause();
493
- currentAudio = null;
494
- }
495
- currentPlayingButton = null;
496
- }
497
- });
498
- </script>
499
- </body>
500
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CogniChat - Chat with your Documents</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Google+Sans:wght@400;500;700&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
11
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
12
+ <style>
13
+ :root {
14
+ --background: #f0f4f9;
15
+ --foreground: #1f1f1f;
16
+ --primary: #1a73e8;
17
+ --primary-hover: #1867cf;
18
+ --card: #ffffff;
19
+ --card-border: #dadce0;
20
+ --input-bg: #e8f0fe;
21
+ --user-bubble: #d9e7ff;
22
+ --bot-bubble: #f1f3f4;
23
+ --select-bg: #ffffff;
24
+ --select-border: #dadce0;
25
+ --select-text: #1f1f1f;
26
+ }
27
+
28
+ .dark {
29
+ --background: #111827; /* Darker background */
30
+ --foreground: #e5e7eb;
31
+ --primary: #3b82f6; /* Adjusted primary blue */
32
+ --primary-hover: #60a5fa; /* Lighter hover blue */
33
+ --card: #1f2937; /* Dark card background */
34
+ --card-border: #4b5563; /* Greyer border */
35
+ --input-bg: #374151; /* Darker input background */
36
+ --user-bubble: #374151; /* Darker user bubble */
37
+ --bot-bubble: #374151; /* Darker bot bubble */
38
+ --select-bg: #374151;
39
+ --select-border: #6b7280;
40
+ --select-text: #f3f4f6;
41
+ --code-bg: #2d2d2d; /* Specific background for code blocks */
42
+ --code-text: #d4d4d4; /* Light grey text for code */
43
+ --copy-btn-bg: #4a4a4a;
44
+ --copy-btn-hover-bg: #5a5a5a;
45
+ --copy-btn-text: #e0e0e0;
46
+ }
47
+
48
+ body {
49
+ font-family: 'Inter', 'Google Sans', 'Roboto', sans-serif;
50
+ background-color: var(--background);
51
+ color: var(--foreground);
52
+ overflow: hidden; /* Prevent body scroll */
53
+ }
54
+
55
+ #chat-window {
56
+ scroll-behavior: smooth; /* Ensure smooth programatic scroll */
57
+ }
58
+ #chat-window::-webkit-scrollbar { width: 8px; }
59
+ #chat-window::-webkit-scrollbar-track { background: transparent; }
60
+ #chat-window::-webkit-scrollbar-thumb { background-color: #4b5563; border-radius: 20px; }
61
+ .dark #chat-window::-webkit-scrollbar-thumb { background-color: #5f6368; }
62
+
63
+ .drop-zone--over {
64
+ border-color: var(--primary);
65
+ box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
66
+ }
67
+
68
+ .loader {
69
+ width: 48px;
70
+ height: 48px;
71
+ border: 3px solid var(--card-border);
72
+ border-radius: 50%;
73
+ display: inline-block;
74
+ position: relative;
75
+ box-sizing: border-box;
76
+ animation: rotation 1s linear infinite;
77
+ }
78
+ .loader::after {
79
+ content: '';
80
+ box-sizing: border-box;
81
+ position: absolute;
82
+ left: 50%;
83
+ top: 50%;
84
+ transform: translate(-50%, -50%);
85
+ width: 56px;
86
+ height: 56px;
87
+ border-radius: 50%;
88
+ border: 3px solid;
89
+ border-color: var(--primary) transparent;
90
+ }
91
+
92
+ @keyframes rotation {
93
+ 0% { transform: rotate(0deg); }
94
+ 100% { transform: rotate(360deg); }
95
+ }
96
+
97
+ /* --- Updated Typing Indicator --- */
98
+ .typing-indicator {
99
+ display: inline-flex; /* Changed to inline-flex */
100
+ align-items: center;
101
+ padding: 8px 0; /* Add some vertical padding */
102
+ }
103
+ .typing-indicator span {
104
+ height: 8px; /* Slightly smaller dots */
105
+ width: 8px;
106
+ margin: 0 2px;
107
+ background-color: #9E9E9E;
108
+ border-radius: 50%;
109
+ opacity: 0; /* Start invisible */
110
+ animation: typing-pulse 1.4s infinite ease-in-out;
111
+ }
112
+ .typing-indicator span:nth-child(1) { animation-delay: 0s; }
113
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
114
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
115
+
116
+ @keyframes typing-pulse {
117
+ 0%, 100% { opacity: 0; transform: scale(0.7); }
118
+ 50% { opacity: 1; transform: scale(1); }
119
+ }
120
+ /* --- End Typing Indicator --- */
121
+
122
+ /* --- Updated Markdown Styling --- */
123
+ .markdown-content { /* Base styles for the content area */
124
+ line-height: 1.75;
125
+ }
126
+ .markdown-content p { margin-bottom: 1rem; }
127
+ .markdown-content h1, .markdown-content h2, .markdown-content h3,
128
+ .markdown-content h4, .markdown-content h5, .markdown-content h6 {
129
+ font-weight: 600;
130
+ margin-top: 1.5rem;
131
+ margin-bottom: 0.75rem;
132
+ line-height: 1.3;
133
+ }
134
+ .markdown-content h1 { font-size: 1.5em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3rem;}
135
+ .markdown-content h2 { font-size: 1.25em; }
136
+ .markdown-content h3 { font-size: 1.1em; }
137
+ .markdown-content ul, .markdown-content ol { padding-left: 1.75rem; margin-bottom: 1rem; }
138
+ .markdown-content li { margin-bottom: 0.5rem; }
139
+ .markdown-content a { color: var(--primary); text-decoration: none; font-weight: 500; }
140
+ .markdown-content a:hover { text-decoration: underline; }
141
+ .markdown-content strong, .markdown-content b { font-weight: 600; } /* Ensure bold works */
142
+ .markdown-content blockquote {
143
+ border-left: 4px solid var(--card-border);
144
+ padding-left: 1rem;
145
+ margin-left: 0;
146
+ margin-bottom: 1rem;
147
+ color: #a0aec0; /* Lighter text for quotes */
148
+ }
149
+ /* --- Code Block Styling --- */
150
+ .markdown-content pre {
151
+ position: relative;
152
+ background-color: var(--code-bg);
153
+ border: 1px solid var(--card-border);
154
+ border-radius: 0.5rem;
155
+ margin-bottom: 1rem;
156
+ font-size: 0.9em;
157
+ color: var(--code-text);
158
+ overflow: hidden; /* Hide horizontal overflow until hovered/focused */
159
+ }
160
+ .markdown-content pre code {
161
+ display: block;
162
+ padding: 1rem;
163
+ overflow-x: auto; /* Enable horizontal scroll on the code itself */
164
+ background: none !important; /* Override potential highlight.js background */
165
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
166
+ white-space: pre; /* Ensure whitespace is preserved */
167
+ }
168
+ /* --- Copy Button Styling --- */
169
+ .markdown-content pre .copy-code-btn {
170
  position: absolute;
171
+ top: 0.5rem;
172
+ right: 0.5rem;
173
+ background-color: var(--copy-btn-bg);
174
+ border: 1px solid var(--card-border);
175
+ color: var(--copy-btn-text);
176
+ padding: 0.3rem 0.6rem;
177
+ border-radius: 0.25rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  cursor: pointer;
179
+ opacity: 0; /* Initially hidden */
180
+ transition: opacity 0.2s, background-color 0.2s;
181
+ font-size: 0.8em;
182
+ display: flex; /* For icon alignment */
183
+ align-items: center;
184
+ gap: 0.25rem;
185
+ }
186
+ .markdown-content pre .copy-code-btn:hover {
187
+ background-color: var(--copy-btn-hover-bg);
188
+ }
189
+ .markdown-content pre:hover .copy-code-btn {
190
+ opacity: 1; /* Show on hover */
191
+ }
192
+ /* --- Inline Code Styling --- */
193
+ .markdown-content code:not(pre code) {
194
+ background-color: rgba(110, 118, 129, 0.4);
195
+ padding: 0.2em 0.4em;
196
+ margin: 0 0.1em; /* Add slight horizontal margin */
197
+ font-size: 85%;
198
+ border-radius: 6px;
199
+ font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
200
+ }
201
+ /* --- End Markdown Styling --- */
202
+
203
+ .tts-button-loader {
204
+ width: 16px;
205
+ height: 16px;
206
+ border: 2px solid currentColor;
207
+ border-radius: 50%;
208
+ display: inline-block;
209
+ box-sizing: border-box;
210
+ animation: rotation 0.8s linear infinite;
211
+ border-bottom-color: transparent;
212
+ }
213
+
214
+ .select-wrapper {
215
+ position: relative;
216
+ }
217
+ .select-wrapper select {
218
+ background-color: var(--select-bg);
219
+ border: 1px solid var(--select-border);
220
+ color: var(--select-text);
221
+ padding: 0.75rem 2.5rem 0.75rem 1rem;
222
+ border-radius: 0.75rem;
223
+ font-size: 0.875rem;
224
+ width: 100%;
225
+ appearance: none;
226
+ -webkit-appearance: none;
227
+ transition: all 0.2s ease-in-out;
228
+ cursor: pointer;
229
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%239ca3af' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
230
+ background-position: right 0.75rem center;
231
+ background-repeat: no-repeat;
232
+ background-size: 1.25em 1.25em;
233
+ }
234
+ /* Add styles for prose class if needed, tailwind typography plugin is good for this */
235
+ /* Example: .prose dark:prose-invert ... */
236
+ </style>
237
+ </head>
238
+ <body class="w-screen h-screen dark">
239
+ <main id="main-content" class="h-full flex flex-col transition-opacity duration-500">
240
+ <div id="chat-container" class="hidden flex-1 flex flex-col w-full mx-auto overflow-hidden">
241
+
242
+ <header class="p-4 border-b border-[var(--card-border)] flex-shrink-0 flex justify-between items-center w-full">
243
+ <div class="w-1/4"></div> <div class="w-1/2 text-center">
244
+ <h1 class="text-xl font-medium tracking-wide">CogniChat ✨</h1>
245
+ <p id="chat-filename" class="text-xs text-gray-400 mt-1 truncate"></p>
246
+ </div>
247
+ <div id="chat-session-info" class="w-1/4 text-right text-xs space-y-1 pr-4">
248
+ </div>
249
+ </header>
250
+
251
+ <div id="chat-window" class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-10">
252
+ <div id="chat-content" class="max-w-4xl mx-auto space-y-8"></div>
253
+ </div>
254
+ <div class="p-4 flex-shrink-0 bg-opacity-50 backdrop-blur-md border-t border-[var(--card-border)]">
255
+ <form id="chat-form" class="max-w-4xl mx-auto bg-[var(--card)] rounded-full p-2 flex items-center shadow-lg border border-[var(--card-border)] focus-within:ring-2 focus-within:ring-[var(--primary)] transition-all">
256
+ <input type="text" id="chat-input" placeholder="Ask a question about your documents..." class="flex-grow bg-transparent focus:outline-none px-4 text-sm" autocomplete="off">
257
+ <button type="submit" id="chat-submit-btn" class="bg-[var(--primary)] hover:bg-[var(--primary-hover)] text-white p-2.5 rounded-full transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" title="Send">
258
+ <svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z" clip-rule="evenodd"></path></svg>
259
+ </button>
260
+ </form>
261
+ </div>
262
+ </div>
263
+
264
+ <div id="upload-container" class="flex-1 flex flex-col items-center justify-center p-8 transition-opacity duration-300">
265
+ <div class="text-center max-w-xl w-full">
266
+ <h1 class="text-5xl font-bold mb-3 tracking-tight">CogniChat ✨</h1>
267
+ <p class="text-lg text-gray-400 mb-8">Upload your documents to start a conversation.</p>
268
+ <div class="mb-8 p-5 bg-[var(--card)] rounded-2xl border border-[var(--card-border)] shadow-lg">
269
+ <div class="flex flex-col sm:flex-row items-center gap-6">
270
+ <div class="w-full sm:w-1/2">
271
  <div class="flex items-center gap-2 mb-2">
272
+ <svg class="w-5 h-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" /></svg>
273
+ <label for="model-select" class="block text-sm font-medium text-gray-300">Model</label>
274
+ </div>
275
  <div class="select-wrapper">
276
+ <select id="model-select" name="model_name">
277
+ <option value="moonshotai/kimi-k2-instruct" selected>Kimi Instruct</option>
278
+ <option value="openai/gpt-oss-20b">GPT OSS 20b</option>
279
+ <option value="llama-3.3-70b-versatile">Llama 3.3 70b</option>
280
+ <option value="llama-3.1-8b-instant">Llama 3.1 8b Instant</option>
281
+ </select>
282
+ </div>
283
+ </div>
284
+ <div class="w-full sm:w-1/2">
285
+ <div class="flex items-center gap-2 mb-2">
286
+ <svg class="w-5 h-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.5 16a3.5 3.5 0 100-7 3.5 3.5 0 000 7zM12 5.5a3.5 3.5 0 11-7 0 3.5 3.5 0 017 0zM14.5 16a3.5 3.5 0 100-7 3.5 3.5 0 000 7z" clip-rule="evenodd" /></svg>
287
+ <label for="temperature-select" class="block text-sm font-medium text-gray-300">Mode</label>
288
+ </div>
289
+ <div class="select-wrapper">
290
+ <select id="temperature-select" name="temperature">
291
+ <option value="0.2" selected>0.2 - Precise</option>
292
+ <option value="0.4">0.4 - Confident</option>
293
+ <option value="0.6">0.6 - Balanced</option>
294
+ <option value="0.8">0.8 - Flexible</option>
295
+ <option value="1.0">1.0 - Creative</option>
296
+ </select>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ <p class="text-xs text-gray-500 mt-4 text-center">Higher creativity modes may reduce factual accuracy.</p>
301
+ </div>
302
+ <div id="drop-zone" class="w-full text-center border-2 border-dashed border-[var(--card-border)] rounded-2xl p-10 transition-all duration-300 cursor-pointer hover:bg-[var(--card)] hover:border-[var(--primary)]">
303
+ <div class="flex flex-col items-center justify-center pointer-events-none">
304
+ <svg class="mx-auto h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16.5V9.75m0 0l3-3m-3 3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"></path></svg>
305
+ <p class="mt-4 text-sm font-medium text-gray-400">Drag & drop files or <span class="text-[var(--primary)] font-semibold">click to upload</span></p>
306
+ <p class="text-xs text-gray-400 mt-1">Supports PDF, DOCX, TXT</p>
307
+ <p id="file-name" class="mt-2 text-xs text-gray-500"></p>
308
+ </div>
309
+ <input id="file-upload" type="file" class="hidden" accept=".pdf,.txt,.docx" multiple>
310
+ </div>
311
+ </div>
312
+ </div>
313
+
314
+ <div id="loading-overlay" class="hidden fixed inset-0 bg-[var(--background)] bg-opacity-80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
315
+ <div class="loader"></div>
316
+ <p id="loading-text" class="mt-6 text-sm font-medium"></p>
317
+ <p id="loading-subtext" class="mt-2 text-xs text-gray-400"></p>
318
+ </div>
319
+ </main>
320
+
321
+ <script>
322
+ document.addEventListener('DOMContentLoaded', () => {
323
+ const uploadContainer = document.getElementById('upload-container');
324
+ const chatContainer = document.getElementById('chat-container');
325
+ const dropZone = document.getElementById('drop-zone');
326
+ const fileUploadInput = document.getElementById('file-upload');
327
+ const fileNameSpan = document.getElementById('file-name');
328
+ const loadingOverlay = document.getElementById('loading-overlay');
329
+ const loadingText = document.getElementById('loading-text');
330
+ const loadingSubtext = document.getElementById('loading-subtext');
331
+ const chatForm = document.getElementById('chat-form');
332
+ const chatInput = document.getElementById('chat-input');
333
+ const chatSubmitBtn = document.getElementById('chat-submit-btn');
334
+ const chatWindow = document.getElementById('chat-window');
335
+ const chatContent = document.getElementById('chat-content');
336
+ const modelSelect = document.getElementById('model-select');
337
+ const temperatureSelect = document.getElementById('temperature-select');
338
+ const chatFilename = document.getElementById('chat-filename');
339
+ const chatSessionInfo = document.getElementById('chat-session-info');
340
+
341
+ let sessionId = sessionStorage.getItem('cognichat_session_id');
342
+ let currentModelInfo = JSON.parse(sessionStorage.getItem('cognichat_model_info')); // Load model info
343
+
344
+ // --- Initialize Marked.js options ---
345
+ marked.setOptions({
346
+ breaks: true, // Convert single line breaks to <br>
347
+ gfm: true, // Enable GitHub Flavored Markdown
348
+ // Optional: Add highlight.js integration if library is loaded
349
+ // highlight: function(code, lang) {
350
+ // const language = hljs.getLanguage(lang) ? lang : 'plaintext';
351
+ // return hljs.highlight(code, { language }).value;
352
+ // }
353
+ });
354
+
355
+ // --- Restore Chat State if Session Exists ---
356
+ if (sessionId && currentModelInfo) {
357
+ console.log("Restoring session:", sessionId);
358
+ // Fetch chat history if needed or just show the chat interface
359
+ // For now, just show the chat interface and header info
360
+ uploadContainer.classList.add('hidden');
361
+ chatContainer.classList.remove('hidden');
362
+ // You might want to fetch and display previous messages here later
363
+ chatFilename.innerHTML = `Chatting with: <strong class="font-semibold">${sessionStorage.getItem('cognichat_filename') || 'documents'}</strong>`;
364
+ chatFilename.title = sessionStorage.getItem('cognichat_filename') || 'documents';
365
+ chatSessionInfo.innerHTML = `
366
+ <p>Model: ${currentModelInfo.simpleModelName}</p>
367
+ <p>Mode: ${currentModelInfo.mode}</p>
368
+ <button class="mt-1 text-xs text-blue-400 hover:text-blue-300 focus:outline-none" onclick="sessionStorage.clear(); location.reload();">New Chat</button>`;
369
+ }
370
+
371
+
372
+ // --- File Upload Logic ---
373
+ dropZone.addEventListener('click', () => fileUploadInput.click());
374
+
375
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
376
+ dropZone.addEventListener(eventName, e => {e.preventDefault(); e.stopPropagation();}, false);
377
+ document.body.addEventListener(eventName, e => {e.preventDefault(); e.stopPropagation();}, false);
378
+ });
379
+ ['dragenter', 'dragover'].forEach(eventName => dropZone.addEventListener(eventName, () => dropZone.classList.add('drop-zone--over')));
380
+ ['dragleave', 'drop'].forEach(eventName => dropZone.addEventListener(eventName, () => dropZone.classList.remove('drop-zone--over')));
381
+
382
+ dropZone.addEventListener('drop', (e) => {
383
+ if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
384
+ });
385
+ fileUploadInput.addEventListener('change', (e) => {
386
+ if (e.target.files.length > 0) handleFiles(e.target.files);
387
+ });
388
+
389
+ async function handleFiles(files) {
390
+ const formData = new FormData();
391
+ let fileNames = Array.from(files).map(f => f.name);
392
+ for (const file of files) { formData.append('file', file); }
393
+
394
+ formData.append('model_name', modelSelect.value);
395
+ formData.append('temperature', temperatureSelect.value);
396
+
397
+ fileNameSpan.textContent = `Selected: ${fileNames.join(', ')}`;
398
+ await uploadAndProcessFiles(formData);
399
+ }
400
+
401
+ async function uploadAndProcessFiles(formData) {
402
+ loadingOverlay.classList.remove('hidden');
403
+ loadingText.textContent = `Processing document(s)...`;
404
+ loadingSubtext.textContent = "Creating a knowledge base... this might take a minute 🧠";
405
+ chatContent.innerHTML = ''; // Clear previous chat content on new upload
406
+
407
+ try {
408
+ const response = await fetch('/upload', { method: 'POST', body: formData });
409
+ const result = await response.json();
410
+ if (!response.ok) throw new Error(result.message || 'Unknown error occurred during upload.');
411
+
412
+ sessionId = result.session_id;
413
+ sessionStorage.setItem('cognichat_session_id', sessionId);
414
+
415
+ const modelOption = modelSelect.querySelector(`option[value="${result.model_name}"]`);
416
+ const simpleModelName = modelOption ? modelOption.textContent : result.model_name; // Adjust if needed
417
+
418
+ currentModelInfo = {
419
+ model: result.model_name,
420
+ mode: result.mode,
421
+ simpleModelName: simpleModelName // Use the derived simpler name
422
+ };
423
+ sessionStorage.setItem('cognichat_model_info', JSON.stringify(currentModelInfo)); // Store model info
424
+ sessionStorage.setItem('cognichat_filename', result.filename); // Store filename
425
+
426
+ chatFilename.innerHTML = `Chatting with: <strong class="font-semibold">${result.filename}</strong>`;
427
+ chatFilename.title = result.filename;
428
+
429
+ chatSessionInfo.innerHTML = `
430
+ <p>Model: ${currentModelInfo.simpleModelName}</p>
431
+ <p>Mode: ${currentModelInfo.mode}</p>
432
+ <button class="mt-1 text-xs text-blue-400 hover:text-blue-300 focus:outline-none" onclick="sessionStorage.clear(); location.reload();">New Chat</button>`;
433
+
434
+ uploadContainer.classList.add('hidden');
435
+ chatContainer.classList.remove('hidden');
436
+ appendMessage("Hello! 👋 I've analyzed your documents. What would you like to know?", "bot", currentModelInfo);
437
+
438
+ } catch (error) {
439
+ console.error('Upload error:', error);
440
+ alert(`Error processing files: ${error.message}`);
441
+ sessionStorage.clear(); // Clear session if upload fails
442
+ } finally {
443
+ loadingOverlay.classList.add('hidden');
444
+ fileNameSpan.textContent = '';
445
+ fileUploadInput.value = '';
446
+ }
447
+ }
448
+
449
+ // --- Chat Logic (Using Server-Sent Events - UPDATED FOR STREAMING & INDICATOR) ---
450
+ chatForm.addEventListener('submit', async (e) => {
451
+ e.preventDefault();
452
+ const question = chatInput.value.trim();
453
+ if (!question || !sessionId) {
454
+ console.warn("Submit ignored: No question or session ID.");
455
+ return;
456
+ }
457
+
458
+ appendMessage(question, 'user');
459
+ chatInput.value = '';
460
+ chatInput.disabled = true;
461
+ chatSubmitBtn.disabled = true;
462
+
463
+ let botMessageContainer;
464
+ let contentDiv;
465
+ let fullResponse = '';
466
+ let eventSource = null;
467
+ let inactivityTimeout = null;
468
+ let streamClosedCleanly = false; // Flag to check if stream ended normally vs error
469
+ let typingIndicatorElement = null; // Store indicator element
470
+
471
+ // Function to finalize chat (called on error, timeout, or successful completion)
472
+ function finalizeChat(isError = false) {
473
+ console.log(`Finalizing chat. Was error: ${isError}, Stream ended cleanly: ${streamClosedCleanly}`);
474
+ if (eventSource) {
475
+ eventSource.close();
476
+ eventSource = null;
477
+ console.log("SSE connection explicitly closed in finalizeChat.");
478
+ }
479
+ if (inactivityTimeout) {
480
+ clearTimeout(inactivityTimeout);
481
+ inactivityTimeout = null;
482
+ }
483
+ // Remove indicator if it's still there
484
+ if (typingIndicatorElement && typingIndicatorElement.parentNode) {
485
+ typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
486
+ typingIndicatorElement = null;
487
+ }
488
+
489
+
490
+ if (botMessageContainer && contentDiv) {
491
+ const hasErrorMsg = contentDiv.innerHTML.includes('⚠️');
492
+ // Ensure final render, apply copy buttons and TTS ONLY if response wasn't an error
493
+ if (!hasErrorMsg && fullResponse) {
494
+ // Re-parse the complete response to ensure correct final Markdown
495
+ contentDiv.innerHTML = marked.parse(fullResponse);
496
+ // Apply final touches like copy buttons and TTS
497
+ contentDiv.querySelectorAll('pre').forEach(addCopyButton);
498
+ addTextToSpeechControls(botMessageContainer, fullResponse);
499
+ // Optional: Final highlighting if using highlight.js
500
+ // contentDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
501
+ }
502
+ scrollToBottom(true); // Ensure scrolled to the end
503
+ }
504
+
505
+ // Always re-enable input fields
506
+ chatInput.disabled = false;
507
+ chatSubmitBtn.disabled = false;
508
+ chatInput.focus();
509
+ }
510
+
511
+ try {
512
+ // Create the bot message container *before* starting the stream
513
+ botMessageContainer = appendMessage('', 'bot', currentModelInfo); // Append empty bot message
514
+ contentDiv = botMessageContainer.querySelector('.markdown-content');
515
+
516
+ // Show typing indicator *inside* the contentDiv
517
+ typingIndicatorElement = showTypingIndicator();
518
+ if (contentDiv) {
519
+ contentDiv.appendChild(typingIndicatorElement);
520
+ scrollToBottom(true); // Scroll to show indicator
521
+ } else {
522
+ console.error("Could not find contentDiv to append typing indicator.");
523
+ }
524
+
525
+
526
+ // Establish SSE connection via GET request
527
+ const chatUrl = `/chat?question=${encodeURIComponent(question)}&session_id=${encodeURIComponent(sessionId)}`;
528
+ console.log("Connecting to SSE:", chatUrl);
529
+ eventSource = new EventSource(chatUrl);
530
+
531
+ eventSource.onopen = () => {
532
+ console.log("SSE Connection opened.");
533
+ // Remove indicator when connection opens and stream is about to start
534
+ if (typingIndicatorElement && typingIndicatorElement.parentNode) {
535
+ typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
536
+ typingIndicatorElement = null;
537
+ }
538
+ streamClosedCleanly = false; // Reset flag on new connection
539
+ };
540
+
541
+ eventSource.onmessage = (event) => {
542
+ // Remove indicator on first message just in case onopen didn't fire reliably
543
+ if (typingIndicatorElement && typingIndicatorElement.parentNode) {
544
+ typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
545
+ typingIndicatorElement = null;
546
+ }
547
+
548
+ // Reset inactivity timeout on each message
549
+ if (inactivityTimeout) clearTimeout(inactivityTimeout);
550
+ inactivityTimeout = setTimeout(() => {
551
+ console.log("Inactivity timeout triggered after message.");
552
+ streamClosedCleanly = true; // Assume normal end
553
+ finalizeChat(false);
554
+ }, 5000); // 5 seconds of inactivity
555
+
556
+ let data;
557
+ try {
558
+ data = JSON.parse(event.data);
559
+ } catch (parseError){
560
+ console.error("Failed to parse SSE data:", event.data, parseError);
561
+ contentDiv.innerHTML += `<p class="text-red-400 text-sm">Error receiving data chunk.</p>`;
562
+ return;
563
+ }
564
+
565
+ if (data.error) {
566
+ console.error('SSE Error from server:', data.error);
567
+ contentDiv.innerHTML = `<p class="text-red-500 font-semibold">⚠️ Server Error: ${data.error}</p>`;
568
+ streamClosedCleanly = false;
569
+ finalizeChat(true); // Pass true for error
570
+ return;
571
+ }
572
+
573
+ if (data.token !== undefined && data.token !== null) {
574
+ fullResponse += data.token;
575
+ // Update content by parsing the accumulated response
576
+ contentDiv.innerHTML = marked.parse(fullResponse);
577
+ scrollToBottom(); // Scroll smoothly as content arrives
578
+ }
579
+ };
580
+
581
+ eventSource.onerror = (error) => {
582
+ console.error('SSE connection error event:', error);
583
+ // Remove indicator on error
584
+ if (typingIndicatorElement && typingIndicatorElement.parentNode) {
585
+ typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
586
+ typingIndicatorElement = null;
587
+ }
588
+ // Don't show generic error if we received data and the stream likely just closed normally
589
+ if (!fullResponse && !streamClosedCleanly) { // Only show error if nothing received AND not already cleanly closed
590
+ const errorMsg = "⚠️ Connection error. Please try again.";
591
+ if (contentDiv) {
592
+ contentDiv.innerHTML = `<p class="text-red-500 font-semibold">${errorMsg}</p>`;
593
+ } else {
594
+ // Fallback if container wasn't created somehow
595
+ appendMessage(errorMsg, 'bot', currentModelInfo); // Pass model info here too
596
+ }
597
+ streamClosedCleanly = false;
598
+ } else if (!streamClosedCleanly) {
599
+ // If we received data, assume it's a normal closure misinterpreted as error
600
+ console.log("SSE connection closed (likely normal end detected by onerror).");
601
+ streamClosedCleanly = true; // Mark as clean closure NOW
602
+ } else {
603
+ console.log("SSE onerror event after stream already marked cleanly closed.")
604
+ }
605
+ finalizeChat(!streamClosedCleanly); // Finalize, indicate error if not clean
606
+ };
607
+
608
+ } catch (error) {
609
+ // For setup errors before SSE starts
610
+ console.error('Chat setup error:', error);
611
+ // Remove indicator on setup error
612
+ if (typingIndicatorElement && typingIndicatorElement.parentNode) {
613
+ typingIndicatorElement.parentNode.removeChild(typingIndicatorElement);
614
+ typingIndicatorElement = null;
615
+ }
616
+ if (botMessageContainer && contentDiv) {
617
+ contentDiv.innerHTML = `<p class="text-red-500 font-semibold">⚠️ Error starting chat: ${error.message}</p>`;
618
+ } else {
619
+ appendMessage(`Error starting chat: ${error.message}`, 'bot', currentModelInfo); // Pass model info
620
+ }
621
+ finalizeChat(true);
622
+ }
623
+ });
624
+
625
+
626
+ // --- UI Helper Functions ---
627
+
628
+ function appendMessage(text, sender, modelInfo = null) {
629
+ const messageWrapper = document.createElement('div');
630
+ const iconSVG = sender === 'user'
631
+ ? `<div class="bg-blue-200 dark:bg-gray-700 p-2.5 rounded-full flex-shrink-0 mt-1 self-start"><svg class="w-5 h-5 text-blue-700 dark:text-blue-300" viewBox="0 0 24 24"><path fill="currentColor" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"></path></svg></div>`
632
+ : `<div class="bg-gray-200 dark:bg-gray-700 rounded-full flex-shrink-0 mt-1 self-start text-xl flex items-center justify-center w-10 h-10">✨</div>`;
633
+
634
+ let senderHTML;
635
+ if (sender === 'user') {
636
+ senderHTML = '<p class="font-medium text-sm mb-1">You</p>';
637
+ } else {
638
+ let modelInfoHTML = '';
639
+ const displayInfo = modelInfo || currentModelInfo;
640
+ if (displayInfo && displayInfo.simpleModelName) {
641
+ modelInfoHTML = `
642
+ <span class="ml-2 text-xs font-normal text-gray-400">
643
+ (Model: ${displayInfo.simpleModelName} | Mode: ${displayInfo.mode})
644
+ </span>
645
+ `;
646
+ }
647
+ senderHTML = `<div class="font-medium text-sm mb-1 flex items-center">CogniChat ${modelInfoHTML}</div>`;
648
+ }
649
+
650
+ messageWrapper.className = `flex items-start gap-3`;
651
+ // Ensure markdown-content div exists even if text is empty for the indicator
652
+ messageWrapper.innerHTML = `
653
+ ${iconSVG}
654
+ <div class="flex-1 pt-1 min-w-0"> ${senderHTML}
655
+ <div class="text-base markdown-content prose dark:prose-invert max-w-none">${text ? marked.parse(text) : ''}</div>
656
+ <div class="tts-controls mt-2"></div>
657
+ </div>
658
+ `;
659
+ chatContent.appendChild(messageWrapper);
660
+ // Force scroll only when adding user message or initial bot message with content
661
+ if (sender === 'user' || text) {
662
+ scrollToBottom(true);
663
+ }
664
+ // Return the container that holds the sender name and content div
665
+ return messageWrapper.querySelector('.flex-1');
666
+ }
667
+
668
+ // --- UPDATED showTypingIndicator ---
669
+ function showTypingIndicator() {
670
+ const indicator = document.createElement('div');
671
+ indicator.className = 'typing-indicator'; // Use the main class
672
+ indicator.innerHTML = '<span></span><span></span><span></span>';
673
+ // Don't append here, just return the element
674
+ return indicator;
675
+ }
676
+ // --- End UPDATED showTypingIndicator ---
677
+
678
+
679
+ function scrollToBottom(force = false) {
680
+ const isNearBottom = chatWindow.scrollHeight - chatWindow.clientHeight <= chatWindow.scrollTop + 150; // Threshold
681
+
682
+ if (force || isNearBottom) {
683
+ requestAnimationFrame(() => { // Use rAF for smoother render loop
684
+ chatWindow.scrollTo({
685
+ top: chatWindow.scrollHeight,
686
+ behavior: 'smooth'
687
+ });
688
+ });
689
+ }
690
+ }
691
+
692
+ function addCopyButton(pre) {
693
+ if (pre.querySelector('.copy-code-btn')) return;
694
+
695
+ const button = document.createElement('button');
696
+ // Updated classes for better styling
697
+ button.className = 'copy-code-btn absolute top-2 right-2 p-1 rounded bg-[var(--copy-btn-bg)] text-[var(--copy-btn-text)] hover:bg-[var(--copy-btn-hover-bg)] transition-opacity duration-200 flex items-center gap-1 text-xs';
698
+ button.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> Copy`;
699
+ pre.style.position = 'relative'; // Ensure parent is relative for absolute positioning
700
+ pre.appendChild(button);
701
+
702
+ button.addEventListener('click', () => {
703
+ const code = pre.querySelector('code')?.innerText || '';
704
+ navigator.clipboard.writeText(code)
705
+ .then(() => {
706
+ button.textContent = 'Copied!';
707
+ setTimeout(() => button.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> Copy`, 1500);
708
+ })
709
+ .catch(err => {
710
+ console.error('Failed to copy code: ', err);
711
+ button.textContent = 'Error';
712
+ setTimeout(() => button.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> Copy`, 1500);
713
+ });
714
+ });
715
+ }
716
+
717
+ // --- TTS Functions ---
718
+ let currentAudio = null;
719
+ let currentPlayingButton = null;
720
+ const playIconSVG = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"></path></svg>`;
721
+ const pauseIconSVG = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M5.75 4.75a.75.75 0 00-.75.75v9.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75v-9.5a.75.75 0 00-.75-.75h-1.5zm6.5 0a.75.75 0 00-.75.75v9.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75v-9.5a.75.75 0 00-.75-.75h-1.5z"></path></svg>`;
722
+
723
+ function addTextToSpeechControls(messageBubble, text) {
724
+ if (!text || !text.trim()) return;
725
+ // Ensure messageBubble is the correct container (the one returned by appendMessage)
726
+ const ttsControls = messageBubble.querySelector('.tts-controls');
727
+ if (!ttsControls || ttsControls.querySelector('.speak-btn')) return;
728
+
729
+ const speakButton = document.createElement('button');
730
+ speakButton.className = 'speak-btn mt-2 px-3 py-1.5 bg-blue-700 text-white rounded-full text-xs font-medium hover:bg-blue-800 transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed';
731
+ speakButton.title = 'Listen to this message';
732
+ speakButton.innerHTML = `${playIconSVG} <span>Listen</span>`;
733
+ ttsControls.appendChild(speakButton);
734
+ speakButton.addEventListener('click', () => handleTTS(text, speakButton));
735
+ }
736
+
737
+ async function handleTTS(text, button) {
738
+ if (!text || !text.trim()) return;
739
+
740
+ if (button === currentPlayingButton) {
741
+ if (currentAudio && !currentAudio.paused) {
742
+ currentAudio.pause();
743
+ button.innerHTML = `${playIconSVG} <span>Listen</span>`;
744
+ } else if (currentAudio && currentAudio.paused) {
745
+ currentAudio.play().catch(e => {console.error("Audio resume error:", e); resetAllSpeakButtons();});
746
+ button.innerHTML = `${pauseIconSVG} <span>Pause</span>`;
747
+ }
748
+ return;
749
+ }
750
+
751
+ resetAllSpeakButtons();
752
+ currentPlayingButton = button;
753
+ button.innerHTML = `<div class="tts-button-loader mr-1"></div> <span>Loading...</span>`;
754
+ button.disabled = true;
755
+
756
+ try {
757
+ const response = await fetch('/tts', {
758
+ method: 'POST',
759
+ headers: { 'Content-Type': 'application/json' },
760
+ body: JSON.stringify({ text: text })
761
+ });
762
+ if (!response.ok) throw new Error(`TTS generation failed (${response.status})`);
763
+ const blob = await response.blob();
764
+ if (!blob || blob.size === 0) throw new Error("Received empty audio blob.");
765
+
766
+ const audioUrl = URL.createObjectURL(blob);
767
+ currentAudio = new Audio(audioUrl);
768
+
769
+ await currentAudio.play();
770
+ button.innerHTML = `${pauseIconSVG} <span>Pause</span>`;
771
+ button.disabled = false;
772
+
773
+ currentAudio.onended = () => {
774
+ if (button === currentPlayingButton) resetAllSpeakButtons();
775
+ };
776
+ currentAudio.onerror = (e) => {
777
+ console.error('Audio object error:', e);
778
+ alert('Error playing audio.');
779
+ resetAllSpeakButtons();
780
+ };
781
+
782
+ } catch (error) {
783
+ console.error('TTS Handling Error:', error);
784
+ alert(`Failed to play audio: ${error.message}`);
785
+ resetAllSpeakButtons();
786
+ }
787
+ }
788
+
789
+ function resetAllSpeakButtons() {
790
+ document.querySelectorAll('.speak-btn').forEach(resetSpecificButton);
791
+ if (currentAudio) {
792
+ currentAudio.pause();
793
+ currentAudio.onended = null;
794
+ currentAudio.onerror = null;
795
+ // It's generally safer to let the browser manage object URLs unless memory becomes an issue
796
+ // If needed: URL.revokeObjectURL(currentAudio.src);
797
+ currentAudio = null;
798
+ }
799
+ currentPlayingButton = null;
800
+ }
801
+
802
+ function resetSpecificButton(btn){
803
+ btn.innerHTML = `${playIconSVG} <span>Listen</span>`;
804
+ btn.disabled = false;
805
+ btn.removeAttribute('data-state');
806
+ }
807
+ // --- End of TTS Functions ---
808
+
809
+ // Optional: Initialize highlight.js after DOM load if using
810
+ // if (typeof hljs !== 'undefined') {
811
+ // document.addEventListener('DOMContentLoaded', (event) => {
812
+ // document.querySelectorAll('pre code').forEach((block) => {
813
+ // hljs.highlightElement(block);
814
+ // });
815
+ // });
816
+ // }
817
+
818
+ });
819
+ </script>
820
+ </body>
821
+ </html>