riteshraut commited on
Commit
b4b270e
Β·
1 Parent(s): ba4d135

Add Dockerfile and final setup for Hugging Face deployment

Browse files
Files changed (3) hide show
  1. app.py +165 -56
  2. rag_processor.py +95 -99
  3. templates/index.html +200 -56
app.py CHANGED
@@ -1,100 +1,209 @@
 
 
1
  import os
 
 
2
  from flask import Flask, request, render_template, session, jsonify, Response, stream_with_context
3
  from werkzeug.utils import secure_filename
4
  from rag_processor import create_rag_chain
5
- import time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  # --- Basic Flask App Setup ---
8
  app = Flask(__name__)
9
- # A secret key is needed for session management
10
- app.config['SECRET_KEY'] = os.urandom(24)
11
- # Configure the upload folder
12
  app.config['UPLOAD_FOLDER'] = 'uploads'
13
- # Ensure the upload folder exists
14
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
15
 
16
- # In-memory storage for RAG chains to avoid re-creating them on every request.
17
- # In a production scenario, you might want a more persistent cache like Redis.
18
  rag_chains = {}
 
19
 
20
- @app.route('/', methods=['GET'])
21
- def index():
 
 
 
 
 
 
 
 
 
 
 
 
22
  """
23
- Renders the main page.
 
24
  """
 
 
 
 
 
 
 
25
  return render_template('index.html')
26
 
27
  @app.route('/upload', methods=['POST'])
28
- def upload_file():
29
- """
30
- Handles file uploads and processing.
31
- """
32
- if 'file' not in request.files:
33
- return jsonify({'status': 'error', 'message': 'No file part in the request.'}), 400
34
-
35
- file = request.files['file']
 
36
 
37
- if file.filename == '':
38
- return jsonify({'status': 'error', 'message': 'No selected file.'}), 400
39
-
40
- if file:
41
- filename = secure_filename(file.filename)
42
- filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
43
- file.save(filepath)
44
 
45
- try:
46
- # --- RAG Chain Creation ---
47
- print(f"Creating RAG chain for {filename}...")
48
- # Simulate a delay for demonstration purposes of the loading animation
49
- time.sleep(2)
50
- rag_chains[filename] = create_rag_chain(filepath)
51
- print("RAG chain created successfully.")
52
 
53
- # Store the filename in the user's session
54
- session['filename'] = filename
55
-
56
- return jsonify({'status': 'success', 'filename': filename})
 
 
 
 
 
 
 
57
 
58
- except Exception as e:
59
- print(f"Error creating RAG chain: {e}")
60
- if os.path.exists(filepath):
61
- os.remove(filepath)
62
- return jsonify({'status': 'error', 'message': f'Failed to process file: {str(e)}'}), 500
63
 
64
- return jsonify({'status': 'error', 'message': 'An unexpected error occurred.'}), 500
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  @app.route('/chat', methods=['POST'])
68
  def chat():
69
- """
70
- Handles chat messages from the user and streams the response.
71
- """
72
  data = request.get_json()
73
  question = data.get('question')
74
- filename = session.get('filename')
75
 
76
- if not question:
77
- return jsonify({'status': 'error', 'message': 'Question is missing.'}), 400
78
 
79
- if not filename or filename not in rag_chains:
80
- return jsonify({'status': 'error', 'message': 'File not uploaded or processed yet.'}), 400
81
 
82
  try:
83
- rag_chain = rag_chains[filename]
 
84
 
85
  def generate():
86
  """A generator function to stream the response."""
87
- for chunk in rag_chain.stream(question):
88
  yield chunk
89
 
90
- # Use stream_with_context to ensure the generator has access to the request context
91
  return Response(stream_with_context(generate()), mimetype='text/plain')
92
 
93
  except Exception as e:
94
  print(f"Error during chat invocation: {e}")
95
- # This error won't be sent as a stream, handle appropriately
96
  return Response("An error occurred while getting the answer.", status=500, mimetype='text/plain')
97
 
98
- if __name__ == '__main__':
99
- app.run(debug=True, port=5001)
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+
3
  import os
4
+ import time
5
+ import uuid
6
  from flask import Flask, request, render_template, session, jsonify, Response, stream_with_context
7
  from werkzeug.utils import secure_filename
8
  from rag_processor import create_rag_chain
9
+
10
+ # ============================ ADDITIONS START ============================
11
+ from gtts import gTTS
12
+ import io
13
+ import re # <-- Import the regular expression module
14
+ # ============================ ADDITIONS END ==============================
15
+
16
+ # Document Loaders
17
+ from langchain_community.document_loaders import (
18
+ TextLoader,
19
+ UnstructuredPDFLoader,
20
+ Docx2txtLoader,
21
+ UnstructuredImageLoader,
22
+ )
23
+
24
+ # Text Splitter, Embeddings, Retrievers
25
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
26
+ from langchain_huggingface import HuggingFaceEmbeddings
27
+ from langchain_community.vectorstores import FAISS
28
+ from langchain.retrievers import EnsembleRetriever
29
+ from langchain_community.retrievers import BM25Retriever
30
+ from langchain_community.chat_message_histories import ChatMessageHistory
31
 
32
  # --- Basic Flask App Setup ---
33
  app = Flask(__name__)
34
+ app.config['SECRET_KEY'] = os.urandom(24)
 
 
35
  app.config['UPLOAD_FOLDER'] = 'uploads'
 
36
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
37
 
38
+ # --- In-memory Storage & Global Model Loading ---
 
39
  rag_chains = {}
40
+ message_histories = {}
41
 
42
+ # Load the embedding model once when the application starts for efficiency.
43
+ print("Loading embedding model...")
44
+ EMBEDDING_MODEL = HuggingFaceEmbeddings(model_name="all-miniLM-L6-v2")
45
+ print("Embedding model loaded successfully.")
46
+
47
+ # A dictionary to map file extensions to their corresponding loader classes
48
+ LOADER_MAPPING = {
49
+ ".txt": TextLoader,
50
+ ".pdf": UnstructuredPDFLoader,
51
+ ".docx": Docx2txtLoader,
52
+ ".jpeg": UnstructuredImageLoader, ".jpg": UnstructuredImageLoader, ".png": UnstructuredImageLoader,
53
+ }
54
+
55
+ def get_session_history(session_id: str) -> ChatMessageHistory:
56
  """
57
+ Retrieves the chat history for a given session ID. If it doesn't exist,
58
+ a new history object is created.
59
  """
60
+ if session_id not in message_histories:
61
+ message_histories[session_id] = ChatMessageHistory()
62
+ return message_histories[session_id]
63
+
64
+ @app.route('/', methods=['GET'])
65
+ def index():
66
+ """Renders the main page."""
67
  return render_template('index.html')
68
 
69
  @app.route('/upload', methods=['POST'])
70
+ def upload_files():
71
+ """Handles multiple file uploads, processing, and RAG chain creation."""
72
+ files = request.files.getlist('file')
73
+
74
+ if not files or all(f.filename == '' for f in files):
75
+ return jsonify({'status': 'error', 'message': 'No selected files.'}), 400
76
+
77
+ all_docs = []
78
+ all_filenames = []
79
 
80
+ try:
81
+ print(f"Processing {len(files)} files...")
 
 
 
 
 
82
 
83
+ for file in files:
84
+ if file and file.filename:
85
+ filename = secure_filename(file.filename)
86
+ all_filenames.append(filename)
87
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
88
+ file.save(filepath)
 
89
 
90
+ file_extension = os.path.splitext(filename)[1].lower()
91
+ if file_extension not in LOADER_MAPPING:
92
+ print(f"Skipping unsupported file type: {filename}")
93
+ continue
94
+
95
+ loader_class = LOADER_MAPPING[file_extension]
96
+ loader_kwargs = {}
97
+ if file_extension in [".jpeg", ".jpg", ".png"]:
98
+ loader_kwargs['mode'] = 'single'
99
+ if file_extension == ".pdf":
100
+ loader_kwargs['languages'] = ['eng']
101
 
102
+ loader = loader_class(filepath, **loader_kwargs)
103
+ all_docs.extend(loader.load())
 
 
 
104
 
105
+ if not all_docs:
106
+ return jsonify({'status': 'error', 'message': 'No processable files were uploaded.'}), 400
107
 
108
+ # --- Process all documents together ---
109
+ text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
110
+ splits = text_splitter.split_documents(all_docs)
111
+
112
+ print("Creating vector store for all documents...")
113
+ vectorstore = FAISS.from_documents(documents=splits, embedding=EMBEDDING_MODEL)
114
+
115
+ bm25_retriever = BM25Retriever.from_documents(splits)
116
+ bm25_retriever.k = 5
117
+ faiss_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
118
+ ensemble_retriever = EnsembleRetriever(
119
+ retrievers=[bm25_retriever, faiss_retriever],
120
+ weights=[0.5, 0.5]
121
+ )
122
+
123
+ session_id = str(uuid.uuid4())
124
+ rag_chains[session_id] = create_rag_chain(ensemble_retriever, get_session_history)
125
+ print(f"RAG chain created for session {session_id} with {len(all_filenames)} documents.")
126
+
127
+ session['session_id'] = session_id
128
+
129
+ display_filenames = ", ".join(all_filenames)
130
+ return jsonify({'status': 'success', 'filename': display_filenames})
131
+
132
+ except Exception as e:
133
+ print(f"Error creating RAG chain: {e}")
134
+ return jsonify({'status': 'error', 'message': f'Failed to process files: {str(e)}'}), 500
135
 
136
  @app.route('/chat', methods=['POST'])
137
  def chat():
138
+ """Handles chat messages and streams the response with memory."""
 
 
139
  data = request.get_json()
140
  question = data.get('question')
141
+ session_id = session.get('session_id')
142
 
143
+ if not all([question, session_id]):
144
+ return jsonify({'status': 'error', 'message': 'Missing data in request.'}), 400
145
 
146
+ if session_id not in rag_chains:
147
+ return jsonify({'status': 'error', 'message': 'Session not found. Please upload documents again.'}), 400
148
 
149
  try:
150
+ rag_chain = rag_chains[session_id]
151
+ config = {"configurable": {"session_id": session_id}}
152
 
153
  def generate():
154
  """A generator function to stream the response."""
155
+ for chunk in rag_chain.stream({"question": question, "config": config}):
156
  yield chunk
157
 
 
158
  return Response(stream_with_context(generate()), mimetype='text/plain')
159
 
160
  except Exception as e:
161
  print(f"Error during chat invocation: {e}")
 
162
  return Response("An error occurred while getting the answer.", status=500, mimetype='text/plain')
163
 
164
+ # ============================ ADDITIONS START ============================
 
165
 
166
+ def clean_markdown_for_tts(text: str) -> str:
167
+ """Removes markdown formatting for cleaner text-to-speech output."""
168
+ # Remove bold (**text**) and italics (*text* or _text_)
169
+ text = re.sub(r'\*(\*?)(.*?)\1\*', r'\2', text)
170
+ text = re.sub(r'\_(.*?)\_', r'\1', text)
171
+ # Remove inline code (`code`)
172
+ text = re.sub(r'`(.*?)`', r'\1', text)
173
+ # Remove headings (e.g., #, ##, ###)
174
+ text = re.sub(r'^\s*#{1,6}\s+', '', text, flags=re.MULTILINE)
175
+ # Remove list item markers (*, -, 1.)
176
+ text = re.sub(r'^\s*[\*\-]\s+', '', text, flags=re.MULTILINE)
177
+ text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
178
+ # Remove blockquotes (>)
179
+ text = re.sub(r'^\s*>\s?', '', text, flags=re.MULTILINE)
180
+ # Replace multiple newlines with a single space
181
+ text = re.sub(r'\n+', ' ', text)
182
+ return text.strip()
183
+
184
+ @app.route('/tts', methods=['POST'])
185
+ def text_to_speech():
186
+ """Generates audio from text and returns it as an MP3 stream."""
187
+ data = request.get_json()
188
+ text = data.get('text')
189
+
190
+ if not text:
191
+ return jsonify({'status': 'error', 'message': 'No text provided.'}), 400
192
+
193
+ try:
194
+ # --- FIX IS HERE: Clean the text before sending to gTTS ---
195
+ clean_text = clean_markdown_for_tts(text)
196
+
197
+ tts = gTTS(clean_text, lang='en')
198
+ mp3_fp = io.BytesIO()
199
+ tts.write_to_fp(mp3_fp)
200
+ mp3_fp.seek(0)
201
+ return Response(mp3_fp, mimetype='audio/mpeg')
202
+ except Exception as e:
203
+ print(f"Error in TTS generation: {e}")
204
+ return jsonify({'status': 'error', 'message': 'Failed to generate audio.'}), 500
205
+ # ============================ ADDITIONS END ==============================
206
+
207
+
208
+ if __name__ == '__main__':
209
+ app.run(debug=True, port=5001)
rag_processor.py CHANGED
@@ -1,55 +1,34 @@
 
 
1
  import os
2
  from dotenv import load_dotenv
3
-
4
- # Document Loaders
5
- from langchain_community.document_loaders import (
6
- TextLoader,
7
- PyPDFLoader,
8
- Docx2txtLoader,
9
- UnstructuredImageLoader,
10
- )
11
-
12
- # Text Splitter
13
- from langchain.text_splitter import RecursiveCharacterTextSplitter
14
-
15
- # Embeddings
16
- from langchain_huggingface import HuggingFaceEmbeddings
17
-
18
- # Vector Stores
19
- from langchain_community.vectorstores import FAISS
20
 
21
  # LLM
22
  from langchain_groq import ChatGroq
23
 
24
  # Prompting
25
- from langchain.prompts import PromptTemplate
26
 
27
  # Chains
28
  from langchain_core.runnables import RunnableParallel, RunnablePassthrough
29
  from langchain_core.output_parsers import StrOutputParser
 
30
 
31
- # A dictionary to map file extensions to their corresponding loader classes
32
- LOADER_MAPPING = {
33
- ".txt": TextLoader,
34
- ".pdf": PyPDFLoader,
35
- ".docx": Docx2txtLoader,
36
- ".jpeg": UnstructuredImageLoader,
37
- ".jpg": UnstructuredImageLoader,
38
- ".png": UnstructuredImageLoader,
39
- }
40
-
41
- def create_rag_chain(filepath):
42
  """
43
- Creates a Retrieval-Augmented Generation (RAG) chain from a given file path.
 
44
 
45
  Args:
46
- filepath (str): The path to the document file.
 
47
 
48
  Returns:
49
- A LangChain runnable object representing the RAG chain.
50
 
51
  Raises:
52
- ValueError: If the file extension is not supported.
53
  """
54
  # Load environment variables from .env file
55
  load_dotenv()
@@ -57,75 +36,92 @@ def create_rag_chain(filepath):
57
  if not api_key:
58
  raise ValueError("GROQ_API_KEY not found in environment variables.")
59
 
60
- # --- 1. Load Document ---
61
- print("Loading document...")
62
- file_extension = "." + filepath.rsplit(".", 1)[-1].lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- if file_extension in LOADER_MAPPING:
65
- loader_class = LOADER_MAPPING[file_extension]
66
- # For image loaders, mode="single" can be useful to treat the image as one document
67
- if file_extension in [".jpeg", ".jpg", ".png"]:
68
- loader = loader_class(filepath, mode="single")
69
- else:
70
- loader = loader_class(filepath)
71
- docs = loader.load()
72
- else:
73
- raise ValueError(f"Unsupported file type: '{file_extension}'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- print(f"Document loaded successfully. Number of pages/docs: {len(docs)}")
76
-
77
- # --- 2. Split Text ---
78
- print("\nSplitting document into chunks...")
79
- text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
80
- splits = text_splitter.split_documents(docs)
81
- print(f"{len(splits)} chunks created.")
82
-
83
- # --- 3. Create Embeddings ---
84
- print("\nInitializing Hugging Face embeddings model...")
85
- model_name = "BAAI/bge-base-en-v1.5"
86
- model_kwargs = {'device': 'cpu'}
87
- encode_kwargs = {'normalize_embeddings': True}
88
- embeddings = HuggingFaceEmbeddings(
89
- model_name=model_name,
90
- model_kwargs=model_kwargs,
91
- encode_kwargs=encode_kwargs
92
- )
93
- print("Embeddings model loaded.")
94
-
95
- # --- 4. Create Vector Store ---
96
- print("\nCreating FAISS vector store from document chunks...")
97
- vectorstore = FAISS.from_documents(documents=splits, embedding=embeddings)
98
- print("Vector store created successfully.")
99
-
100
- # --- 5. Create Retriever ---
101
- retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
102
-
103
- # --- 6. Define the Prompt Template ---
104
- template = """
105
- You are an expert program. Your job is to provide accurate and helpful answers based ONLY on the provided context.
106
- If the information is not in the context, say that you don't know the answer.
107
- Keep your more ellaborated and explain in a clear way.
108
-
109
- Context: {context}
110
-
111
- Question: {question}
112
-
113
- Answer:
114
- """
115
- prompt = PromptTemplate.from_template(template)
116
-
117
- # --- 7. Initialize the LLM ---
118
- llm = ChatGroq(model_name="llama-3.1-8b-instant", api_key=api_key, temperature=0)
119
-
120
- # --- 8. Create the RAG Chain ---
121
- rag_chain = (
122
- RunnableParallel(
123
- context=retriever,
124
- question=RunnablePassthrough()
125
- )
126
- | prompt
127
  | llm
128
  | StrOutputParser()
129
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
- return rag_chain
 
 
1
+ # rag_processor.py
2
+
3
  import os
4
  from dotenv import load_dotenv
5
+ from operator import itemgetter # <--- ADD THIS IMPORT
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  # LLM
8
  from langchain_groq import ChatGroq
9
 
10
  # Prompting
11
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
12
 
13
  # Chains
14
  from langchain_core.runnables import RunnableParallel, RunnablePassthrough
15
  from langchain_core.output_parsers import StrOutputParser
16
+ from langchain_core.runnables.history import RunnableWithMessageHistory
17
 
18
+ def create_rag_chain(retriever, get_session_history_func):
 
 
 
 
 
 
 
 
 
 
19
  """
20
+ Creates an advanced Retrieval-Augmented Generation (RAG) chain with hybrid search,
21
+ query rewriting, answer refinement, and conversational memory.
22
 
23
  Args:
24
+ retriever: A configured LangChain retriever object.
25
+ get_session_history_func: A function to get the chat history for a session.
26
 
27
  Returns:
28
+ A LangChain runnable object representing the RAG chain with memory.
29
 
30
  Raises:
31
+ ValueError: If the GROQ_API_KEY is missing.
32
  """
33
  # Load environment variables from .env file
34
  load_dotenv()
 
36
  if not api_key:
37
  raise ValueError("GROQ_API_KEY not found in environment variables.")
38
 
39
+ # --- 1. Initialize the LLM ---
40
+ # Updated model_name to a standard, high-performance Groq model
41
+ llm = ChatGroq(model_name="llama-3.1-8b-instant", api_key=api_key, temperature=1)
42
+
43
+ # --- 2. Create Query Rewriting Chain 🧠 ---
44
+ print("\nSetting up query rewriting chain...")
45
+ rewrite_template = """You are an expert at rewriting user questions for a vector database.
46
+ You are here to help the user with their document.
47
+ Based on the chat history, reformulate the follow-up question to be a standalone question.
48
+ This new query should be optimized to find the most relevant documents in a knowledge base.
49
+ Do NOT answer the question, only provide the rewritten, optimized question.
50
+
51
+ Chat History:
52
+ {chat_history}
53
+
54
+ Follow-up Question: {question}
55
+ Standalone Question:"""
56
+ rewrite_prompt = ChatPromptTemplate.from_messages([
57
+ ("system", rewrite_template),
58
+ MessagesPlaceholder(variable_name="chat_history"),
59
+ ("human", "Based on our conversation, reformulate this question to be a standalone query: {question}")
60
+ ])
61
+ query_rewriter = rewrite_prompt | llm | StrOutputParser()
62
 
63
+ # --- 3. Create Main RAG Chain with Memory ---
64
+ print("\nSetting up main RAG chain...")
65
+ rag_template = """You are an expert assistant named `Cognichat`.Whenver user ask you about who you are , simply say you are `Cognichat`.
66
+ You are developed by Ritesh and Alish.
67
+ Your job is to provide accurate and helpful answers based ONLY on the provided context.
68
+ If the information is not in the context, clearly state that you don't know the answer.
69
+ Provide a clear and concise answer.
70
+
71
+ Context:
72
+ {context}"""
73
+ rag_prompt = ChatPromptTemplate.from_messages([
74
+ ("system", rag_template),
75
+ MessagesPlaceholder(variable_name="chat_history"),
76
+ ("human", "{question}"),
77
+ ])
78
+
79
+ # ============================ FIX IS HERE ============================
80
+ # Parallel process to fetch context and correctly pass through question and history.
81
+ # We use itemgetter to select the specific keys from the input dictionary.
82
+ setup_and_retrieval = RunnableParallel({
83
+ "context": query_rewriter | retriever,
84
+ "question": itemgetter("question"),
85
+ "chat_history": itemgetter("chat_history"),
86
+ })
87
+ # =====================================================================
88
 
89
+ # The initial RAG chain
90
+ conversational_rag_chain = (
91
+ setup_and_retrieval
92
+ | rag_prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  | llm
94
  | StrOutputParser()
95
  )
96
+
97
+ # Wrap the chain with memory management
98
+ chain_with_memory = RunnableWithMessageHistory(
99
+ conversational_rag_chain,
100
+ get_session_history_func,
101
+ input_messages_key="question",
102
+ history_messages_key="chat_history",
103
+ )
104
+
105
+ # --- 4. Create Answer Refinement Chain ✨ ---
106
+ print("\nSetting up answer refinement chain...")
107
+ refine_template = """You are an expert at editing and refining content.
108
+ Your task is to take a given answer and improve its clarity, structure, and readability.
109
+ Use formatting such as bold text, bullet points, or numbered lists where it enhances the explanation.
110
+ Do not add any new information that wasn't in the original answer.
111
+
112
+ Original Answer:
113
+ {answer}
114
+
115
+ Refined Answer:"""
116
+ refine_prompt = ChatPromptTemplate.from_template(refine_template)
117
+ refinement_chain = refine_prompt | llm | StrOutputParser()
118
+
119
+ # --- 5. Combine Everything into the Final Chain ---
120
+ # The final chain passes the output of the memory-enabled chain to the refinement chain
121
+ # Note: We need to adapt the input for the refinement chain
122
+ final_chain = (
123
+ lambda input_dict: {"answer": chain_with_memory.invoke(input_dict, config=input_dict.get('config'))}
124
+ ) | refinement_chain
125
 
126
+ print("\nFinalizing the complete chain with memory...")
127
+ return final_chain
templates/index.html CHANGED
@@ -82,6 +82,22 @@
82
  100% { transform: rotate(360deg); }
83
  }
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  /* Markdown Styling */
86
  .markdown-content p { margin-bottom: 0.75rem; line-height: 1.75; }
87
  .markdown-content ul, .markdown-content ol { margin-left: 1.5rem; margin-bottom: 0.75rem; }
@@ -93,12 +109,22 @@
93
  .markdown-content pre .copy-code-btn { position: absolute; top: 0.5rem; right: 0.5rem; background-color: #e8eaed; border: 1px solid #dadce0; color: #5f6368; padding: 0.3rem 0.6rem; border-radius: 0.25rem; cursor: pointer; opacity: 0; transition: opacity 0.2s; font-size: 0.8em;}
94
  .dark .markdown-content pre .copy-code-btn { background-color: #3c4043; border-color: #5f6368; color: #e8eaed; }
95
  .markdown-content pre:hover .copy-code-btn { opacity: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
96
  </style>
97
  </head>
98
- <body class="w-screen h-screen dark"> <!-- Default to dark mode -->
99
-
100
  <main id="main-content" class="h-full flex flex-col transition-opacity duration-500">
101
- <!-- Chat Area -->
102
  <div id="chat-container" class="hidden flex-1 flex flex-col w-full mx-auto overflow-hidden">
103
  <header class="text-center p-4 border-b border-[var(--card-border)] flex-shrink-0">
104
  <h1 class="text-xl font-medium">Chat with your Docs</h1>
@@ -106,36 +132,34 @@
106
  </header>
107
  <div id="chat-window" class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-10">
108
  <div id="chat-content" class="max-w-4xl mx-auto space-y-8">
109
- <!-- Chat messages will be appended here -->
110
  </div>
111
  </div>
112
  <div class="p-4 flex-shrink-0 bg-[var(--background)] border-t border-[var(--card-border)]">
113
  <form id="chat-form" class="max-w-4xl mx-auto bg-[var(--card)] rounded-full p-2 flex items-center shadow-sm border border-transparent focus-within:border-[var(--primary)] transition-colors">
114
- <input type="text" id="chat-input" placeholder="Ask a question about your document..." class="flex-grow bg-transparent focus-outline-none px-4 text-sm" autocomplete="off">
115
- <button type="submit" id="chat-submit-btn" class="bg-[var(--primary)] hover:bg-[var(--primary-hover)] text-white p-2 rounded-full transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-500" title="butn">
116
  <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.49941 11.5556L11.555 3.5L12.4438 4.38889L6.27721 10.5556H21.9994V11.5556H6.27721L12.4438 17.7222L11.555 18.6111L3.49941 10.5556V11.5556Z" transform="rotate(180, 12.7497, 11.0556)" fill="currentColor"></path></svg>
117
  </button>
118
  </form>
119
  </div>
120
  </div>
121
 
122
- <!-- Upload Area -->
123
  <div id="upload-container" class="flex-1 flex flex-col items-center justify-center p-8 transition-opacity duration-300">
124
  <div class="text-center">
125
  <h1 class="text-5xl font-medium mb-4">Upload docs to chat</h1>
126
  <div id="drop-zone" class="w-full max-w-lg text-center border-2 border-dashed border-[var(--card-border)] rounded-2xl p-10 transition-all duration-300 cursor-pointer bg-[var(--card)] hover:border-[var(--primary)]">
127
- <input id="file-upload" type="file" class="hidden" accept=".pdf,.txt,.docx,.jpg,.jpeg,.png" placeholder="upload your documents here">
128
  <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" ><path stroke-linecap="round" stroke-linejoin="round" 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>
129
- <p class="mt-4 text-sm font-medium">Drag & drop a file or click to upload</p>
130
  <p id="file-name" class="mt-2 text-xs text-gray-500"></p>
131
  </div>
132
  </div>
133
  </div>
134
 
135
- <!-- Loading Overlay -->
136
- <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">
137
  <div class="loader"></div>
138
- <p id="loading-text" class="mt-6 text-sm">Processing...</p>
 
139
  </div>
140
  </main>
141
 
@@ -148,6 +172,7 @@
148
  const fileNameSpan = document.getElementById('file-name');
149
  const loadingOverlay = document.getElementById('loading-overlay');
150
  const loadingText = document.getElementById('loading-text');
 
151
 
152
  const chatForm = document.getElementById('chat-form');
153
  const chatInput = document.getElementById('chat-input');
@@ -156,8 +181,6 @@
156
  const chatContent = document.getElementById('chat-content');
157
  const chatFilename = document.getElementById('chat-filename');
158
 
159
- let selectedFile = null;
160
-
161
  // --- File Upload Logic ---
162
  dropZone.addEventListener('click', () => fileUploadInput.click());
163
 
@@ -171,67 +194,51 @@
171
 
172
  dropZone.addEventListener('drop', (e) => {
173
  const files = e.dataTransfer.files;
174
- if (files.length > 0) handleFile(files[0]);
175
  });
176
 
177
  fileUploadInput.addEventListener('change', (e) => {
178
- if (e.target.files.length > 0) handleFile(e.target.files[0]);
179
  });
180
 
181
  function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
182
 
183
- async function handleFile(file) {
184
- selectedFile = file;
185
- fileNameSpan.textContent = `Selected: ${file.name}`;
186
- await uploadAndProcessFile();
187
- }
188
-
189
- async function uploadAndProcessFile() {
190
- if (!selectedFile) return;
191
-
192
  const formData = new FormData();
193
- formData.append('file', selectedFile);
 
 
 
 
 
 
 
 
194
 
 
195
  loadingOverlay.classList.remove('hidden');
 
 
196
 
197
- const loadingSteps = [
198
- `Uploading ${selectedFile.name}...`,
199
- "Parsing document...",
200
- "Extracting text...",
201
- "Creating embeddings (this may take a moment)...",
202
- "Building knowledge base..."
203
- ];
204
- let stepIndex = 0;
205
- loadingText.textContent = loadingSteps[stepIndex];
206
-
207
- const stepInterval = setInterval(() => {
208
- stepIndex++;
209
- if (stepIndex < loadingSteps.length) {
210
- loadingText.textContent = loadingSteps[stepIndex];
211
- } else {
212
- loadingText.textContent = "Finalizing...";
213
- }
214
- }, 1500);
215
-
216
  try {
217
  const response = await fetch('/upload', { method: 'POST', body: formData });
218
  const result = await response.json();
219
 
220
  if (!response.ok) throw new Error(result.message || 'Unknown error occurred.');
221
 
222
- chatFilename.textContent = `Chatting with ${result.filename}`;
223
  uploadContainer.classList.add('hidden');
224
  chatContainer.classList.remove('hidden');
225
- appendMessage("I've analyzed your document. What would you like to know?", "bot");
226
 
227
  } catch (error) {
228
  console.error('Upload error:', error);
229
  alert(`Error: ${error.message}`);
230
  } finally {
231
- clearInterval(stepInterval);
232
  loadingOverlay.classList.add('hidden');
 
233
  fileNameSpan.textContent = '';
234
- selectedFile = null;
235
  }
236
  }
237
 
@@ -246,8 +253,9 @@
246
  chatInput.disabled = true;
247
  chatSubmitBtn.disabled = true;
248
 
249
- const botMessageContainer = appendMessage('', 'bot');
250
- const contentDiv = botMessageContainer.querySelector('.markdown-content');
 
251
 
252
  try {
253
  const response = await fetch('/chat', {
@@ -258,6 +266,10 @@
258
 
259
  if (!response.ok) throw new Error(`Server error: ${response.statusText}`);
260
 
 
 
 
 
261
  const reader = response.body.getReader();
262
  const decoder = new TextDecoder();
263
  let fullResponse = '';
@@ -271,10 +283,17 @@
271
  scrollToBottom();
272
  }
273
  contentDiv.querySelectorAll('pre').forEach(addCopyButton);
 
 
274
 
275
  } catch (error) {
276
  console.error('Chat error:', error);
277
- contentDiv.innerHTML = `<p class="text-red-500">Error: ${error.message}</p>`;
 
 
 
 
 
278
  } finally {
279
  chatInput.disabled = false;
280
  chatSubmitBtn.disabled = false;
@@ -283,6 +302,7 @@
283
  });
284
 
285
  // --- UI Helper Functions ---
 
286
  function appendMessage(text, sender) {
287
  const messageWrapper = document.createElement('div');
288
  messageWrapper.className = `flex items-start gap-4`;
@@ -301,9 +321,13 @@
301
  const contentDiv = document.createElement('div');
302
  contentDiv.className = 'text-base markdown-content';
303
  contentDiv.innerHTML = marked.parse(text);
304
-
 
 
 
305
  messageBubble.appendChild(senderName);
306
  messageBubble.appendChild(contentDiv);
 
307
  messageWrapper.innerHTML = iconSVG;
308
  messageWrapper.appendChild(messageBubble);
309
 
@@ -312,6 +336,35 @@
312
 
313
  return messageBubble;
314
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
  function scrollToBottom() {
317
  chatWindow.scrollTo({
@@ -334,9 +387,100 @@
334
  });
335
  });
336
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  });
338
  </script>
339
  </body>
340
- </html>
341
-
342
-
 
82
  100% { transform: rotate(360deg); }
83
  }
84
 
85
+ /* Typing Indicator Animation */
86
+ .typing-indicator span {
87
+ height: 10px;
88
+ width: 10px;
89
+ background-color: #9E9E9E;
90
+ border-radius: 50%;
91
+ display: inline-block;
92
+ animation: bounce 1.4s infinite ease-in-out both;
93
+ }
94
+ .typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
95
+ .typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
96
+ @keyframes bounce {
97
+ 0%, 80%, 100% { transform: scale(0); }
98
+ 40% { transform: scale(1.0); }
99
+ }
100
+
101
  /* Markdown Styling */
102
  .markdown-content p { margin-bottom: 0.75rem; line-height: 1.75; }
103
  .markdown-content ul, .markdown-content ol { margin-left: 1.5rem; margin-bottom: 0.75rem; }
 
109
  .markdown-content pre .copy-code-btn { position: absolute; top: 0.5rem; right: 0.5rem; background-color: #e8eaed; border: 1px solid #dadce0; color: #5f6368; padding: 0.3rem 0.6rem; border-radius: 0.25rem; cursor: pointer; opacity: 0; transition: opacity 0.2s; font-size: 0.8em;}
110
  .dark .markdown-content pre .copy-code-btn { background-color: #3c4043; border-color: #5f6368; color: #e8eaed; }
111
  .markdown-content pre:hover .copy-code-btn { opacity: 1; }
112
+
113
+ /* Spinner for the TTS button */
114
+ .tts-button-loader {
115
+ width: 16px;
116
+ height: 16px;
117
+ border: 2px solid currentColor; /* Use button's text color */
118
+ border-radius: 50%;
119
+ display: inline-block;
120
+ box-sizing: border-box;
121
+ animation: rotation 0.8s linear infinite;
122
+ border-bottom-color: transparent; /* Makes it a half circle spinner */
123
+ }
124
  </style>
125
  </head>
126
+ <body class="w-screen h-screen dark">
 
127
  <main id="main-content" class="h-full flex flex-col transition-opacity duration-500">
 
128
  <div id="chat-container" class="hidden flex-1 flex flex-col w-full mx-auto overflow-hidden">
129
  <header class="text-center p-4 border-b border-[var(--card-border)] flex-shrink-0">
130
  <h1 class="text-xl font-medium">Chat with your Docs</h1>
 
132
  </header>
133
  <div id="chat-window" class="flex-1 overflow-y-auto p-4 md:p-6 lg:p-10">
134
  <div id="chat-content" class="max-w-4xl mx-auto space-y-8">
 
135
  </div>
136
  </div>
137
  <div class="p-4 flex-shrink-0 bg-[var(--background)] border-t border-[var(--card-border)]">
138
  <form id="chat-form" class="max-w-4xl mx-auto bg-[var(--card)] rounded-full p-2 flex items-center shadow-sm border border-transparent focus-within:border-[var(--primary)] transition-colors">
139
+ <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">
140
+ <button type="submit" id="chat-submit-btn" class="bg-[var(--primary)] hover:bg-[var(--primary-hover)] text-white p-2 rounded-full transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-500" title="Send">
141
  <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.49941 11.5556L11.555 3.5L12.4438 4.38889L6.27721 10.5556H21.9994V11.5556H6.27721L12.4438 17.7222L11.555 18.6111L3.49941 10.5556V11.5556Z" transform="rotate(180, 12.7497, 11.0556)" fill="currentColor"></path></svg>
142
  </button>
143
  </form>
144
  </div>
145
  </div>
146
 
 
147
  <div id="upload-container" class="flex-1 flex flex-col items-center justify-center p-8 transition-opacity duration-300">
148
  <div class="text-center">
149
  <h1 class="text-5xl font-medium mb-4">Upload docs to chat</h1>
150
  <div id="drop-zone" class="w-full max-w-lg text-center border-2 border-dashed border-[var(--card-border)] rounded-2xl p-10 transition-all duration-300 cursor-pointer bg-[var(--card)] hover:border-[var(--primary)]">
151
+ <input id="file-upload" type="file" class="hidden" accept=".pdf,.txt,.docx,.jpg,.jpeg,.png" multiple title="input">
152
  <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" ><path stroke-linecap="round" stroke-linejoin="round" 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>
153
+ <p class="mt-4 text-sm font-medium">Drag & drop files or click to upload</p>
154
  <p id="file-name" class="mt-2 text-xs text-gray-500"></p>
155
  </div>
156
  </div>
157
  </div>
158
 
159
+ <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 text-center p-4">
 
160
  <div class="loader"></div>
161
+ <p id="loading-text" class="mt-6 text-sm font-medium"></p>
162
+ <p id="loading-subtext" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>
163
  </div>
164
  </main>
165
 
 
172
  const fileNameSpan = document.getElementById('file-name');
173
  const loadingOverlay = document.getElementById('loading-overlay');
174
  const loadingText = document.getElementById('loading-text');
175
+ const loadingSubtext = document.getElementById('loading-subtext');
176
 
177
  const chatForm = document.getElementById('chat-form');
178
  const chatInput = document.getElementById('chat-input');
 
181
  const chatContent = document.getElementById('chat-content');
182
  const chatFilename = document.getElementById('chat-filename');
183
 
 
 
184
  // --- File Upload Logic ---
185
  dropZone.addEventListener('click', () => fileUploadInput.click());
186
 
 
194
 
195
  dropZone.addEventListener('drop', (e) => {
196
  const files = e.dataTransfer.files;
197
+ if (files.length > 0) handleFiles(files);
198
  });
199
 
200
  fileUploadInput.addEventListener('change', (e) => {
201
+ if (e.target.files.length > 0) handleFiles(e.target.files);
202
  });
203
 
204
  function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
205
 
206
+ async function handleFiles(files) {
 
 
 
 
 
 
 
 
207
  const formData = new FormData();
208
+ let fileNames = [];
209
+ for (const file of files) {
210
+ formData.append('file', file);
211
+ fileNames.push(file.name);
212
+ }
213
+
214
+ fileNameSpan.textContent = `Selected: ${fileNames.join(', ')}`;
215
+ await uploadAndProcessFiles(formData, fileNames);
216
+ }
217
 
218
+ async function uploadAndProcessFiles(formData, fileNames) {
219
  loadingOverlay.classList.remove('hidden');
220
+ loadingText.textContent = `Processing ${fileNames.length} document(s)...`;
221
+ loadingSubtext.textContent = "For large documents or OCR, setup may take a few minutes to build the knowledge base.";
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  try {
224
  const response = await fetch('/upload', { method: 'POST', body: formData });
225
  const result = await response.json();
226
 
227
  if (!response.ok) throw new Error(result.message || 'Unknown error occurred.');
228
 
229
+ chatFilename.textContent = `Chatting with: ${result.filename}`;
230
  uploadContainer.classList.add('hidden');
231
  chatContainer.classList.remove('hidden');
232
+ appendMessage("I've analyzed your documents. What would you like to know?", "bot");
233
 
234
  } catch (error) {
235
  console.error('Upload error:', error);
236
  alert(`Error: ${error.message}`);
237
  } finally {
 
238
  loadingOverlay.classList.add('hidden');
239
+ loadingSubtext.textContent = '';
240
  fileNameSpan.textContent = '';
241
+ fileUploadInput.value = ''; // Reset file input
242
  }
243
  }
244
 
 
253
  chatInput.disabled = true;
254
  chatSubmitBtn.disabled = true;
255
 
256
+ const typingIndicator = showTypingIndicator();
257
+ let botMessageContainer = null;
258
+ let contentDiv = null;
259
 
260
  try {
261
  const response = await fetch('/chat', {
 
266
 
267
  if (!response.ok) throw new Error(`Server error: ${response.statusText}`);
268
 
269
+ typingIndicator.remove();
270
+ botMessageContainer = appendMessage('', 'bot');
271
+ contentDiv = botMessageContainer.querySelector('.markdown-content');
272
+
273
  const reader = response.body.getReader();
274
  const decoder = new TextDecoder();
275
  let fullResponse = '';
 
283
  scrollToBottom();
284
  }
285
  contentDiv.querySelectorAll('pre').forEach(addCopyButton);
286
+
287
+ addTextToSpeechControls(botMessageContainer, fullResponse);
288
 
289
  } catch (error) {
290
  console.error('Chat error:', error);
291
+ if (typingIndicator) typingIndicator.remove();
292
+ if (contentDiv) {
293
+ contentDiv.innerHTML = `<p class="text-red-500">Error: ${error.message}</p>`;
294
+ } else {
295
+ appendMessage(`Error: ${error.message}`, 'bot');
296
+ }
297
  } finally {
298
  chatInput.disabled = false;
299
  chatSubmitBtn.disabled = false;
 
302
  });
303
 
304
  // --- UI Helper Functions ---
305
+
306
  function appendMessage(text, sender) {
307
  const messageWrapper = document.createElement('div');
308
  messageWrapper.className = `flex items-start gap-4`;
 
321
  const contentDiv = document.createElement('div');
322
  contentDiv.className = 'text-base markdown-content';
323
  contentDiv.innerHTML = marked.parse(text);
324
+
325
+ const controlsContainer = document.createElement('div');
326
+ controlsContainer.className = 'tts-controls mt-2';
327
+
328
  messageBubble.appendChild(senderName);
329
  messageBubble.appendChild(contentDiv);
330
+ messageBubble.appendChild(controlsContainer);
331
  messageWrapper.innerHTML = iconSVG;
332
  messageWrapper.appendChild(messageBubble);
333
 
 
336
 
337
  return messageBubble;
338
  }
339
+
340
+ function showTypingIndicator() {
341
+ const indicatorWrapper = document.createElement('div');
342
+ indicatorWrapper.className = `flex items-start gap-4`;
343
+ indicatorWrapper.id = 'typing-indicator';
344
+
345
+ const iconSVG = `<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>`;
346
+
347
+ const messageBubble = document.createElement('div');
348
+ messageBubble.className = 'flex-1 pt-1';
349
+
350
+ const senderName = document.createElement('p');
351
+ senderName.className = 'font-medium text-sm mb-1';
352
+ senderName.textContent = 'CogniChat is thinking...';
353
+
354
+ const indicator = document.createElement('div');
355
+ indicator.className = 'typing-indicator';
356
+ indicator.innerHTML = '<span></span><span></span><span></span>';
357
+
358
+ messageBubble.appendChild(senderName);
359
+ messageBubble.appendChild(indicator);
360
+ indicatorWrapper.innerHTML = iconSVG;
361
+ indicatorWrapper.appendChild(messageBubble);
362
+
363
+ chatContent.appendChild(indicatorWrapper);
364
+ scrollToBottom();
365
+
366
+ return indicatorWrapper;
367
+ }
368
 
369
  function scrollToBottom() {
370
  chatWindow.scrollTo({
 
387
  });
388
  });
389
  }
390
+
391
+ // ============================ MODIFICATIONS START ==============================
392
+ let currentAudio = null;
393
+ let currentPlayingButton = null;
394
+
395
+ const playIconSVG = `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
396
+ const pauseIconSVG = `<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`;
397
+
398
+ function addTextToSpeechControls(messageBubble, text) {
399
+ const ttsControls = messageBubble.querySelector('.tts-controls');
400
+ if (text.trim().length > 0) {
401
+ const speakButton = document.createElement('button');
402
+ // --- STYLING CHANGE HERE: Brighter blue color for better visibility ---
403
+ speakButton.className = 'speak-btn px-3 py-1.5 bg-blue-600 text-white rounded-full text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed';
404
+ speakButton.title = 'Listen to this message';
405
+ // --- EMOJI ADDED ---
406
+ speakButton.innerHTML = `πŸ”Š ${playIconSVG} <span>Listen</span>`;
407
+ ttsControls.appendChild(speakButton);
408
+ speakButton.addEventListener('click', () => handleTTS(text, speakButton));
409
+ }
410
+ }
411
+
412
+ // --- BUG FIX: Reworked the entire function for correct pause/resume/stop logic ---
413
+ async function handleTTS(text, button) {
414
+ // Case 1: The clicked button is already playing or paused.
415
+ if (button === currentPlayingButton) {
416
+ if (currentAudio && !currentAudio.paused) { // If playing, pause it.
417
+ currentAudio.pause();
418
+ button.innerHTML = `πŸ”Š ${playIconSVG} <span>Listen</span>`;
419
+ } else if (currentAudio && currentAudio.paused) { // If paused, resume it.
420
+ currentAudio.play();
421
+ button.innerHTML = `πŸ”Š ${pauseIconSVG} <span>Pause</span>`;
422
+ }
423
+ return;
424
+ }
425
+
426
+ // Case 2: A new button is clicked. Stop any other audio.
427
+ if (currentAudio) {
428
+ currentAudio.pause();
429
+ }
430
+ resetAllSpeakButtons();
431
+
432
+ currentPlayingButton = button;
433
+ button.innerHTML = `<div class="tts-button-loader"></div> <span>Loading...</span>`;
434
+ button.disabled = true;
435
+
436
+ try {
437
+ const response = await fetch('/tts', {
438
+ method: 'POST',
439
+ headers: { 'Content-Type': 'application/json' },
440
+ body: JSON.stringify({ text: text })
441
+ });
442
+ if (!response.ok) throw new Error('Failed to generate audio.');
443
+
444
+ const blob = await response.blob();
445
+ const audioUrl = URL.createObjectURL(blob);
446
+ currentAudio = new Audio(audioUrl);
447
+
448
+ currentAudio.play().catch(e => { throw e; });
449
+ button.innerHTML = `πŸ”Š ${pauseIconSVG} <span>Pause</span>`;
450
+
451
+ currentAudio.onended = () => {
452
+ button.innerHTML = `πŸ”Š ${playIconSVG} <span>Listen</span>`;
453
+ currentAudio = null;
454
+ currentPlayingButton = null;
455
+ };
456
+
457
+ currentAudio.onerror = (e) => {
458
+ console.error('Audio playback error:', e);
459
+ throw new Error('Could not play the generated audio.');
460
+ };
461
+
462
+ } catch (error) {
463
+ console.error('TTS Error:', error);
464
+ alert('Failed to play audio. Please try again.');
465
+ resetAllSpeakButtons(); // Reset state on error
466
+ } finally {
467
+ button.disabled = false;
468
+ }
469
+ }
470
+
471
+ function resetAllSpeakButtons() {
472
+ document.querySelectorAll('.speak-btn').forEach(btn => {
473
+ btn.innerHTML = `πŸ”Š ${playIconSVG} <span>Listen</span>`;
474
+ btn.disabled = false;
475
+ });
476
+ if (currentAudio) {
477
+ currentAudio.pause();
478
+ currentAudio = null;
479
+ }
480
+ currentPlayingButton = null;
481
+ }
482
+ // ============================ MODIFICATIONS END ==============================
483
  });
484
  </script>
485
  </body>
486
+ </html>