aaporosh commited on
Commit
574210c
·
verified ·
1 Parent(s): 59558d9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +220 -164
app.py CHANGED
@@ -9,48 +9,43 @@ from sentence_transformers import SentenceTransformer
9
  from transformers import pipeline
10
  import re
11
 
12
- # Setup logging for Spaces
13
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
14
  logger = logging.getLogger(__name__)
15
 
16
- # Lazy load models with caching
 
17
  @st.cache_resource(ttl=1800)
18
  def load_embeddings_model():
19
- logger.info("Loading embeddings model")
20
  try:
21
  return SentenceTransformer("all-MiniLM-L12-v2")
22
  except Exception as e:
23
- logger.error(f"Embeddings load error: {str(e)}")
24
  st.error(f"Embedding model error: {str(e)}")
25
  return None
26
 
27
  @st.cache_resource(ttl=1800)
28
  def load_qa_pipeline():
29
- logger.info("Loading QA pipeline")
30
  try:
31
  return pipeline("text2text-generation", model="google/flan-t5-small", max_length=300)
32
  except Exception as e:
33
- logger.error(f"QA model load error: {str(e)}")
34
  st.error(f"QA model error: {str(e)}")
35
  return None
36
 
37
  @st.cache_resource(ttl=1800)
38
  def load_summary_pipeline():
39
- logger.info("Loading summary pipeline")
40
  try:
41
  return pipeline("summarization", model="sshleifer/distilbart-cnn-6-6", max_length=150)
42
  except Exception as e:
43
- logger.error(f"Summary model load error: {str(e)}")
44
  st.error(f"Summary model error: {str(e)}")
45
  return None
46
 
47
- # Process PDF with improved extraction
 
48
  def process_pdf(uploaded_file):
49
- logger.info("Processing PDF with enhanced extraction")
 
50
  try:
51
- text = ""
52
- code_blocks = []
53
- with pdfplumber.open(BytesIO(uploaded_file.getvalue())) as pdf:
54
  for page in pdf.pages[:20]:
55
  extracted = page.extract_text(layout=False)
56
  if extracted:
@@ -58,23 +53,16 @@ def process_pdf(uploaded_file):
58
  for char in page.chars:
59
  if 'fontname' in char and 'mono' in char['fontname'].lower():
60
  code_blocks.append(char['text'])
61
- code_text_page = page.extract_text()
62
- code_matches = re.finditer(r'(^\s{2,}.*?(?:\n\s{2,}.*?)*)', code_text_page or "", re.MULTILINE)
63
  for match in code_matches:
64
  code_blocks.append(match.group().strip())
65
  tables = page.extract_tables()
66
  if tables:
67
  for table in tables:
68
  text += "\n".join([" | ".join(map(str, row)) for row in table if row]) + "\n"
69
- for obj in page.extract_words():
70
- if obj.get('size', 0) > 12:
71
- text += f"\n{obj['text']}\n"
72
-
73
  code_text = "\n".join(code_blocks).strip()
74
- if not text:
75
- raise ValueError("No text extracted from PDF")
76
 
77
- # Use RecursiveCharacterTextSplitter for better semantic splitting
78
  text_splitter = RecursiveCharacterTextSplitter(
79
  chunk_size=500, chunk_overlap=100, separators=["\n\n", "\n", ".", " "]
80
  )
@@ -85,160 +73,228 @@ def process_pdf(uploaded_file):
85
  if not embeddings_model:
86
  return None, None, text, code_text
87
 
88
- # Build FAISS vector stores efficiently
89
  text_vectors = [embeddings_model.encode(chunk) for chunk in text_chunks]
90
  code_vectors = [embeddings_model.encode(chunk) for chunk in code_chunks]
91
 
92
  text_vector_store = FAISS.from_embeddings(zip(text_chunks, text_vectors), embeddings_model.encode) if text_chunks else None
93
  code_vector_store = FAISS.from_embeddings(zip(code_chunks, code_vectors), embeddings_model.encode) if code_chunks else None
94
 
95
- logger.info("PDF processed successfully with enhanced extraction")
96
  return text_vector_store, code_vector_store, text, code_text
 
97
  except Exception as e:
98
- logger.error(f"PDF processing error: {str(e)}")
99
  st.error(f"PDF error: {str(e)}")
100
  return None, None, "", ""
101
 
102
- # Summarize PDF
103
- def summarize_pdf(text):
104
- logger.info("Generating summary")
105
- try:
106
- summary_pipeline = load_summary_pipeline()
107
- if not summary_pipeline:
108
- return "Summary model unavailable."
109
 
110
- text_splitter = RecursiveCharacterTextSplitter(
111
- chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", ".", " "]
112
- )
113
- chunks = text_splitter.split_text(text)[:2]
114
- summaries = []
115
-
116
- for chunk in chunks:
117
- summary = summary_pipeline(chunk[:500], max_length=100, min_length=30, do_sample=False)[0]['summary_text']
118
- summaries.append(summary.strip())
119
-
120
- combined_summary = " ".join(summaries)
121
- if len(combined_summary.split()) > 150:
122
- combined_summary = " ".join(combined_summary.split()[:150])
123
- logger.info("Summary generated")
124
- return f"Sure, here's a concise summary of the PDF:\n{combined_summary}"
125
- except Exception as e:
126
- logger.error(f"Summary error: {str(e)}")
127
- return f"Oops, something went wrong summarizing: {str(e)}"
128
 
129
- # Answer question with improved response
130
- def answer_question(text_vector_store, code_vector_store, query):
131
- logger.info(f"Processing query: {query}")
132
- try:
133
- if not text_vector_store and not code_vector_store:
134
- return "Please upload a PDF first!"
135
 
136
- qa_pipeline = load_qa_pipeline()
137
- if not qa_pipeline:
138
- return "Sorry, the QA model is unavailable right now."
139
-
140
- is_code_query = any(keyword in query.lower() for keyword in ["code", "script", "function", "programming", "give me code", "show code"])
141
- if is_code_query and code_vector_store:
142
- return f"Here's the code from the PDF:\n```python\n{st.session_state.code_text}\n```"
143
-
144
- vector_store = text_vector_store
145
- if not vector_store:
146
- return "No relevant content found for your query."
147
-
148
- docs = vector_store.similarity_search(query, k=5)
149
- context = "\n".join(doc.page_content for doc in docs)
150
- prompt = f"Context: {context}\nQuestion: {query}\nProvide a detailed, accurate answer based on the context, prioritizing relevant information. Respond as a helpful assistant:"
151
- response = qa_pipeline(prompt)[0]['generated_text']
152
- logger.info("Answer generated")
153
- return f"Got it! Here's a detailed answer:\n{response.strip()}"
154
- except Exception as e:
155
- logger.error(f"Query error: {str(e)}")
156
- return f"Sorry, something went wrong: {str(e)}"
157
-
158
- # Streamlit UI
159
- try:
160
- st.set_page_config(page_title="Smart PDF Q&A", page_icon="📄", layout="wide")
161
- st.markdown("""
162
- <style>
163
- .main { max-width: 900px; margin: 0 auto; padding: 20px; }
164
- .sidebar { background-color: #f8f9fa; padding: 10px; border-radius: 5px; }
165
- .chat-container { border: 1px solid #ddd; border-radius: 10px; padding: 10px; height: 65vh; overflow-y: auto; margin-top: 20px; }
166
- .user-bubble { background-color: #e6f3ff; border-radius: 15px; padding: 10px; margin: 5px; text-align: right; }
167
- .assistant-bubble { background-color: #f0f0f0; border-radius: 15px; padding: 10px; margin: 5px; text-align: left; }
168
- .stButton>button { background-color: #4CAF50; color: white; border: none; padding: 8px 16px; border-radius: 5px; }
169
- .stButton>button:hover { background-color: #45a049; }
170
- pre { background-color: #f8f8f8; padding: 10px; border-radius: 5px; overflow-x: auto; }
171
- .header { background: linear-gradient(90deg, #4CAF50, #81C784); color: white; padding: 10px; border-radius: 5px; text-align: center; }
172
- .stChatInput { position: fixed; bottom: 10px; width: 80%; }
173
- </style>
174
- """, unsafe_allow_html=True)
175
-
176
- st.markdown('<div class="header"><h1>Smart PDF Q&A</h1></div>', unsafe_allow_html=True)
177
- st.markdown("Upload a PDF to ask questions, summarize (~150 words), or extract code with 'give me code'. Fast and friendly responses!")
178
-
179
- # Initialize session state
180
- if "messages" not in st.session_state:
181
- st.session_state.messages = []
182
- if "text_vector_store" not in st.session_state:
183
- st.session_state.text_vector_store = None
184
- if "code_vector_store" not in st.session_state:
185
- st.session_state.code_vector_store = None
186
- if "pdf_text" not in st.session_state:
187
- st.session_state.pdf_text = ""
188
- if "code_text" not in st.session_state:
189
- st.session_state.code_text = ""
190
-
191
- # Sidebar
192
- with st.sidebar:
193
- st.markdown('<div class="sidebar">', unsafe_allow_html=True)
194
- theme = st.radio("Theme", ["Light", "Dark"], index=0)
195
- st.markdown('</div>', unsafe_allow_html=True)
196
-
197
- # PDF upload and processing
198
- uploaded_file = st.file_uploader("Upload a PDF", type=["pdf"])
199
- col1, col2 = st.columns([1, 1])
200
- with col1:
201
- if st.button("Process PDF") and uploaded_file:
202
- with st.spinner("Processing PDF..."):
203
- st.session_state.text_vector_store, st.session_state.code_vector_store, st.session_state.pdf_text, st.session_state.code_text = process_pdf(uploaded_file)
204
- if st.session_state.text_vector_store or st.session_state.code_vector_store:
205
- st.success("PDF processed! Ask away or summarize.")
206
- st.session_state.messages = []
207
- else:
208
- st.error("Failed to process PDF.")
209
- with col2:
210
- if st.button("Summarize PDF") and st.session_state.pdf_text:
211
- with st.spinner("Summarizing..."):
212
- summary = summarize_pdf(st.session_state.pdf_text)
213
- st.session_state.messages.append({"role": "assistant", "content": summary})
214
- st.markdown(summary, unsafe_allow_html=True)
215
-
216
- # Chat interface
217
- st.markdown('<div class="chat-container">', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  if st.session_state.text_vector_store or st.session_state.code_vector_store:
219
- prompt = st.chat_input("Ask a question (e.g., 'Give me code' or 'What’s the main idea?'):")
220
- if prompt:
221
- st.session_state.messages.append({"role": "user", "content": prompt})
222
- with st.chat_message("user"):
223
- st.markdown(f"<div class='user-bubble'>{prompt}</div>", unsafe_allow_html=True)
224
- with st.chat_message("assistant"):
225
- with st.spinner('<div class="spinner">⏳</div>'):
226
- answer = answer_question(st.session_state.text_vector_store, st.session_state.code_vector_store, prompt)
227
- st.markdown(f"<div class='assistant-bubble'>{answer}</div>", unsafe_allow_html=True)
228
- st.session_state.messages.append({"role": "assistant", "content": answer})
229
-
230
- # Display chat history
231
- for message in st.session_state.messages:
232
- css_class = "user-bubble" if message["role"] == "user" else "assistant-bubble"
233
- st.markdown(f"<div class='{css_class}'>{message['content']}</div>", unsafe_allow_html=True)
234
-
235
- st.markdown('</div>', unsafe_allow_html=True)
236
-
237
- # Download chat history
238
- if st.session_state.messages:
239
- chat_text = "\n".join(f"{m['role'].capitalize()}: {m['content']}" for m in st.session_state.messages)
240
- st.download_button("Download Chat History", chat_text, "chat_history.txt")
241
-
242
- except Exception as e:
243
- logger.error(f"App initialization failed: {str(e)}")
244
- st.error(f"App failed to start: {str(e)}. Check Spaces logs or contact support.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from transformers import pipeline
10
  import re
11
 
12
+ # Setup logging
13
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
14
  logger = logging.getLogger(__name__)
15
 
16
+ # ----------- Load Models -----------
17
+
18
  @st.cache_resource(ttl=1800)
19
  def load_embeddings_model():
 
20
  try:
21
  return SentenceTransformer("all-MiniLM-L12-v2")
22
  except Exception as e:
 
23
  st.error(f"Embedding model error: {str(e)}")
24
  return None
25
 
26
  @st.cache_resource(ttl=1800)
27
  def load_qa_pipeline():
 
28
  try:
29
  return pipeline("text2text-generation", model="google/flan-t5-small", max_length=300)
30
  except Exception as e:
 
31
  st.error(f"QA model error: {str(e)}")
32
  return None
33
 
34
  @st.cache_resource(ttl=1800)
35
  def load_summary_pipeline():
 
36
  try:
37
  return pipeline("summarization", model="sshleifer/distilbart-cnn-6-6", max_length=150)
38
  except Exception as e:
 
39
  st.error(f"Summary model error: {str(e)}")
40
  return None
41
 
42
+ # ----------- PDF Processing -----------
43
+
44
  def process_pdf(uploaded_file):
45
+ text = ""
46
+ code_blocks = []
47
  try:
48
+ with pdfplumber.open(BytesIO(uploaded_file.read())) as pdf:
 
 
49
  for page in pdf.pages[:20]:
50
  extracted = page.extract_text(layout=False)
51
  if extracted:
 
53
  for char in page.chars:
54
  if 'fontname' in char and 'mono' in char['fontname'].lower():
55
  code_blocks.append(char['text'])
56
+ code_text_page = page.extract_text() or ""
57
+ code_matches = re.finditer(r'(^\s{2,}.*?(?:\n\s{2,}.*?)*)', code_text_page, re.MULTILINE)
58
  for match in code_matches:
59
  code_blocks.append(match.group().strip())
60
  tables = page.extract_tables()
61
  if tables:
62
  for table in tables:
63
  text += "\n".join([" | ".join(map(str, row)) for row in table if row]) + "\n"
 
 
 
 
64
  code_text = "\n".join(code_blocks).strip()
 
 
65
 
 
66
  text_splitter = RecursiveCharacterTextSplitter(
67
  chunk_size=500, chunk_overlap=100, separators=["\n\n", "\n", ".", " "]
68
  )
 
73
  if not embeddings_model:
74
  return None, None, text, code_text
75
 
 
76
  text_vectors = [embeddings_model.encode(chunk) for chunk in text_chunks]
77
  code_vectors = [embeddings_model.encode(chunk) for chunk in code_chunks]
78
 
79
  text_vector_store = FAISS.from_embeddings(zip(text_chunks, text_vectors), embeddings_model.encode) if text_chunks else None
80
  code_vector_store = FAISS.from_embeddings(zip(code_chunks, code_vectors), embeddings_model.encode) if code_chunks else None
81
 
 
82
  return text_vector_store, code_vector_store, text, code_text
83
+
84
  except Exception as e:
 
85
  st.error(f"PDF error: {str(e)}")
86
  return None, None, "", ""
87
 
88
+ # ----------- Preload Dataset -----------
 
 
 
 
 
 
89
 
90
+ def preload_dataset():
91
+ dataset_path = "data"
92
+ combined_text = ""
93
+ combined_code = ""
94
+ text_vector_store = None
95
+ code_vector_store = None
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ if not os.path.exists(dataset_path):
98
+ return text_vector_store, code_vector_store, combined_text, combined_code
 
 
 
 
99
 
100
+ embeddings_model = load_embeddings_model()
101
+ if not embeddings_model:
102
+ return text_vector_store, code_vector_store, combined_text, combined_code
103
+
104
+ all_text_chunks = []
105
+ all_text_vectors = []
106
+ all_code_chunks = []
107
+ all_code_vectors = []
108
+
109
+ for file_name in os.listdir(dataset_path):
110
+ file_path = os.path.join(dataset_path, file_name)
111
+ if file_name.lower().endswith(".pdf"):
112
+ with open(file_path, "rb") as f:
113
+ t_store, c_store, t_text, c_text = process_pdf(f)
114
+ combined_text += t_text + "\n"
115
+ combined_code += c_text + "\n"
116
+ if t_store:
117
+ for chunk in t_store.index_to_docstore().values():
118
+ all_text_chunks.append(chunk)
119
+ all_text_vectors.append(embeddings_model.encode(chunk))
120
+ if c_store:
121
+ for chunk in c_store.index_to_docstore().values():
122
+ all_code_chunks.append(chunk)
123
+ all_code_vectors.append(embeddings_model.encode(chunk))
124
+ elif file_name.lower().endswith(".txt"):
125
+ with open(file_path, "r", encoding="utf-8") as f:
126
+ text_content = f.read()
127
+ combined_text += text_content + "\n"
128
+ chunks = text_content.split("\n\n")
129
+ for chunk in chunks:
130
+ all_text_chunks.append(chunk)
131
+ all_text_vectors.append(embeddings_model.encode(chunk))
132
+
133
+ if all_text_chunks:
134
+ text_vector_store = FAISS.from_embeddings(zip(all_text_chunks, all_text_vectors), embeddings_model.encode)
135
+ if all_code_chunks:
136
+ code_vector_store = FAISS.from_embeddings(zip(all_code_chunks, all_code_vectors), embeddings_model.encode)
137
+
138
+ return text_vector_store, code_vector_store, combined_text, combined_code
139
+
140
+ # ----------- Streamlit UI -----------
141
+
142
+ st.set_page_config(page_title="Smart PDF Q&A", page_icon="📄", layout="wide")
143
+
144
+ # Fixed CSS for chat colors
145
+ st.markdown("""
146
+ <style>
147
+ /* Chat container */
148
+ .chat-container {
149
+ border: 1px solid #ddd;
150
+ border-radius: 10px;
151
+ padding: 10px;
152
+ height: 60vh;
153
+ overflow-y: auto;
154
+ margin-top: 20px;
155
+ }
156
+
157
+ /* Chat bubbles */
158
+ .stChatMessage {
159
+ border-radius: 15px;
160
+ padding: 10px;
161
+ margin: 5px;
162
+ max-width: 70%;
163
+ word-wrap: break-word;
164
+ }
165
+
166
+ /* User message */
167
+ .user {
168
+ background-color: #e6f3ff !important;
169
+ color: #000 !important;
170
+ align-self: flex-end;
171
+ text-align: right;
172
+ }
173
+
174
+ /* Assistant message */
175
+ .assistant {
176
+ background-color: #f0f0f0 !important;
177
+ color: #000 !important;
178
+ text-align: left;
179
+ }
180
+
181
+ /* Dark mode support */
182
+ body[data-theme="dark"] .user {
183
+ background-color: #2a2a72 !important;
184
+ color: #fff !important;
185
+ }
186
+ body[data-theme="dark"] .assistant {
187
+ background-color: #2e2e2e !important;
188
+ color: #fff !important;
189
+ }
190
+
191
+ /* Buttons */
192
+ .stButton>button {
193
+ background-color: #4CAF50;
194
+ color: white;
195
+ border: none;
196
+ padding: 8px 16px;
197
+ border-radius: 5px;
198
+ }
199
+ .stButton>button:hover {
200
+ background-color: #45a049;
201
+ }
202
+
203
+ /* Preformatted code */
204
+ pre {
205
+ background-color: #f8f8f8;
206
+ padding: 10px;
207
+ border-radius: 5px;
208
+ overflow-x: auto;
209
+ }
210
+
211
+ /* Header */
212
+ .header {
213
+ background: linear-gradient(90deg, #4CAF50, #81C784);
214
+ color: white;
215
+ padding: 10px;
216
+ border-radius: 5px;
217
+ text-align: center;
218
+ }
219
+ </style>
220
+ """, unsafe_allow_html=True)
221
+
222
+ st.markdown('<div class="header"><h1>Smart PDF Q&A</h1></div>', unsafe_allow_html=True)
223
+ st.markdown("Upload a PDF to ask questions, summarize (~150 words), or extract code with 'give me code'.")
224
+
225
+ # Session state
226
+ if "messages" not in st.session_state:
227
+ st.session_state.messages = []
228
+ if "text_vector_store" not in st.session_state:
229
+ st.session_state.text_vector_store = None
230
+ if "code_vector_store" not in st.session_state:
231
+ st.session_state.code_vector_store = None
232
+ if "pdf_text" not in st.session_state:
233
+ st.session_state.pdf_text = ""
234
+ if "code_text" not in st.session_state:
235
+ st.session_state.code_text = ""
236
+
237
+ # Preload dataset at start
238
+ if st.session_state.text_vector_store is None and st.session_state.code_vector_store is None:
239
+ st.session_state.text_vector_store, st.session_state.code_vector_store, st.session_state.pdf_text, st.session_state.code_text = preload_dataset()
240
  if st.session_state.text_vector_store or st.session_state.code_vector_store:
241
+ st.info("Preloaded sample dataset loaded for better QA and code retrieval.")
242
+
243
+ # PDF upload & buttons
244
+ uploaded_file = st.file_uploader("Upload a PDF", type=["pdf"])
245
+ col1, col2 = st.columns([1,1])
246
+ with col1:
247
+ if st.button("Process PDF") and uploaded_file:
248
+ with st.spinner("Processing PDF..."):
249
+ st.session_state.text_vector_store, st.session_state.code_vector_store, st.session_state.pdf_text, st.session_state.code_text = process_pdf(uploaded_file)
250
+ if st.session_state.text_vector_store or st.session_state.code_vector_store:
251
+ st.success("PDF processed! Ask away or summarize.")
252
+ st.session_state.messages = []
253
+ else:
254
+ st.error("Failed to process PDF.")
255
+
256
+ with col2:
257
+ if st.button("Summarize PDF") and st.session_state.pdf_text:
258
+ with st.spinner("Summarizing..."):
259
+ summary_pipeline = load_summary_pipeline()
260
+ text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", ".", " "])
261
+ chunks = text_splitter.split_text(st.session_state.pdf_text)[:2]
262
+ summaries = []
263
+ for chunk in chunks:
264
+ summary = summary_pipeline(chunk[:500], max_length=100, min_length=30, do_sample=False)[0]['summary_text']
265
+ summaries.append(summary.strip())
266
+ combined_summary = " ".join(summaries)
267
+ st.session_state.messages.append({"role":"assistant","content":combined_summary})
268
+ st.markdown(combined_summary)
269
+
270
+ # Chat interface
271
+ st.markdown('<div class="chat-container">', unsafe_allow_html=True)
272
+ prompt = st.chat_input("Ask a question (e.g., 'Give me code' or 'What’s the main idea?'):")
273
+ if prompt:
274
+ st.session_state.messages.append({"role":"user","content":prompt})
275
+ with st.chat_message("user"):
276
+ st.markdown(f"<div class='user'>{prompt}</div>", unsafe_allow_html=True)
277
+ with st.chat_message("assistant"):
278
+ qa_pipeline = load_qa_pipeline()
279
+ is_code_query = any(k in prompt.lower() for k in ["code","script","function","programming","give me code","show code"])
280
+ if is_code_query and st.session_state.code_vector_store:
281
+ answer = f"Here's the code from the PDF:\n```python\n{st.session_state.code_text}\n```"
282
+ elif st.session_state.text_vector_store:
283
+ docs = st.session_state.text_vector_store.similarity_search(prompt, k=5)
284
+ context = "\n".join(doc.page_content for doc in docs)
285
+ answer = qa_pipeline(f"Context: {context}\nQuestion: {prompt}\nProvide a detailed answer.")[0]['generated_text']
286
+ else:
287
+ answer = "Please upload a PDF first!"
288
+ st.markdown(f"<div class='assistant'>{answer}</div>", unsafe_allow_html=True)
289
+ st.session_state.messages.append({"role":"assistant","content":answer})
290
+
291
+ # Display chat history
292
+ for msg in st.session_state.messages:
293
+ cls = "user" if msg["role"]=="user" else "assistant"
294
+ st.markdown(f"<div class='{cls}' style='margin:5px;padding:10px;border-radius:15px;'>{msg['content']}</div>", unsafe_allow_html=True)
295
+ st.markdown('</div>', unsafe_allow_html=True)
296
+
297
+ # Download chat
298
+ if st.session_state.messages:
299
+ chat_text = "\n".join(f"{m['role'].capitalize()}: {m['content']}" for m in st.session_state.messages)
300
+ st.download_button("Download Chat History", chat_text, "chat_history.txt")