Soham Waghmare commited on
Commit
79ae05b
·
1 Parent(s): 62283c0

feat: frontend - QOL; backend - QOL

Browse files
.gitignore CHANGED
@@ -49,3 +49,5 @@ frontend/.vercel
49
  # typescript
50
  frontend/*.tsbuildinfo
51
  frontend/next-env.d.ts
 
 
 
49
  # typescript
50
  frontend/*.tsbuildinfo
51
  frontend/next-env.d.ts
52
+
53
+ frontend/.copilotignore
backend/app.py CHANGED
@@ -1,5 +1,8 @@
 
1
  import json
2
  import logging
 
 
3
  from typing import Dict
4
 
5
  import socketio
@@ -17,12 +20,7 @@ logging.basicConfig(level=logging.INFO)
17
  logger = logging.getLogger(__name__)
18
 
19
  app = FastAPI()
20
- CORS_ALLOWED_ORIGINS = [
21
- "*",
22
- "http://localhost:3000",
23
- "http://127.0.0.1:3000",
24
- "https://knowledge-net.vercel.app",
25
- ]
26
  app.add_middleware(
27
  CORSMiddleware,
28
  allow_origins=CORS_ALLOWED_ORIGINS,
@@ -43,6 +41,7 @@ app.mount("/", socketio.ASGIApp(sio))
43
  class SessionManager:
44
  def __init__(self):
45
  self.sessions: Dict[str, tuple[KNet, CrawlForAIScraper]] = {}
 
46
 
47
  async def get_or_create_session(self, sid: str) -> tuple[KNet, CrawlForAIScraper]:
48
  if sid not in self.sessions:
@@ -53,11 +52,27 @@ class SessionManager:
53
  return self.sessions[sid]
54
 
55
  async def cleanup_session(self, sid: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  if sid in self.sessions:
57
  _, scraper = self.sessions[sid]
58
  await scraper.close()
59
  del self.sessions[sid]
60
 
 
 
 
61
 
62
  session_manager = SessionManager()
63
 
@@ -96,7 +111,13 @@ async def start_research(sid, data):
96
  async def progress_callback(status: dict):
97
  await sio.emit("status", status, room=session_id)
98
 
99
- research_results = await knet.conduct_research(topic, progress_callback, max_depth, num_sites_per_query)
 
 
 
 
 
 
100
  logger.info(f"Research completed for topic: {topic}")
101
  await sio.emit("research_complete", research_results, room=session_id)
102
 
@@ -105,14 +126,40 @@ async def start_research(sid, data):
105
  await sio.emit("error", {"message": str(e)}, room=session_id)
106
 
107
 
 
 
 
 
 
 
108
  @sio.event
109
  async def test(sid, data):
110
- knet, _ = await session_manager.get_or_create_session(sid)
111
- print("Testing...")
112
  data = json.loads(data) if type(data) is not dict else data
113
- res = await knet.scraper._scrape_page(data["url"])
114
- print(json.dumps(res, indent=2))
115
- await sio.emit("test", res, room=sid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
 
118
  if __name__ == "__main__":
 
1
+ import asyncio
2
  import json
3
  import logging
4
+ import os
5
+ import time
6
  from typing import Dict
7
 
8
  import socketio
 
20
  logger = logging.getLogger(__name__)
21
 
22
  app = FastAPI()
23
+ CORS_ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", ",").split(",")
 
 
 
 
 
24
  app.add_middleware(
25
  CORSMiddleware,
26
  allow_origins=CORS_ALLOWED_ORIGINS,
 
41
  class SessionManager:
42
  def __init__(self):
43
  self.sessions: Dict[str, tuple[KNet, CrawlForAIScraper]] = {}
44
+ self.tasks: Dict[str, asyncio.Task] = {} # Track research tasks for each session
45
 
46
  async def get_or_create_session(self, sid: str) -> tuple[KNet, CrawlForAIScraper]:
47
  if sid not in self.sessions:
 
52
  return self.sessions[sid]
53
 
54
  async def cleanup_session(self, sid: str):
55
+ # Cancel running task if it exists
56
+ if sid in self.tasks and not self.tasks[sid].done():
57
+ self.tasks[sid].cancel()
58
+ try:
59
+ await self.tasks[sid]
60
+ except asyncio.CancelledError:
61
+ logger.info(f"Research task for session {sid} was cancelled")
62
+ except Exception as e:
63
+ logger.error(f"Error while cancelling task for {sid}: {str(e)}")
64
+ finally:
65
+ del self.tasks[sid]
66
+
67
+ # Clean up session resources
68
  if sid in self.sessions:
69
  _, scraper = self.sessions[sid]
70
  await scraper.close()
71
  del self.sessions[sid]
72
 
73
+ def register_task(self, sid: str, task: asyncio.Task):
74
+ self.tasks[sid] = task
75
+
76
 
77
  session_manager = SessionManager()
78
 
 
111
  async def progress_callback(status: dict):
112
  await sio.emit("status", status, room=session_id)
113
 
114
+ task = asyncio.create_task(knet.conduct_research(topic, progress_callback, max_depth, num_sites_per_query))
115
+ session_manager.register_task(sid, task)
116
+ research_results = await task
117
+
118
+ if not research_results:
119
+ sio.emit("research_aborted", room=session_id)
120
+
121
  logger.info(f"Research completed for topic: {topic}")
122
  await sio.emit("research_complete", research_results, room=session_id)
123
 
 
126
  await sio.emit("error", {"message": str(e)}, room=session_id)
127
 
128
 
129
+ @sio.event
130
+ async def abort_research(sid):
131
+ logger.info(f"Aborting research for client {sid}")
132
+ await session_manager.cleanup_session(sid)
133
+
134
+
135
  @sio.event
136
  async def test(sid, data):
 
 
137
  data = json.loads(data) if type(data) is not dict else data
138
+ topic = data.get("topic").strip().replace("\n", "")
139
+ logger.info(json.dumps(data, indent=2))
140
+
141
+ knet, _ = await session_manager.get_or_create_session(sid)
142
+ time.sleep(1)
143
+
144
+ async def progress_callback(status: dict):
145
+ await sio.emit("status", status, room=sid)
146
+
147
+ # Create a task and register it for proper cancellation
148
+ task = asyncio.create_task(knet.test(topic, progress_callback))
149
+ session_manager.register_task(sid, task)
150
+
151
+ try:
152
+ await task
153
+
154
+ with open("output.log.json", "r") as f:
155
+ data = json.load(f)
156
+ await sio.emit("research_complete", data, room=sid)
157
+ except asyncio.CancelledError:
158
+ logger.info(f"Test task for '{topic}' was cancelled")
159
+ await sio.emit("research_aborted", room=sid)
160
+ except Exception as e:
161
+ logger.error(f"Test error: {str(e)}")
162
+ await sio.emit("error", {"message": str(e)}, room=sid)
163
 
164
 
165
  if __name__ == "__main__":
backend/knet.py CHANGED
@@ -1,6 +1,8 @@
 
1
  import json
2
  import logging
3
  import os
 
4
  from collections import deque
5
  from datetime import datetime
6
  from textwrap import dedent
@@ -13,9 +15,11 @@ from google.genai import types
13
  from research_node import ResearchNode
14
  from scraper import CrawlForAIScraper
15
 
16
- # Load environment variables
17
  load_dotenv()
18
 
 
 
 
19
 
20
  class Prompt:
21
  def __init__(self) -> None:
@@ -51,7 +55,7 @@ class Prompt:
51
 
52
  Return only decision: true/false""")
53
 
54
- self.search_query = dedent("""Based on the following findings on topic {vertical}, suggest new research directions.
55
  Global Research Plan:
56
  {research_plan}
57
 
@@ -67,6 +71,8 @@ class Prompt:
67
  - Explores different aspects
68
  - Goes deeper into important details
69
 
 
 
70
  Return as JSON array of objects with properties:
71
  - query (string)""")
72
 
@@ -102,6 +108,9 @@ class Prompt:
102
  Do not include the heading in the content.
103
  """)
104
 
 
 
 
105
 
106
  class Schema:
107
  def __init__(self) -> None:
@@ -177,7 +186,7 @@ class KNet:
177
  self.ctx_manager: list[str] = []
178
  self.token_count: int = 0
179
 
180
- async def conduct_research(self, topic: str, progress_callback, max_depth: int, num_sites_per_query: int) -> dict:
181
  # Local Runtime State
182
  self.progress = ResearchProgress(progress_callback)
183
  self.max_depth = max_depth
@@ -193,6 +202,8 @@ class KNet:
193
  try:
194
  # Generate research plan
195
  await self.progress.update(0, "Generating research plan...")
 
 
196
  self.research_plan = self.generate_content(self.prompt.research_plan.format(topic=topic), schema=self.schema.research_plan, temp=1.5)[
197
  "steps"
198
  ]
@@ -204,6 +215,8 @@ class KNet:
204
 
205
  # Iterate on research plan
206
  for self.idx_research_plan, _ in enumerate(self.research_plan):
 
 
207
  # Generate initial search query
208
  query = self.generate_content(
209
  self.prompt.search_query.format(
@@ -221,6 +234,8 @@ class KNet:
221
  await self.progress.update(100 / (len(self.research_plan) + 1), f"{self.research_plan[self.idx_research_plan]}")
222
 
223
  while to_explore:
 
 
224
  current_node, current_depth = to_explore.popleft()
225
  if current_depth > self.max_depth:
226
  continue
@@ -242,6 +257,8 @@ class KNet:
242
  for branch in new_branches:
243
  to_explore.appendleft((branch, current_depth + 1))
244
 
 
 
245
  # Generate final report
246
  await self.progress.update(100 / (len(self.research_plan) + 1), "Generating final report...")
247
  final_report = await self._generate_final_report(master_node, topic)
@@ -253,24 +270,38 @@ class KNet:
253
  json.dump(final_report, f, indent=2)
254
  return final_report
255
 
 
 
 
256
  except Exception:
257
  self.logger.error("Research failed", exc_info=True)
258
  raise
259
 
 
 
 
 
 
260
  async def _generate_final_report(self, root_node: ResearchNode, topic: str, retry_count: int = 1) -> Dict[str, Any]:
261
  try:
 
 
262
  await self.progress.setter(0, "Generating report...")
263
  findings = "\n\n------\n\n".join(self.ctx_manager)
264
  with open("ctx_manager.log.txt", "w", encoding="utf-8") as f:
265
  f.write(findings)
266
 
267
  # Generate report outline
 
268
  outline = self.generate_content(self.prompt.report_outline.format(topic=topic, ctx_manager=findings), schema=self.schema.report_outline)
269
  self.logger.info(f"Report outline:\n{json.dumps(outline, indent=2)}")
270
  report = []
271
  raster_report = f"# {outline['title']}\n\n"
 
272
  # Fill in report outline
273
  for i, heading in enumerate(outline["headings"]):
 
 
274
  await self.progress.update(100 / (len(outline["headings"]) + 1), "Generating report...")
275
  content = self.generate_content(
276
  self.prompt.report_fillin.format(
@@ -290,7 +321,7 @@ class KNet:
290
  raster_report += f"\n\n## {heading}\n\n{content}"
291
 
292
  # Collate multimedia content
293
- media_content = {"images": [], "videos": [], "links": [], "references": []}
294
  all_sources_data = root_node.get_all_data()
295
  for data in all_sources_data:
296
  if data.get("images"):
@@ -331,12 +362,14 @@ class KNet:
331
  },
332
  }
333
 
 
 
334
  except Exception as e:
335
  if e in ["GEMINI_RECITATION", "NO_RESPONSE"]:
336
  self.logger.error("GEMINI_RECITATION or NO_RESPONSE")
337
  if retry_count < 3:
338
  self.logger.error(f"Retrying final report:C:{retry_count} / 3", exc_info=True)
339
- return await self._generate_final_report(root_node, retry_count + 1)
340
  self.logger.error("Error generating final report", exc_info=True)
341
  raise
342
 
@@ -408,7 +441,7 @@ class KNet:
408
  self.logger.error("Branch decision failed:", exc_info=True)
409
  raise
410
 
411
- def generate_content(self, prompt: str, schema: Dict[str, Any] = {}, temp: float = 1, _retry_count: int = 1) -> Dict[str, Any] | str:
412
  safe = [
413
  types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=types.HarmBlockThreshold.BLOCK_NONE),
414
  types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold=types.HarmBlockThreshold.BLOCK_NONE),
@@ -435,3 +468,27 @@ class KNet:
435
  if response.candidates[0].finish_reason == types.FinishReason.RECITATION:
436
  raise Exception("GEMINI_RECITATION")
437
  raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import json
3
  import logging
4
  import os
5
+ import time
6
  from collections import deque
7
  from datetime import datetime
8
  from textwrap import dedent
 
15
  from research_node import ResearchNode
16
  from scraper import CrawlForAIScraper
17
 
 
18
  load_dotenv()
19
 
20
+ # Today's Date | Format 15th Dec, 2025
21
+ DATE = datetime.now().strftime("%d %b, %Y")
22
+
23
 
24
  class Prompt:
25
  def __init__(self) -> None:
 
55
 
56
  Return only decision: true/false""")
57
 
58
+ self.search_query = dedent("""Based on the following findings on topic {vertical}, create google search queries
59
  Global Research Plan:
60
  {research_plan}
61
 
 
71
  - Explores different aspects
72
  - Goes deeper into important details
73
 
74
+ - Do not do quote searches
75
+ - Keep the queries short and to the point
76
  Return as JSON array of objects with properties:
77
  - query (string)""")
78
 
 
108
  Do not include the heading in the content.
109
  """)
110
 
111
+ for prompt in [self.research_plan, self.site_summary, self.continue_branch, self.search_query]:
112
+ prompt += f"\n\nFYI Date {DATE}"
113
+
114
 
115
  class Schema:
116
  def __init__(self) -> None:
 
186
  self.ctx_manager: list[str] = []
187
  self.token_count: int = 0
188
 
189
+ async def conduct_research(self, topic: str, progress_callback, max_depth: int, num_sites_per_query: int) -> dict | bool:
190
  # Local Runtime State
191
  self.progress = ResearchProgress(progress_callback)
192
  self.max_depth = max_depth
 
202
  try:
203
  # Generate research plan
204
  await self.progress.update(0, "Generating research plan...")
205
+ self._check_cancelled()
206
+
207
  self.research_plan = self.generate_content(self.prompt.research_plan.format(topic=topic), schema=self.schema.research_plan, temp=1.5)[
208
  "steps"
209
  ]
 
215
 
216
  # Iterate on research plan
217
  for self.idx_research_plan, _ in enumerate(self.research_plan):
218
+ self._check_cancelled()
219
+
220
  # Generate initial search query
221
  query = self.generate_content(
222
  self.prompt.search_query.format(
 
234
  await self.progress.update(100 / (len(self.research_plan) + 1), f"{self.research_plan[self.idx_research_plan]}")
235
 
236
  while to_explore:
237
+ self._check_cancelled()
238
+
239
  current_node, current_depth = to_explore.popleft()
240
  if current_depth > self.max_depth:
241
  continue
 
257
  for branch in new_branches:
258
  to_explore.appendleft((branch, current_depth + 1))
259
 
260
+ self._check_cancelled()
261
+
262
  # Generate final report
263
  await self.progress.update(100 / (len(self.research_plan) + 1), "Generating final report...")
264
  final_report = await self._generate_final_report(master_node, topic)
 
270
  json.dump(final_report, f, indent=2)
271
  return final_report
272
 
273
+ except asyncio.CancelledError:
274
+ self.logger.info(f"Research task for topic '{topic}' was cancelled")
275
+ return {"status": False}
276
  except Exception:
277
  self.logger.error("Research failed", exc_info=True)
278
  raise
279
 
280
+ def _check_cancelled(self):
281
+ """Check if the current task has been cancelled and raise CancelledError if so"""
282
+ if asyncio.current_task() and asyncio.current_task().cancelled():
283
+ raise asyncio.CancelledError("Research task was cancelled")
284
+
285
  async def _generate_final_report(self, root_node: ResearchNode, topic: str, retry_count: int = 1) -> Dict[str, Any]:
286
  try:
287
+ self._check_cancelled()
288
+
289
  await self.progress.setter(0, "Generating report...")
290
  findings = "\n\n------\n\n".join(self.ctx_manager)
291
  with open("ctx_manager.log.txt", "w", encoding="utf-8") as f:
292
  f.write(findings)
293
 
294
  # Generate report outline
295
+ self._check_cancelled()
296
  outline = self.generate_content(self.prompt.report_outline.format(topic=topic, ctx_manager=findings), schema=self.schema.report_outline)
297
  self.logger.info(f"Report outline:\n{json.dumps(outline, indent=2)}")
298
  report = []
299
  raster_report = f"# {outline['title']}\n\n"
300
+
301
  # Fill in report outline
302
  for i, heading in enumerate(outline["headings"]):
303
+ self._check_cancelled()
304
+
305
  await self.progress.update(100 / (len(outline["headings"]) + 1), "Generating report...")
306
  content = self.generate_content(
307
  self.prompt.report_fillin.format(
 
321
  raster_report += f"\n\n## {heading}\n\n{content}"
322
 
323
  # Collate multimedia content
324
+ media_content = {"images": [], "videos": [], "links": []}
325
  all_sources_data = root_node.get_all_data()
326
  for data in all_sources_data:
327
  if data.get("images"):
 
362
  },
363
  }
364
 
365
+ except asyncio.CancelledError:
366
+ raise
367
  except Exception as e:
368
  if e in ["GEMINI_RECITATION", "NO_RESPONSE"]:
369
  self.logger.error("GEMINI_RECITATION or NO_RESPONSE")
370
  if retry_count < 3:
371
  self.logger.error(f"Retrying final report:C:{retry_count} / 3", exc_info=True)
372
+ return await self._generate_final_report(root_node, topic, retry_count + 1)
373
  self.logger.error("Error generating final report", exc_info=True)
374
  raise
375
 
 
441
  self.logger.error("Branch decision failed:", exc_info=True)
442
  raise
443
 
444
+ def generate_content(self, prompt: str, schema: Dict[str, Any] = {}, temp: float = 1) -> Dict[str, Any] | str:
445
  safe = [
446
  types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=types.HarmBlockThreshold.BLOCK_NONE),
447
  types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold=types.HarmBlockThreshold.BLOCK_NONE),
 
468
  if response.candidates[0].finish_reason == types.FinishReason.RECITATION:
469
  raise Exception("GEMINI_RECITATION")
470
  raise
471
+
472
+ async def test(self, topic: str, progress_callback):
473
+ self.progress = ResearchProgress(progress_callback)
474
+ try:
475
+ for i in range(5):
476
+ self._check_cancelled()
477
+
478
+ await self.progress.setter(i * 10, f"Researching {topic} {i * 10}%")
479
+ time.sleep(1)
480
+ for j in range(5):
481
+ self._check_cancelled()
482
+
483
+ await self.progress.setter(i * 10, f"s_ example google search {str(j)}")
484
+ time.sleep(1)
485
+
486
+ for i in range(10):
487
+ self._check_cancelled()
488
+
489
+ await self.progress.setter(i * 10, "Generating report...")
490
+ time.sleep(1)
491
+
492
+ except asyncio.CancelledError:
493
+ self.logger.info(f"Test task for '{topic}' was cancelled")
494
+ raise
backend/scraper.py CHANGED
@@ -158,9 +158,10 @@ class WebScraper:
158
  class CrawlForAIScraper:
159
  def __init__(self) -> None:
160
  self.logger = logging.getLogger(__name__)
 
161
  self.base_browser = BrowserConfig(
162
  browser_type="chromium",
163
- headless=True,
164
  viewport_width=1920,
165
  viewport_height=1080,
166
  accept_downloads=False,
@@ -216,7 +217,7 @@ class CrawlForAIScraper:
216
  scan_full_page=True,
217
  )
218
 
219
- soup = BeautifulSoup(result.cleaned_html, "html.parser")
220
  search_results = []
221
 
222
  for link in list(soup.select("div > span > a"))[2:]:
@@ -227,16 +228,47 @@ class CrawlForAIScraper:
227
  continue
228
  search_results.append(url)
229
 
 
 
 
 
 
230
  self.logger.info(f"Found {len(search_results)} results")
231
  return search_results
232
 
233
- except requests.exceptions.RequestException as e:
234
- self.logger.error(f"Google search error: {str(e)}", exc_info=True)
235
- raise
236
  except Exception as e:
237
  self.logger.error(f"Google search error: {str(e)}", exc_info=True)
238
  raise
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  async def _scrape_pages(self, urls: str, max_sites: int) -> Dict[str, Any]:
241
  await self.start()
242
 
@@ -289,7 +321,7 @@ class CrawlForAIScraper:
289
  }
290
  scraped_sites.append(data)
291
  self.logger.info(f" - {result.url[:80]}...")
292
- return scraped_sites[: max_sites]
293
 
294
  except Exception as e:
295
  self.logger.error(f"Scraping error while {urls}: {str(e)}")
 
158
  class CrawlForAIScraper:
159
  def __init__(self) -> None:
160
  self.logger = logging.getLogger(__name__)
161
+ self.session = requests.Session()
162
  self.base_browser = BrowserConfig(
163
  browser_type="chromium",
164
+ headless=False,
165
  viewport_width=1920,
166
  viewport_height=1080,
167
  accept_downloads=False,
 
217
  scan_full_page=True,
218
  )
219
 
220
+ soup = BeautifulSoup(result.html, "html.parser")
221
  search_results = []
222
 
223
  for link in list(soup.select("div > span > a"))[2:]:
 
228
  continue
229
  search_results.append(url)
230
 
231
+ if not search_results:
232
+ self.logger.warning("No search results found.")
233
+ self.logger.info("Performing DuckDuckGo search as fallback...")
234
+ search_results = self._duckduckgo_search(query)
235
+
236
  self.logger.info(f"Found {len(search_results)} results")
237
  return search_results
238
 
 
 
 
239
  except Exception as e:
240
  self.logger.error(f"Google search error: {str(e)}", exc_info=True)
241
  raise
242
 
243
+ def _duckduckgo_search(self, query: str) -> List[str]:
244
+ self.logger.info("Performing DuckDuckGo search...")
245
+ try:
246
+ encoded_query = quote_plus(query)
247
+ url = f"https://html.duckduckgo.com/html/?q={encoded_query}"
248
+
249
+ response = self.session.get(url, headers=self.headers, timeout=self.timeout)
250
+ response.raise_for_status()
251
+
252
+ soup = BeautifulSoup(response.text, "html.parser")
253
+ search_results = []
254
+
255
+ # DuckDuckGo search results are in elements with class 'result__url'
256
+ for result in soup.select(".result__url"):
257
+ url = result.get("href").replace(" ", "").replace("\\n", "")
258
+ if not url.startswith(("http://", "https://")):
259
+ url = "https://" + url
260
+ search_results.append(url)
261
+
262
+ self.logger.info(f"Found {len(search_results)} URLs")
263
+ return search_results
264
+
265
+ except requests.exceptions.RequestException as e: # Catch network errors specifically
266
+ self.logger.error(f"DuckDuckGo search error: {str(e)}")
267
+ return []
268
+ except Exception as e: # Catch any other errors
269
+ self.logger.error(f"DuckDuckGo search error: {str(e)}")
270
+ return []
271
+
272
  async def _scrape_pages(self, urls: str, max_sites: int) -> Dict[str, Any]:
273
  await self.start()
274
 
 
321
  }
322
  scraped_sites.append(data)
323
  self.logger.info(f" - {result.url[:80]}...")
324
+ return scraped_sites[:max_sites]
325
 
326
  except Exception as e:
327
  self.logger.error(f"Scraping error while {urls}: {str(e)}")
frontend/bun.lock CHANGED
@@ -12,7 +12,7 @@
12
  "@radix-ui/react-scroll-area": "^1.2.3",
13
  "@radix-ui/react-select": "^2.1.6",
14
  "@radix-ui/react-separator": "^1.1.2",
15
- "@radix-ui/react-slot": "^1.1.2",
16
  "@radix-ui/react-tabs": "^1.1.3",
17
  "@radix-ui/react-tooltip": "^1.1.8",
18
  "@types/react-syntax-highlighter": "^15.5.13",
@@ -296,7 +296,7 @@
296
 
297
  "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ=="],
298
 
299
- "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
300
 
301
  "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng=="],
302
 
@@ -1476,6 +1476,20 @@
1476
 
1477
  "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
1478
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1479
  "@ts-morph/common/minimatch": ["minimatch@7.4.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw=="],
1480
 
1481
  "@typescript-eslint/parser/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
 
12
  "@radix-ui/react-scroll-area": "^1.2.3",
13
  "@radix-ui/react-select": "^2.1.6",
14
  "@radix-ui/react-separator": "^1.1.2",
15
+ "@radix-ui/react-slot": "^1.2.0",
16
  "@radix-ui/react-tabs": "^1.1.3",
17
  "@radix-ui/react-tooltip": "^1.1.8",
18
  "@types/react-syntax-highlighter": "^15.5.13",
 
296
 
297
  "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ=="],
298
 
299
+ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w=="],
300
 
301
  "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng=="],
302
 
 
1476
 
1477
  "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
1478
 
1479
+ "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
1480
+
1481
+ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
1482
+
1483
+ "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
1484
+
1485
+ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
1486
+
1487
+ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
1488
+
1489
+ "@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
1490
+
1491
+ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
1492
+
1493
  "@ts-morph/common/minimatch": ["minimatch@7.4.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw=="],
1494
 
1495
  "@typescript-eslint/parser/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
frontend/package.json CHANGED
@@ -17,7 +17,7 @@
17
  "@radix-ui/react-scroll-area": "^1.2.3",
18
  "@radix-ui/react-select": "^2.1.6",
19
  "@radix-ui/react-separator": "^1.1.2",
20
- "@radix-ui/react-slot": "^1.1.2",
21
  "@radix-ui/react-tabs": "^1.1.3",
22
  "@radix-ui/react-tooltip": "^1.1.8",
23
  "@types/react-syntax-highlighter": "^15.5.13",
 
17
  "@radix-ui/react-scroll-area": "^1.2.3",
18
  "@radix-ui/react-select": "^2.1.6",
19
  "@radix-ui/react-separator": "^1.1.2",
20
+ "@radix-ui/react-slot": "^1.2.0",
21
  "@radix-ui/react-tabs": "^1.1.3",
22
  "@radix-ui/react-tooltip": "^1.1.8",
23
  "@types/react-syntax-highlighter": "^15.5.13",
frontend/src/app/layout.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { ThemeProvider } from "@/components/theme-provider";
 
2
  import type { Metadata } from "next";
3
  import "./globals.css";
4
 
@@ -12,7 +13,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
12
  <html lang="en" suppressHydrationWarning>
13
  <body>
14
  <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
15
- {children}
16
  </ThemeProvider>
17
  </body>
18
  </html>
 
1
  import { ThemeProvider } from "@/components/theme-provider";
2
+ import { ChatProvider } from "@/lib/store/ChatContext";
3
  import type { Metadata } from "next";
4
  import "./globals.css";
5
 
 
13
  <html lang="en" suppressHydrationWarning>
14
  <body>
15
  <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
16
+ <ChatProvider>{children}</ChatProvider>
17
  </ThemeProvider>
18
  </body>
19
  </html>
frontend/src/app/page.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import ChatInterface from '@/components/ChatInterface';
2
 
3
  export default function Home() {
4
  return <ChatInterface />;
 
1
+ import ChatInterface from "@/components/ChatInterface";
2
 
3
  export default function Home() {
4
  return <ChatInterface />;
frontend/src/components/ChatHistory.tsx CHANGED
@@ -1,8 +1,9 @@
1
  "use client";
2
  import { ScrollArea } from "@/components/ui/scroll-area";
 
3
  import { Message as MessageType } from "@/lib/types";
4
- import { Loader2 } from "lucide-react";
5
- import React, { useEffect, useRef } from "react";
6
  import Message from "./Message";
7
 
8
  interface ChatHistoryProps {
@@ -10,54 +11,39 @@ interface ChatHistoryProps {
10
  isLoading: boolean;
11
  }
12
 
 
13
  const ChatHistory: React.FC<ChatHistoryProps> = ({ messages, isLoading }) => {
14
  const messagesEndRef = useRef<HTMLDivElement>(null);
 
15
 
16
  useEffect(() => {
17
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
18
  }, [messages]);
19
 
20
  return (
21
- <ScrollArea tabIndex={-1}>
22
- <div className="pb-24 focus-visible:outline-0" tabIndex={-1}>
23
- {messages.length === 0 ? (
24
- <div className="h-full flex flex-col items-center justify-center p-8 text-center">
25
- <div className="max-w-md space-y-2">
26
- <h2 className="text-2xl font-bold">Welcome to the KNet demo</h2>
27
- <p className="text-muted-foreground">Ask any research question to get started. The assistant will provide detailed answers backed by research and sources.</p>
28
- </div>
29
  </div>
30
- ) : (
31
- messages.map((message) => <Message key={message.id} message={message} />)
32
- )}
 
 
 
33
 
34
- {isLoading && (
35
- <div className="my-2 mx-4">
36
- <div className="max-w-2xl mx-auto flex gap-4 relative">
37
- <div className="h-8 w-8 rounded-full shrink-0 bg-muted flex items-center justify-center absolute -left-12 top-0">
38
- <Loader2 className="h-5 w-5 animate-spin" />
39
- </div>
40
- <div className="flex-1">
41
- <div className="flex items-center gap-2 mb-1">
42
- <div className="font-medium">KNet</div>
43
- <div className="text-xs text-muted-foreground">Just now</div>
44
- </div>
45
- <div className="mt-1 bg-muted/50 p-3 rounded-2xl rounded-tl-sm">
46
- <div className="flex space-x-2">
47
- <div className="w-2 h-2 rounded-full bg-muted-foreground/30 animate-pulse"></div>
48
- <div className="w-2 h-2 rounded-full bg-muted-foreground/30 animate-pulse" style={{ animationDelay: "300ms" }}></div>
49
- <div className="w-2 h-2 rounded-full bg-muted-foreground/30 animate-pulse" style={{ animationDelay: "600ms" }}></div>
50
- </div>
51
- </div>
52
- </div>
53
- </div>
54
- </div>
55
- )}
56
-
57
- <div ref={messagesEndRef} />
58
- </div>
59
- </ScrollArea>
60
  );
61
  };
62
 
 
 
 
 
 
 
63
  export default ChatHistory;
 
1
  "use client";
2
  import { ScrollArea } from "@/components/ui/scroll-area";
3
+ import { useChatContext } from "@/lib/store/ChatContext";
4
  import { Message as MessageType } from "@/lib/types";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import React, { useEffect, useRef, useState } from "react";
7
  import Message from "./Message";
8
 
9
  interface ChatHistoryProps {
 
11
  isLoading: boolean;
12
  }
13
 
14
+ // Traditional prop-based component
15
  const ChatHistory: React.FC<ChatHistoryProps> = ({ messages, isLoading }) => {
16
  const messagesEndRef = useRef<HTMLDivElement>(null);
17
+ const [lastProgressMessage, setLastProgressMessage] = useState<string>("");
18
 
19
  useEffect(() => {
20
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
21
  }, [messages]);
22
 
23
  return (
24
+ <div className="pb-24 overflow-auto focus-visible:outline-0" tabIndex={-1}>
25
+ {messages.length === 0 ? (
26
+ <div className="h-full flex flex-col items-center justify-center p-8 text-center">
27
+ <div className="max-w-md space-y-2">
28
+ <h2 className="text-2xl font-bold">Welcome to the KNet demo</h2>
29
+ <p className="text-muted-foreground">Ask any research question to get started. The assistant will provide detailed answers backed by research and sources.</p>
 
 
30
  </div>
31
+ </div>
32
+ ) : (
33
+ messages.map((message) => {
34
+ return <Message key={message.id} message={message} lastProgressMessage={lastProgressMessage} setLastProgressMessage={setLastProgressMessage} />;
35
+ })
36
+ )}
37
 
38
+ <div ref={messagesEndRef} />
39
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  );
41
  };
42
 
43
+ // Context-based component
44
+ export const ChatHistoryWithContext: React.FC = () => {
45
+ const { chatState } = useChatContext();
46
+ return <ChatHistory messages={chatState.messages} isLoading={chatState.isLoading} />;
47
+ };
48
+
49
  export default ChatHistory;
frontend/src/components/ChatInterface.tsx CHANGED
@@ -1,317 +1,17 @@
1
  "use client";
2
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3
- import { disconnectSocket, getSocket, initializeSocket } from "@/lib/socket";
4
- import { ChatData, ChatState, Conversation, Message, ResearchOptions, ResearchResults, StatusUpdate } from "@/lib/types";
5
- import { useEffect, useRef, useState } from "react";
6
- import { v4 as uuidv4 } from "uuid";
7
  import ChatHistory from "./ChatHistory";
8
  import MessageInput from "./MessageInput";
9
  import ResearchControls from "./ResearchControls";
10
  import ChatLayout from "./ui/ChatLayout";
11
  import ConversationList from "./ui/ConversationList";
12
 
13
- const saveToStorage = (data: ChatData) => {
14
- if (typeof window !== "undefined") {
15
- localStorage.setItem("chatData", JSON.stringify(data));
16
- }
17
- };
18
-
19
- const loadFromStorage = (): ChatData => {
20
- if (typeof window === "undefined") {
21
- return { conversations: [], currentConversationId: null };
22
- }
23
- const data = localStorage.getItem("chatData");
24
- if (!data) {
25
- return { conversations: [], currentConversationId: null };
26
- }
27
- try {
28
- const parsed = JSON.parse(data);
29
- return {
30
- conversations: Array.isArray(parsed.conversations)
31
- ? parsed.conversations.map((conv: Conversation) => ({
32
- ...conv,
33
- messages: Array.isArray(conv.messages)
34
- ? conv.messages.map((msg: Message) => ({
35
- ...msg,
36
- // Ensure media property is preserved if it exists
37
- media: msg.media || undefined,
38
- }))
39
- : [],
40
- }))
41
- : [],
42
- currentConversationId: parsed.currentConversationId,
43
- };
44
- } catch (e) {
45
- return { conversations: [], currentConversationId: null };
46
- }
47
- };
48
-
49
  const ChatInterface = () => {
50
- const [chatState, setChatState] = useState<ChatState>({ messages: [], isLoading: false, error: null });
51
- const [conversations, setConversations] = useState<Conversation[]>([]);
52
- const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
53
-
54
- const [researchOptions, setResearchOptions] = useState<ResearchOptions>({
55
- depth: "basic",
56
- sources: true,
57
- citations: false,
58
- max_depth: 1,
59
- num_sites_per_query: 3,
60
- });
61
-
62
- const userInputRef = useRef<HTMLTextAreaElement>(null);
63
-
64
- // Add this effect for focus management
65
- useEffect(() => {
66
- const focusInput = () => {
67
- setTimeout(() => {
68
- userInputRef.current?.focus();
69
- }, 100);
70
- };
71
-
72
- focusInput();
73
- }, [currentConversationId]);
74
-
75
- // Initialize socket once
76
- useEffect(() => {
77
- const socket = initializeSocket();
78
-
79
- socket.on("connect", () => {
80
- console.log("Connected to research server");
81
- });
82
-
83
- socket.on("disconnect", () => {
84
- console.log("Disconnected from research server");
85
- });
86
-
87
- socket.on("status", (data: StatusUpdate) => {
88
- setChatState((prevState) => {
89
- const messages = [...prevState.messages];
90
- const progressText = `(${data.progress}%) ${data.message}`;
91
-
92
- // Find the last assistant message that is a progress update
93
- const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.content.includes("%)"));
94
-
95
- if (lastProgressIndex !== -1) {
96
- // Update existing progress message
97
- messages[lastProgressIndex] = {
98
- ...messages[lastProgressIndex],
99
- content: progressText,
100
- };
101
- } else {
102
- // Add new progress message
103
- messages.push({
104
- id: uuidv4(),
105
- content: progressText,
106
- role: "assistant",
107
- timestamp: new Date(),
108
- });
109
- }
110
-
111
- return {
112
- ...prevState,
113
- messages,
114
- isLoading: true,
115
- };
116
- });
117
- });
118
-
119
- socket.on("research_complete", (results: ResearchResults) => {
120
- setChatState((prevState) => {
121
- const messages = [...prevState.messages];
122
-
123
- // Remove the last progress message if it exists
124
- const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.content.includes("%)"));
125
- if (lastProgressIndex !== -1) {
126
- messages.splice(lastProgressIndex, 1);
127
- }
128
-
129
- // Format research stats and response
130
- const stats = [`Total Queries: ${results.metadata.total_queries}`, `Sources Used: ${results.metadata.total_sources}`, `Search Depth: ${results.metadata.max_depth_reached}`].join(" | ");
131
-
132
- // Format images in a way the Message component can extract
133
- const imageMarkdown = results.media?.images?.length ? `\n\n**Relevant Images:**\n${results.media.images.map((img) => `![Image](${img})`).join("\n")}` : "";
134
-
135
- const formattedResponse = [results.content, `\n\n---\n**Research Stats:**\n${stats}`, imageMarkdown].join("");
136
-
137
- const newMessages = [
138
- ...messages,
139
- {
140
- id: uuidv4(),
141
- content: formattedResponse,
142
- role: "assistant" as const,
143
- timestamp: new Date(results.timestamp),
144
- // Store the media object directly with the message
145
- media: results.media,
146
- },
147
- ];
148
-
149
- // Save updated messages to localStorage
150
- const updatedState = {
151
- ...prevState,
152
- isLoading: false,
153
- messages: newMessages,
154
- };
155
-
156
- // Update localStorage with the new messages
157
- const updatedData: ChatData = {
158
- conversations: conversations.map((conv) => ({
159
- ...conv,
160
- messages: conv.id === currentConversationId ? newMessages : conv.messages || [],
161
- lastUpdated: conv.id === currentConversationId ? new Date().toISOString() : conv.lastUpdated,
162
- })),
163
- currentConversationId,
164
- };
165
- saveToStorage(updatedData);
166
-
167
- return updatedState;
168
- });
169
- });
170
-
171
- socket.on("error", (error: { message: string }) => {
172
- setChatState((prevState) => ({
173
- ...prevState,
174
- error: error.message,
175
- isLoading: false,
176
- }));
177
- });
178
-
179
- return () => {
180
- disconnectSocket();
181
- };
182
- }, []); // Empty dependency array
183
-
184
- // Load initial data
185
- useEffect(() => {
186
- const data = loadFromStorage();
187
- setConversations(data.conversations);
188
- setCurrentConversationId(data.currentConversationId);
189
-
190
- if (data.currentConversationId) {
191
- const conversation = data.conversations.find((c) => c.id === data.currentConversationId);
192
- if (conversation) {
193
- setChatState((prev) => ({ ...prev, messages: conversation.messages }));
194
- }
195
- }
196
- }, []);
197
-
198
- useEffect(() => {
199
- const currentConv = conversations.find((c) => c.id === currentConversationId);
200
-
201
- const data: ChatData = {
202
- conversations: conversations.map((conv) => ({
203
- ...conv,
204
- messages: conv.id === currentConversationId ? chatState.messages : conv.messages || [],
205
- })),
206
- currentConversationId,
207
- };
208
-
209
- saveToStorage(data);
210
- }, [conversations, currentConversationId, chatState.messages]);
211
-
212
- const handleSendMessage = (content: string) => {
213
- if (!content.trim()) return;
214
-
215
- let conversationId = currentConversationId;
216
- const newMessage: Message = {
217
- id: uuidv4(),
218
- content,
219
- role: "user",
220
- timestamp: new Date(),
221
- };
222
-
223
- // Create a new conversation if none exists
224
- if (!conversationId) {
225
- conversationId = uuidv4();
226
- setCurrentConversationId(conversationId);
227
- setConversations((prev) => [
228
- {
229
- id: conversationId as string,
230
- title: content.length > 30 ? `${content.substring(0, 30)}...` : content,
231
- lastUpdated: new Date().toISOString(),
232
- messages: [newMessage],
233
- active: true,
234
- },
235
- ...prev.map((c) => ({ ...c, active: false })),
236
- ]);
237
- } else {
238
- // Update the existing conversation
239
- setConversations((prev) =>
240
- prev.map((conv) => ({
241
- ...conv,
242
- lastUpdated: conv.id === conversationId ? new Date().toISOString() : conv.lastUpdated,
243
- active: conv.id === conversationId,
244
- messages: conv.id === conversationId ? [...(conv.messages || []), newMessage] : conv.messages || [],
245
- }))
246
- );
247
- }
248
-
249
- setChatState((prevState) => ({
250
- ...prevState,
251
- messages: [...prevState.messages, newMessage],
252
- isLoading: true,
253
- error: null,
254
- }));
255
-
256
- // Send message to server via socket
257
- try {
258
- const socket = getSocket();
259
- socket.emit("start_research", {
260
- topic: content,
261
- max_depth: researchOptions.max_depth,
262
- num_sites_per_query: researchOptions.num_sites_per_query,
263
- });
264
- } catch (error) {
265
- setChatState((prevState) => ({
266
- ...prevState,
267
- error: "Failed to connect to research server",
268
- isLoading: false,
269
- }));
270
- }
271
- };
272
-
273
- const handleNewConversation = () => {
274
- userInputRef.current?.focus();
275
- setCurrentConversationId(null);
276
- setChatState(() => ({
277
- messages: [],
278
- isLoading: false,
279
- error: null,
280
- }));
281
- };
282
-
283
- const handleSelectConversation = (id: string) => {
284
- const data = loadFromStorage();
285
- const conversation = data.conversations.find((c) => c.id === id);
286
-
287
- setCurrentConversationId(id);
288
- setChatState((prev) => ({
289
- ...prev,
290
- messages: conversation?.messages || [],
291
- isLoading: false,
292
- error: null,
293
- }));
294
- setConversations((prev) =>
295
- prev.map((conv) => ({
296
- ...conv,
297
- active: conv.id === id,
298
- }))
299
- );
300
- };
301
-
302
- const handleDeleteConversation = (id: string) => {
303
- setConversations((prev) => prev.filter((conv) => conv.id !== id));
304
- if (currentConversationId === id) {
305
- handleNewConversation();
306
- }
307
- };
308
-
309
- const handleDeleteAllConversations = () => {
310
- setConversations([]);
311
- handleNewConversation();
312
- };
313
 
314
- const sidebar = <ConversationList conversations={conversations} onNewConversation={handleNewConversation} onSelectConversation={handleSelectConversation} onDeleteConversation={handleDeleteConversation} onDeleteAllConversations={handleDeleteAllConversations} />;
315
 
316
  const mainContent = (
317
  <div className="flex flex-col w-full h-full relative" tabIndex={-1}>
@@ -324,7 +24,7 @@ const ChatInterface = () => {
324
  </Alert>
325
  )}
326
 
327
- <MessageInput onSendMessage={handleSendMessage} isLoading={chatState.isLoading} userInputRef={userInputRef} />
328
  </div>
329
  );
330
 
 
1
  "use client";
2
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3
+ import { useChatContext } from "@/lib/store/ChatContext";
4
+ import React from "react";
 
 
5
  import ChatHistory from "./ChatHistory";
6
  import MessageInput from "./MessageInput";
7
  import ResearchControls from "./ResearchControls";
8
  import ChatLayout from "./ui/ChatLayout";
9
  import ConversationList from "./ui/ConversationList";
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  const ChatInterface = () => {
12
+ const { chatState, conversations, abortResearch, userInputRef, researchOptions, sendMessage, newConversation, selectConversation, deleteConversation, deleteAllConversations, setResearchOptions } = useChatContext();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ const sidebar = <ConversationList conversations={conversations} onNewConversation={newConversation} onSelectConversation={selectConversation} onDeleteConversation={deleteConversation} onDeleteAllConversations={deleteAllConversations} />;
15
 
16
  const mainContent = (
17
  <div className="flex flex-col w-full h-full relative" tabIndex={-1}>
 
24
  </Alert>
25
  )}
26
 
27
+ <MessageInput onSendMessage={sendMessage} isLoading={chatState.isLoading} userInputRef={userInputRef} onCancel={abortResearch} />
28
  </div>
29
  );
30
 
frontend/src/components/Message.tsx CHANGED
@@ -3,13 +3,125 @@ import { ScrollArea } from "@/components/ui/scroll-area";
3
  import { Button } from "@/components/ui/button";
4
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
6
- import { Message as MessageType } from "@/lib/types";
7
- import { Bot, Copy, MoreHorizontal, User2 } from "lucide-react";
8
- import React, { useState, useEffect } from "react";
9
  import ReactMarkdown from "react-markdown";
10
  import remarkGfm from "remark-gfm";
11
  import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
12
  import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  // ImageGallery component for handling images in a scrollable container
15
  const ImageGallery = ({ imageUrls }: { imageUrls: string[] }) => {
@@ -45,12 +157,12 @@ const ImageGallery = ({ imageUrls }: { imageUrls: string[] }) => {
45
  return (
46
  <div className="mt-4 mb-4">
47
  <h3 className="text-md font-semibold mb-2">Relevant Images:</h3>
48
- <ScrollArea className="w-full max-h-72 rounded-md border">
49
- <div className="p-2 space-y-3">
50
  {imageUrls.map((url, index) => (
51
- <div key={index} className="image-container">
52
  <img
53
- className="lazy-image rounded-md max-w-full"
54
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23f1f1f1'/%3E%3C/svg%3E"
55
  data-src={url}
56
  alt={`Research image ${index + 1}`}
@@ -112,65 +224,90 @@ const MarkdownComponents: Record<string, React.ComponentType<any>> = {
112
  ),
113
  blockquote: ({ children }) => <blockquote className="border-l-4 border-border pl-4 italic my-4">{children}</blockquote>,
114
  table: ({ children }) => (
115
- <div className="overflow-x-auto border rounded-2xl my-4">
116
- <table className="w-full border-collapse rounded-2xl overflow-hidden shadow-sm">{children}</table>
117
  </div>
118
  ),
119
  thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
120
  tbody: ({ children }) => <tbody>{children}</tbody>,
121
- th: ({ children }) => <th className="border-r last:border-r-0 border-slate-900 px-4 py-2 text-left font-semibold">{children}</th>,
122
  tr: ({ children }) => <tr className="border-b last:border-b-0 border-border">{children}</tr>,
123
- td: ({ children }) => <td className="border-r last:border-r-0 border-border px-4 py-2">{children}</td>,
124
  };
125
 
126
- const Message = ({ message }: { message: MessageType }) => {
127
- const isUser = message.role === "user";
128
- const isProgress = message.content.includes("%)") && message.role === "assistant";
 
 
 
 
 
 
129
  const [imageUrls, setImageUrls] = useState<string[]>([]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  // Extract image URLs from the message content or use the media object
132
  useEffect(() => {
133
- if (!isUser && !isProgress) {
 
 
 
 
 
 
134
  let urls: string[] = [];
135
 
136
  // First, check if there's a media object with images
137
  if (message.media?.images && message.media.images.length > 0) {
138
  urls = message.media.images;
139
- } else {
140
- // Fallback to extracting from markdown content
141
- const imgRegex = /!\[.*?\]\((.*?)\)/g;
142
- let match;
143
-
144
- while ((match = imgRegex.exec(message.content)) !== null) {
145
- if (match[1]) {
146
- urls.push(match[1]);
147
- }
148
- }
149
  }
150
-
151
  setImageUrls(urls);
152
  }
153
- }, [message.content, message.media, isUser, isProgress]);
154
 
155
- // Prepare message content without image markdown
156
- const cleanContent = message.content.replace(/!\[.*?\]\(.*?\)\n?/g, "").replace(/\*\*Relevant Images:\*\*\n/g, "");
 
 
 
157
 
158
  const copyToClipboard = () => {
159
- navigator.clipboard.writeText(message.content);
 
 
160
  };
161
 
162
  return (
163
- <div className="py-2 px-4">
164
- <div className={`max-w-2xl mx-auto flex gap-4 relative ${isUser ? "flex-row-reverse" : ""}`}>
165
- <Avatar className={`h-8 w-8 shrink-0 absolute justify-center item-center ${isUser ? "-right-12" : "-left-12"} top-0`}>{isUser ? <User2 className="h-5 w-5" /> : <Bot className="h-5 w-5" />}</Avatar>
166
 
167
- <div className={`max-w-2xl flex-1 ${isUser ? "items-end" : "items-start"}`}>
168
  <div className={`flex items-center gap-2 mb-1 ${isUser ? "justify-end" : "justify-start"}`}>
169
- {isUser && <div className="text-xs text-muted-foreground">{new Date(message.timestamp).toLocaleTimeString()}</div>}
170
  <div className="font-medium">{isUser ? "You" : "KNet"}</div>
171
- {!isUser && <div className="text-xs text-muted-foreground">{new Date(message.timestamp).toLocaleTimeString()}</div>}
 
172
 
173
- {!isUser && (
174
  <div className="ml-auto flex items-center gap-2">
175
  <TooltipProvider>
176
  <Tooltip>
@@ -199,12 +336,37 @@ const Message = ({ message }: { message: MessageType }) => {
199
  )}
200
  </div>
201
 
202
- <div className={`mt-1 max-w-none ${isUser ? "bg-slate-300 dark:bg-slate-200 dark:text-background text-foreground" : isProgress ? "bg-muted/30" : "bg-muted/50"} p-3 rounded-2xl ${isUser ? "rounded-tr-sm" : "rounded-tl-sm"}`} style={{ overflowWrap: "anywhere" }}>
203
- <ReactMarkdown remarkPlugins={[remarkGfm]} components={MarkdownComponents}>
204
- {cleanContent}
205
- </ReactMarkdown>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
 
207
- {imageUrls.length > 0 && <ImageGallery imageUrls={imageUrls} />}
 
 
 
208
  </div>
209
  </div>
210
  </div>
 
3
  import { Button } from "@/components/ui/button";
4
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
6
+ import { Message as MessageType, ResearchTree } from "@/lib/types";
7
+ import { Bot, Copy, MoreHorizontal, User2, ExternalLink, Loader2, SearchIcon } from "lucide-react";
8
+ import React, { useState, useEffect, useMemo, useRef } from "react";
9
  import ReactMarkdown from "react-markdown";
10
  import remarkGfm from "remark-gfm";
11
  import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
12
  import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
13
+ import { Badge } from "@/components/ui/badge";
14
+
15
+ // Function to extract all source URLs from the research tree
16
+ const extractAllSources = (tree: ResearchTree | undefined): Array<{ text: string; url: string }> => {
17
+ if (!tree) return [];
18
+
19
+ // Start with an empty set to avoid duplicates
20
+ const uniqueSources = new Set<string>();
21
+
22
+ // Recursive function to gather all sources
23
+ const collectSources = (node: ResearchTree) => {
24
+ // Add all sources from the current node
25
+ if (node.sources && Array.isArray(node.sources)) {
26
+ node.sources.forEach((url) => uniqueSources.add(url));
27
+ }
28
+
29
+ // Process all children recursively
30
+ if (node.children && Array.isArray(node.children)) {
31
+ node.children.forEach((child) => collectSources(child));
32
+ }
33
+ };
34
+
35
+ // Start the collection process
36
+ collectSources(tree);
37
+
38
+ // Convert the set to an array of objects with text and url properties
39
+ return Array.from(uniqueSources).map((url) => {
40
+ // Try to extract a readable title from the URL
41
+ let text = "";
42
+ try {
43
+ const urlObj = new URL(url);
44
+ // Remove 'www.' if present and take the hostname
45
+ text = urlObj.hostname.replace(/^www\./, "");
46
+
47
+ // Add the pathname if it's not just "/"
48
+ if (urlObj.pathname && urlObj.pathname !== "/") {
49
+ // Format the pathname - keep it short and clean
50
+ const path = urlObj.pathname.split("/").filter(Boolean);
51
+ if (path.length > 0) {
52
+ const lastPathSegment = path[path.length - 1]
53
+ .replace(/[-_]/g, " ") // Replace dashes and underscores with spaces
54
+ .replace(/\.html$|\.pdf$|\.php$/, ""); // Remove common extensions
55
+
56
+ text = `${text} - ${lastPathSegment}`;
57
+ }
58
+ }
59
+ } catch (e) {
60
+ // If URL parsing fails, use the URL as is
61
+ text = url;
62
+ }
63
+
64
+ return { text, url };
65
+ });
66
+ };
67
+
68
+ // SourceLinks component for displaying research sources in a scrollable container
69
+ const SourceLinks = ({ links }: { links: Array<{ text: string; url: string }> }) => {
70
+ if (!links || links.length === 0) return null;
71
+
72
+ return (
73
+ <div className="mt-4 mb-4">
74
+ <h3 className="text-md font-semibold mb-2">Research Sources:</h3>
75
+ <ScrollArea className="w-full h-[300px] overflow-auto border border-slate-200 dark:border-slate-700 rounded-xl shadow-sm p-1" type="always">
76
+ <div className="space-y-2">
77
+ {links.map((link, index) => {
78
+ // Extract domain for display
79
+ let domain = "";
80
+ try {
81
+ const urlObj = new URL(link.url);
82
+ domain = urlObj.hostname.replace(/^www\./, "");
83
+ } catch (e) {
84
+ domain = "Unknown source";
85
+ }
86
+
87
+ return (
88
+ <div key={index} className="flex items-start gap-2 group hover:bg-muted/50 p-2 rounded-md transition-colors">
89
+ <ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
90
+ <div className="flex-1">
91
+ <a href={link.url} className="text-primary hover:underline text-sm block" target="_blank" rel="noopener noreferrer">
92
+ {domain}
93
+ </a>
94
+ <a href={link.url} className="text-xs text-muted-foreground hover:underline block truncate" target="_blank" rel="noopener noreferrer" title={link.url}>
95
+ {link.url}
96
+ </a>
97
+ </div>
98
+ <div className="opacity-0 group-hover:opacity-100 transition-opacity">
99
+ <TooltipProvider>
100
+ <Tooltip>
101
+ <TooltipTrigger asChild>
102
+ <Button
103
+ size="sm"
104
+ variant="ghost"
105
+ className="h-6 w-6 p-0"
106
+ onClick={(e) => {
107
+ e.preventDefault();
108
+ navigator.clipboard.writeText(link.url);
109
+ }}>
110
+ <Copy className="h-3 w-3" />
111
+ </Button>
112
+ </TooltipTrigger>
113
+ <TooltipContent>Copy link</TooltipContent>
114
+ </Tooltip>
115
+ </TooltipProvider>
116
+ </div>
117
+ </div>
118
+ );
119
+ })}
120
+ </div>
121
+ </ScrollArea>
122
+ </div>
123
+ );
124
+ };
125
 
126
  // ImageGallery component for handling images in a scrollable container
127
  const ImageGallery = ({ imageUrls }: { imageUrls: string[] }) => {
 
157
  return (
158
  <div className="mt-4 mb-4">
159
  <h3 className="text-md font-semibold mb-2">Relevant Images:</h3>
160
+ <ScrollArea className="w-full h-[300px] overflow-auto border border-slate-200 dark:border-slate-700 rounded-xl shadow-sm p-1" type="always">
161
+ <div className="p-2 grid grid-cols-2 md:grid-cols-4 gap-2">
162
  {imageUrls.map((url, index) => (
163
+ <div key={index} className="image-container h-[150px]">
164
  <img
165
+ className="lazy-image rounded-md w-full h-full object-cover shadow-sm border border-slate-100 dark:border-slate-800"
166
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23f1f1f1'/%3E%3C/svg%3E"
167
  data-src={url}
168
  alt={`Research image ${index + 1}`}
 
224
  ),
225
  blockquote: ({ children }) => <blockquote className="border-l-4 border-border pl-4 italic my-4">{children}</blockquote>,
226
  table: ({ children }) => (
227
+ <div className="overflow-x-scroll border rounded-2xl my-4">
228
+ <table className="w-max border-collapse rounded-2xl overflow-hidden shadow-sm">{children}</table>
229
  </div>
230
  ),
231
  thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
232
  tbody: ({ children }) => <tbody>{children}</tbody>,
233
+ th: ({ children }) => <th className="border-r last:border-r-0 border-slate-900 px-2 py-1 text-left font-semibold">{children}</th>,
234
  tr: ({ children }) => <tr className="border-b last:border-b-0 border-border">{children}</tr>,
235
+ td: ({ children }) => <td className="border-r last:border-r-0 border-border px-2 py-1">{children}</td>,
236
  };
237
 
238
+ interface MessageProps {
239
+ message: MessageType;
240
+ isLoading?: boolean;
241
+ lastProgressMessage?: string;
242
+ setLastProgressMessage?: React.Dispatch<React.SetStateAction<string>>;
243
+ }
244
+
245
+ const Message = ({ message, isLoading, lastProgressMessage, setLastProgressMessage }: MessageProps) => {
246
+ const isUser = message?.role === "user";
247
  const [imageUrls, setImageUrls] = useState<string[]>([]);
248
+ const progressPercentage = message.progress || 0;
249
+ const isProgressMessage = message.isProgress === true;
250
+ const [isSearchMessage, setIsSearchMessage] = useState(message.content.startsWith("s_"));
251
+
252
+ // Use useMemo to extract sources only once and only when they change
253
+ const sourceLinks = useMemo(() => {
254
+ // If this is the loading component, return empty sources
255
+ if (isLoading) return [];
256
+
257
+ // First, extract sources from the research_tree if available
258
+ const researchSources = extractAllSources(message.research_tree);
259
+
260
+ // If research_tree sources exist, use those
261
+ if (researchSources.length > 0) {
262
+ return researchSources;
263
+ }
264
+
265
+ // Otherwise, fall back to any links in the media object
266
+ return message.media?.links || [];
267
+ }, [message.research_tree, message.media?.links, isLoading]);
268
 
269
  // Extract image URLs from the message content or use the media object
270
  useEffect(() => {
271
+ if (isLoading) {
272
+ setImageUrls([]);
273
+ return;
274
+ }
275
+
276
+ if (!isUser) {
277
+ // Handle image URLs
278
  let urls: string[] = [];
279
 
280
  // First, check if there's a media object with images
281
  if (message.media?.images && message.media.images.length > 0) {
282
  urls = message.media.images;
 
 
 
 
 
 
 
 
 
 
283
  }
 
284
  setImageUrls(urls);
285
  }
 
286
 
287
+ setIsSearchMessage(message.content.startsWith("s_"));
288
+ if ((isLoading || isProgressMessage) && !message.content.startsWith("s_")) {
289
+ setLastProgressMessage!(message.content);
290
+ }
291
+ }, [message.content, message.media, isUser, isLoading, isProgressMessage, setLastProgressMessage]);
292
 
293
  const copyToClipboard = () => {
294
+ if (!isLoading) {
295
+ navigator.clipboard.writeText(message.content);
296
+ }
297
  };
298
 
299
  return (
300
+ <div className="py-2 px-2 sm:px-4 sm:mx-12">
301
+ <div className={`w-full flex gap-4 relative ${isUser ? "justify-end" : "justify-start"}`}>
302
+ <Avatar className={`h-8 w-8 rounded-full bg-muted flex justify-center item-center absolute ${isUser ? "right-0 sm:-right-12" : "left-0 sm:-left-12"} top-0 hidden sm:flex`}>{isLoading || isProgressMessage ? <Loader2 className="h-full w-6 animate-spin" /> : isUser ? <User2 className="h-full w-6" /> : <Bot className="h-full w-6" />}</Avatar>
303
 
304
+ <div className={`max-w-full ${isLoading || isProgressMessage ? "w-[80%]" : ""} ${isUser ? "items-end ml-auto" : "items-start mr-auto"}`}>
305
  <div className={`flex items-center gap-2 mb-1 ${isUser ? "justify-end" : "justify-start"}`}>
 
306
  <div className="font-medium">{isUser ? "You" : "KNet"}</div>
307
+ {!isUser && !isLoading && <div className="text-xs text-muted-foreground">{new Date(message.timestamp).toLocaleTimeString()}</div>}
308
+ {isLoading && <div className="text-xs text-muted-foreground">Just now</div>}
309
 
310
+ {!isUser && !isLoading && !isProgressMessage && (
311
  <div className="ml-auto flex items-center gap-2">
312
  <TooltipProvider>
313
  <Tooltip>
 
336
  )}
337
  </div>
338
 
339
+ <div className={`mt-1 w-full ${isUser ? "bg-slate-300 dark:bg-slate-200 dark:text-background text-foreground" : "bg-muted/50"} p-3 rounded-2xl ${isUser ? "rounded-tr-sm" : "rounded-tl-sm"}`} style={{ overflowWrap: "anywhere" }}>
340
+ {isLoading || isProgressMessage ? (
341
+ <div className="space-y-2">
342
+ <div>{lastProgressMessage}</div>
343
+ <div className={`flex items-center ${isSearchMessage ? "justify-between" : "justify-end"} text-sm w-full`}>
344
+ {isSearchMessage ? (
345
+ <Badge variant="outline" className="bg-primary/20 text-primary rounded-full">
346
+ <SearchIcon className="h-3 w-3 mr-1" />
347
+ {message.content.slice(2)}
348
+ </Badge>
349
+ ) : null}
350
+ <Badge variant="outline" className="ml-2 bg-primary/20 text-primary">
351
+ {progressPercentage}%
352
+ </Badge>
353
+ </div>
354
+ <div className="w-full bg-muted/30 h-2.5 rounded-full overflow-hidden">
355
+ <div className="h-full bg-primary transition-all duration-500 rounded-full flex items-center justify-end" style={{ width: `${progressPercentage}%` }}>
356
+ <div className="h-2 w-2 rounded-full bg-primary-foreground mr-0.5 animate-pulse"></div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ ) : (
361
+ <>
362
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={MarkdownComponents}>
363
+ {message.content}
364
+ </ReactMarkdown>
365
 
366
+ {sourceLinks.length > 0 && <SourceLinks links={sourceLinks} />}
367
+ {imageUrls.length > 0 && <ImageGallery imageUrls={imageUrls} />}
368
+ </>
369
+ )}
370
  </div>
371
  </div>
372
  </div>
frontend/src/components/MessageInput.tsx CHANGED
@@ -2,16 +2,19 @@
2
  import { Button } from "@/components/ui/button";
3
  import { AutosizeTextarea } from "@/components/ui/textarea";
4
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
5
- import { Mic, Paperclip, Send } from "lucide-react";
 
6
  import React, { useState } from "react";
7
 
8
  interface MessageInputProps {
9
  onSendMessage: (content: string) => void;
10
  isLoading: boolean;
11
  userInputRef: React.LegacyRef<any>;
 
12
  }
13
 
14
- const MessageInput: React.FC<MessageInputProps> = ({ onSendMessage, isLoading, userInputRef }) => {
 
15
  const [message, setMessage] = useState("");
16
 
17
  const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -37,7 +40,7 @@ const MessageInput: React.FC<MessageInputProps> = ({ onSendMessage, isLoading, u
37
  <div className="p-4 absolute bottom-0 left-0 right-0 bg-transparent mb-4">
38
  <form onSubmit={handleSubmit} className="max-w-4xl mx-auto">
39
  <div className="relative flex items-center bg-background shadow-lg rounded-[2rem] border overflow-hidden h-full">
40
- <AutosizeTextarea placeholder="Ask a research question..." maxHeight={500} minHeight={52} className="pr-36 pl-6 py-4 font-medium border-none h-auto resize-none" value={message} onChange={handleMessageChange} onKeyDown={handleKeyDown} disabled={isLoading} rows={1} autoFocus ref={userInputRef}/>
41
 
42
  <div className="absolute right-3 flex items-center gap-2 h-full">
43
  <TooltipProvider>
@@ -62,10 +65,17 @@ const MessageInput: React.FC<MessageInputProps> = ({ onSendMessage, isLoading, u
62
  </Tooltip>
63
  </TooltipProvider>
64
 
65
- <Button type="submit" size="icon" className="h-9 w-9 rounded-full" disabled={isLoading || !message.trim()}>
66
- <Send className="h-4 w-4" />
67
- <span className="sr-only">Send message</span>
68
- </Button>
 
 
 
 
 
 
 
69
  </div>
70
  </div>
71
  </form>
 
2
  import { Button } from "@/components/ui/button";
3
  import { AutosizeTextarea } from "@/components/ui/textarea";
4
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
5
+ import { useChatContext } from "@/lib/store/ChatContext";
6
+ import { Mic, Paperclip, Send, Square, StopCircle } from "lucide-react";
7
  import React, { useState } from "react";
8
 
9
  interface MessageInputProps {
10
  onSendMessage: (content: string) => void;
11
  isLoading: boolean;
12
  userInputRef: React.LegacyRef<any>;
13
+ onCancel: () => void;
14
  }
15
 
16
+ // Traditional component that takes props
17
+ const MessageInput: React.FC<MessageInputProps> = ({ onSendMessage, isLoading, userInputRef, onCancel }) => {
18
  const [message, setMessage] = useState("");
19
 
20
  const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
 
40
  <div className="p-4 absolute bottom-0 left-0 right-0 bg-transparent mb-4">
41
  <form onSubmit={handleSubmit} className="max-w-4xl mx-auto">
42
  <div className="relative flex items-center bg-background shadow-lg rounded-[2rem] border overflow-hidden h-full">
43
+ <AutosizeTextarea placeholder="Ask a research question..." maxHeight={500} minHeight={52} className="pr-36 pl-6 py-4 font-medium border-none h-auto resize-none" value={message} onChange={handleMessageChange} onKeyDown={handleKeyDown} disabled={isLoading} rows={1} autoFocus ref={userInputRef} />
44
 
45
  <div className="absolute right-3 flex items-center gap-2 h-full">
46
  <TooltipProvider>
 
65
  </Tooltip>
66
  </TooltipProvider>
67
 
68
+ {isLoading && onCancel ? (
69
+ <Button type="button" size="icon" variant="ghost" className="h-9 w-9 rounded-full" onClick={onCancel}>
70
+ <Square className="h-4 w-4" />
71
+ <span className="sr-only">Cancel</span>
72
+ </Button>
73
+ ) : (
74
+ <Button type="submit" size="icon" className="h-9 w-9 rounded-full" disabled={isLoading || !message.trim()}>
75
+ <Send className="h-4 w-4" />
76
+ <span className="sr-only">Send message</span>
77
+ </Button>
78
+ )}
79
  </div>
80
  </div>
81
  </form>
frontend/src/components/ResearchControls.tsx CHANGED
@@ -2,15 +2,17 @@ import { Checkbox } from "@/components/ui/checkbox";
2
  import { Label } from "@/components/ui/label";
3
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
4
  import { Separator } from "@/components/ui/separator";
 
5
  import { ResearchOptions } from "@/lib/types";
6
  import React from "react";
7
- import { Input } from "@/components/ui/input"; // Make sure you have an Input component
8
 
9
  interface ResearchControlsProps {
10
  options: ResearchOptions;
11
  onOptionChange: (options: ResearchOptions) => void;
12
  }
13
 
 
14
  const ResearchControls: React.FC<ResearchControlsProps> = ({ options, onOptionChange }) => {
15
  return (
16
  <div className="space-y-6">
@@ -56,24 +58,12 @@ const ResearchControls: React.FC<ResearchControlsProps> = ({ options, onOptionCh
56
 
57
  <div className="space-y-2">
58
  <Label htmlFor="max-depth">Max Depth</Label>
59
- <Input
60
- type="number"
61
- id="max-depth"
62
- value={options.max_depth}
63
- onChange={(e) => onOptionChange({ ...options, max_depth: parseInt(e.target.value, 10) })}
64
- className="w-full"
65
- />
66
  </div>
67
 
68
  <div className="space-y-2">
69
  <Label htmlFor="num-sites-per-query">Number of Sites per Query</Label>
70
- <Input
71
- type="number"
72
- id="num-sites-per-query"
73
- value={options.num_sites_per_query}
74
- onChange={(e) => onOptionChange({ ...options, num_sites_per_query: parseInt(e.target.value, 10) })}
75
- className="w-full"
76
- />
77
  </div>
78
  </div>
79
 
@@ -99,4 +89,11 @@ const ResearchControls: React.FC<ResearchControlsProps> = ({ options, onOptionCh
99
  );
100
  };
101
 
102
- export default ResearchControls;
 
 
 
 
 
 
 
 
2
  import { Label } from "@/components/ui/label";
3
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
4
  import { Separator } from "@/components/ui/separator";
5
+ import { useChatContext } from "@/lib/store/ChatContext";
6
  import { ResearchOptions } from "@/lib/types";
7
  import React from "react";
8
+ import { Input } from "@/components/ui/input"; // Make sure you have an Input component
9
 
10
  interface ResearchControlsProps {
11
  options: ResearchOptions;
12
  onOptionChange: (options: ResearchOptions) => void;
13
  }
14
 
15
+ // Traditional prop-based component
16
  const ResearchControls: React.FC<ResearchControlsProps> = ({ options, onOptionChange }) => {
17
  return (
18
  <div className="space-y-6">
 
58
 
59
  <div className="space-y-2">
60
  <Label htmlFor="max-depth">Max Depth</Label>
61
+ <Input type="number" id="max-depth" value={options.max_depth} onChange={(e) => onOptionChange({ ...options, max_depth: parseInt(e.target.value, 10) })} className="w-full" />
 
 
 
 
 
 
62
  </div>
63
 
64
  <div className="space-y-2">
65
  <Label htmlFor="num-sites-per-query">Number of Sites per Query</Label>
66
+ <Input type="number" id="num-sites-per-query" value={options.num_sites_per_query} onChange={(e) => onOptionChange({ ...options, num_sites_per_query: parseInt(e.target.value, 10) })} className="w-full" />
 
 
 
 
 
 
67
  </div>
68
  </div>
69
 
 
89
  );
90
  };
91
 
92
+ // Context-based component
93
+ export const ResearchControlsWithContext: React.FC = () => {
94
+ const { researchOptions, setResearchOptions } = useChatContext();
95
+
96
+ return <ResearchControls options={researchOptions} onOptionChange={setResearchOptions} />;
97
+ };
98
+
99
+ export default ResearchControls;
frontend/src/components/ui/ChatLayout.tsx CHANGED
@@ -29,9 +29,7 @@ const ChatLayout: React.FC<ChatLayoutProps> = ({ sidebar, mainContent, settingsP
29
  </SheetTrigger>
30
  <SheetContent side="left" className="w-[80%] sm:w-[350px] p-0">
31
  <SheetTitle className="sr-only">Mobile Navigation</SheetTitle>
32
- <SheetDescription className="sr-only">
33
- Sidebar navigation for mobile devices
34
- </SheetDescription>
35
  <div className="border-b p-4">
36
  <h2 className="text-lg font-semibold">Conversations</h2>
37
  </div>
@@ -63,7 +61,7 @@ const ChatLayout: React.FC<ChatLayoutProps> = ({ sidebar, mainContent, settingsP
63
 
64
  <div className="flex-1 overflow-hidden">
65
  <ResizablePanelGroup direction="horizontal">
66
- <ResizablePanel defaultSize={25} minSize={17} maxSize={30} className="hidden md:block">
67
  <Card className="h-full rounded-none border-r border-t-0 border-l-0 border-b-0">
68
  <ScrollArea className="h-full">{sidebar}</ScrollArea>
69
  </Card>
@@ -71,7 +69,7 @@ const ChatLayout: React.FC<ChatLayoutProps> = ({ sidebar, mainContent, settingsP
71
 
72
  <ResizableHandle withHandle className="hidden md:flex" />
73
 
74
- <ResizablePanel defaultSize={75} className="w-full md:w-auto">
75
  <Tabs defaultValue="chat" className="h-full flex flex-col">
76
  <div className="p-4">
77
  <TabsList className="">
@@ -86,7 +84,7 @@ const ChatLayout: React.FC<ChatLayoutProps> = ({ sidebar, mainContent, settingsP
86
  </TabsList>
87
  </div>
88
 
89
- <TabsContent value="chat" className="overflow-auto flex flex-1" tabIndex={-1}>
90
  {mainContent}
91
  </TabsContent>
92
 
 
29
  </SheetTrigger>
30
  <SheetContent side="left" className="w-[80%] sm:w-[350px] p-0">
31
  <SheetTitle className="sr-only">Mobile Navigation</SheetTitle>
32
+ <SheetDescription className="sr-only">Sidebar navigation for mobile devices</SheetDescription>
 
 
33
  <div className="border-b p-4">
34
  <h2 className="text-lg font-semibold">Conversations</h2>
35
  </div>
 
61
 
62
  <div className="flex-1 overflow-hidden">
63
  <ResizablePanelGroup direction="horizontal">
64
+ <ResizablePanel defaultSize={15} className="hidden md:block min-w-[15rem] max-w-[35rem]">
65
  <Card className="h-full rounded-none border-r border-t-0 border-l-0 border-b-0">
66
  <ScrollArea className="h-full">{sidebar}</ScrollArea>
67
  </Card>
 
69
 
70
  <ResizableHandle withHandle className="hidden md:flex" />
71
 
72
+ <ResizablePanel defaultSize={85} className="w-full md:w-auto">
73
  <Tabs defaultValue="chat" className="h-full flex flex-col">
74
  <div className="p-4">
75
  <TabsList className="">
 
84
  </TabsList>
85
  </div>
86
 
87
+ <TabsContent value="chat" className="overflow-auto flex flex-1 !mt-0" tabIndex={-1}>
88
  {mainContent}
89
  </TabsContent>
90
 
frontend/src/components/ui/ConversationList.tsx CHANGED
@@ -1,9 +1,18 @@
1
  "use client";
2
  import { Button } from "@/components/ui/button";
3
- import { ConversationListProps } from "@/lib/types";
4
  import { MessageSquare, PlusCircle, Trash2, XCircle } from "lucide-react";
5
  import React from "react";
6
 
 
 
 
 
 
 
 
 
 
7
  const ConversationList: React.FC<ConversationListProps> = ({ conversations, onNewConversation, onSelectConversation, onDeleteConversation, onDeleteAllConversations }) => {
8
  const handleSelectConversation = (id: string) => {
9
  onSelectConversation(id);
@@ -22,36 +31,42 @@ const ConversationList: React.FC<ConversationListProps> = ({ conversations, onNe
22
  </Button>
23
  )}
24
  </div>
25
-
26
  <div className="px-2 py-2">
27
  <h2 className="text-sm font-semibold px-2 mb-2">Recent Research</h2>
28
  <div className="space-y-1">
29
  {conversations.length === 0 ? (
30
  <p className="text-sm text-muted-foreground px-2">No conversations yet</p>
31
  ) : (
32
- conversations.map((conversation) => (
33
- <div key={conversation.id} className="flex items-center gap-1 px-1 group">
34
- <Button variant={conversation.active ? "secondary" : "ghost"} className={`flex-1 pr-0.5 justify-start text-left truncate ${conversation.active ? "bg-accent" : ""}`} onClick={() => handleSelectConversation(conversation.id)}>
35
- <MessageSquare className="mr-2 h-4 w-4 shrink-0" />
36
- <span className="truncate">{conversation.title}</span>
37
- </Button>
38
- <Button
39
- variant="ghost"
40
- size="icon"
41
- className="h-8 w-8 ml-auto hidden group-hover:flex hover:bg-destructive"
42
- onClick={(e) => {
43
- e.stopPropagation();
44
- onDeleteConversation(conversation.id);
45
- }}>
46
- <XCircle className="h-4 w-4" />
47
- </Button>
48
- </div>
49
- ))
50
- )}
51
  </div>
52
  </div>
53
  </div>
54
  );
55
  };
56
 
 
 
 
 
 
 
 
57
  export default ConversationList;
 
1
  "use client";
2
  import { Button } from "@/components/ui/button";
3
+ import { useChatContext } from "@/lib/store/ChatContext";
4
  import { MessageSquare, PlusCircle, Trash2, XCircle } from "lucide-react";
5
  import React from "react";
6
 
7
+ interface ConversationListProps {
8
+ conversations: any[];
9
+ onNewConversation: () => void;
10
+ onSelectConversation: (id: string) => void;
11
+ onDeleteConversation: (id: string) => void;
12
+ onDeleteAllConversations: () => void;
13
+ }
14
+
15
+ // A component that accepts props for backward compatibility
16
  const ConversationList: React.FC<ConversationListProps> = ({ conversations, onNewConversation, onSelectConversation, onDeleteConversation, onDeleteAllConversations }) => {
17
  const handleSelectConversation = (id: string) => {
18
  onSelectConversation(id);
 
31
  </Button>
32
  )}
33
  </div>
 
34
  <div className="px-2 py-2">
35
  <h2 className="text-sm font-semibold px-2 mb-2">Recent Research</h2>
36
  <div className="space-y-1">
37
  {conversations.length === 0 ? (
38
  <p className="text-sm text-muted-foreground px-2">No conversations yet</p>
39
  ) : (
40
+ conversations.map((conversation) => (
41
+ <div key={conversation.id} className="flex items-center gap-1 px-1 group">
42
+ <Button variant={conversation.active ? "secondary" : "ghost"} className={`flex-1 pr-0.5 justify-start text-left truncate ${conversation.active ? "bg-accent" : ""}`} onClick={() => handleSelectConversation(conversation.id)}>
43
+ <MessageSquare className="mr-2 h-4 w-4 shrink-0" />
44
+ <span className="truncate">{conversation.title}</span>
45
+ </Button>
46
+ <Button
47
+ variant="ghost"
48
+ size="icon"
49
+ className="h-8 w-8 ml-auto hidden group-hover:flex hover:bg-destructive"
50
+ onClick={(e) => {
51
+ e.stopPropagation();
52
+ onDeleteConversation(conversation.id);
53
+ }}>
54
+ <XCircle className="h-4 w-4" />
55
+ </Button>
56
+ </div>
57
+ ))
58
+ )}
59
  </div>
60
  </div>
61
  </div>
62
  );
63
  };
64
 
65
+ // A component that uses context directly
66
+ export const ConversationListWithContext: React.FC = () => {
67
+ const { conversations, newConversation, selectConversation, deleteConversation, deleteAllConversations } = useChatContext();
68
+
69
+ return <ConversationList conversations={conversations} onNewConversation={newConversation} onSelectConversation={selectConversation} onDeleteConversation={deleteConversation} onDeleteAllConversations={deleteAllConversations} />;
70
+ };
71
+
72
  export default ConversationList;
frontend/src/components/ui/badge.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
frontend/src/lib/store/ChatContext.tsx ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ChatData, ChatState, Conversation, Message, ResearchOptions, ResearchResults, StatusUpdate } from "@/lib/types";
4
+ import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import { disconnectSocket, getSocket, initializeSocket } from "@/lib/socket";
7
+
8
+ // Utility functions for local storage
9
+ const saveToStorage = (data: ChatData) => {
10
+ if (typeof window !== "undefined") {
11
+ localStorage.setItem("chatData", JSON.stringify(data));
12
+ }
13
+ };
14
+
15
+ const loadFromStorage = (): ChatData => {
16
+ if (typeof window === "undefined") {
17
+ return { conversations: [], currentConversationId: null };
18
+ }
19
+ const data = localStorage.getItem("chatData");
20
+ if (!data) {
21
+ return { conversations: [], currentConversationId: null };
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(data);
25
+ return {
26
+ conversations: Array.isArray(parsed.conversations)
27
+ ? parsed.conversations.map((conv: Conversation) => ({
28
+ ...conv,
29
+ messages: Array.isArray(conv.messages)
30
+ ? conv.messages.map((msg: Message) => ({
31
+ ...msg,
32
+ // Ensure media property is preserved if it exists
33
+ media: msg.media || undefined,
34
+ }))
35
+ : [],
36
+ }))
37
+ : [],
38
+ currentConversationId: parsed.currentConversationId,
39
+ };
40
+ } catch (e) {
41
+ return { conversations: [], currentConversationId: null };
42
+ }
43
+ };
44
+
45
+ // Define the context type
46
+ interface ChatContextType {
47
+ // State
48
+ chatState: ChatState;
49
+ conversations: Conversation[];
50
+ currentConversationId: string | null;
51
+ researchOptions: ResearchOptions;
52
+ userInputRef: React.RefObject<HTMLTextAreaElement>;
53
+
54
+ // Actions
55
+ setResearchOptions: (options: ResearchOptions) => void;
56
+ sendMessage: (content: string) => void;
57
+ newConversation: () => void;
58
+ selectConversation: (id: string) => void;
59
+ deleteConversation: (id: string) => void;
60
+ deleteAllConversations: () => void;
61
+ abortResearch: () => void; // New function to abort research
62
+ }
63
+
64
+ // Create the context with a default value
65
+ const ChatContext = createContext<ChatContextType | undefined>(undefined);
66
+
67
+ // Provider component
68
+ export const ChatProvider = ({ children }: { children: ReactNode }) => {
69
+ const [chatState, setChatState] = useState<ChatState>({ messages: [], isLoading: false, error: null });
70
+ const [conversations, setConversations] = useState<Conversation[]>([]);
71
+ const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
72
+ const [researchOptions, setResearchOptions] = useState<ResearchOptions>({
73
+ depth: "basic",
74
+ sources: true,
75
+ citations: false,
76
+ max_depth: 1,
77
+ num_sites_per_query: 3,
78
+ });
79
+
80
+ const userInputRef = useRef<HTMLTextAreaElement>(null);
81
+
82
+ // Focus management
83
+ useEffect(() => {
84
+ const focusInput = () => {
85
+ setTimeout(() => {
86
+ userInputRef.current?.focus();
87
+ }, 100);
88
+ };
89
+
90
+ focusInput();
91
+ }, [currentConversationId]);
92
+
93
+ // Load initial data
94
+ useEffect(() => {
95
+ const data = loadFromStorage();
96
+ setConversations(data.conversations);
97
+ setCurrentConversationId(data.currentConversationId);
98
+
99
+ if (data.currentConversationId) {
100
+ const conversation = data.conversations.find((c) => c.id === data.currentConversationId);
101
+ if (conversation) {
102
+ // Check if any loaded message has isProgress true
103
+ let messages = conversation.messages;
104
+ if (messages.some(msg => msg.isProgress === true)) {
105
+ // Convert any progress messages to error messages
106
+ messages = messages.map(msg =>
107
+ msg.isProgress === true
108
+ ? { ...msg, content: "Connection error", isProgress: false }
109
+ : msg
110
+ );
111
+ }
112
+
113
+ setChatState((prev) => ({ ...prev, messages, isLoading: false }));
114
+ }
115
+ }
116
+ }, []);
117
+
118
+ // Socket initialization
119
+ useEffect(() => {
120
+ const socket = initializeSocket();
121
+
122
+ socket.on("connect", () => {
123
+ console.log("Connected to research server");
124
+ });
125
+
126
+ socket.on("disconnect", () => {
127
+ console.log("Disconnected from research server");
128
+
129
+ // When socket disconnects, update any progress messages to show connection error
130
+ setChatState((prevState) => {
131
+ const updatedMessages = prevState.messages.map(msg =>
132
+ msg.isProgress === true
133
+ ? { ...msg, content: "Connection error", isProgress: false }
134
+ : msg
135
+ );
136
+
137
+ return {
138
+ ...prevState,
139
+ messages: updatedMessages,
140
+ isLoading: false,
141
+ error: "Lost connection to research server"
142
+ };
143
+ });
144
+ });
145
+
146
+ socket.on("status", (data: StatusUpdate) => {
147
+ setChatState((prevState) => {
148
+ const messages = [...prevState.messages];
149
+ const progressText = data.message;
150
+ const progress = data.progress;
151
+
152
+ // Find the last assistant message that is a progress update
153
+ const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true);
154
+
155
+ if (lastProgressIndex !== -1) {
156
+ // Update existing progress message
157
+ messages[lastProgressIndex] = {
158
+ ...messages[lastProgressIndex],
159
+ content: progressText,
160
+ progress: progress,
161
+ timestamp: new Date(),
162
+ };
163
+ } else {
164
+ // Add new progress message
165
+ messages.push({
166
+ id: uuidv4(),
167
+ content: progressText,
168
+ role: "assistant",
169
+ timestamp: new Date(),
170
+ progress: progress,
171
+ isProgress: true,
172
+ });
173
+ }
174
+
175
+ return {
176
+ ...prevState,
177
+ messages,
178
+ isLoading: true,
179
+ };
180
+ });
181
+ });
182
+
183
+ socket.on("research_complete", (results: ResearchResults) => {
184
+ setChatState((prevState) => {
185
+ const messages = [...prevState.messages];
186
+
187
+ // Remove the last progress message if it exists
188
+ const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true);
189
+
190
+ if (lastProgressIndex !== -1) {
191
+ messages.splice(lastProgressIndex, 1);
192
+ }
193
+
194
+ const newMessages = [
195
+ ...messages,
196
+ {
197
+ id: uuidv4(),
198
+ content: results.content || "Error: No content available",
199
+ role: "assistant" as const,
200
+ timestamp: new Date(results.timestamp),
201
+ media: results.media,
202
+ research_tree: results.research_tree,
203
+ },
204
+ ];
205
+
206
+ // Save updated messages to localStorage
207
+ const updatedState = {
208
+ ...prevState,
209
+ isLoading: false,
210
+ messages: newMessages,
211
+ };
212
+
213
+ // Update localStorage with the new messages
214
+ const updatedData: ChatData = {
215
+ conversations: conversations.map((conv) => ({
216
+ ...conv,
217
+ messages: conv.id === currentConversationId ? newMessages : conv.messages || [],
218
+ lastUpdated: conv.id === currentConversationId ? new Date().toISOString() : conv.lastUpdated,
219
+ })),
220
+ currentConversationId,
221
+ };
222
+
223
+ saveToStorage(updatedData);
224
+
225
+ return updatedState;
226
+ });
227
+ });
228
+
229
+ socket.on("research_aborted", () => {
230
+ setChatState((prevState) => {
231
+ const messages = [...prevState.messages];
232
+ const lastProgressIndex = messages.findLastIndex((msg) => msg.role === "assistant" && msg.isProgress === true);
233
+
234
+ if (lastProgressIndex !== -1) {
235
+ messages.splice(lastProgressIndex, 1);
236
+ }
237
+
238
+ // Add a message indicating the research was canceled
239
+ messages.push({
240
+ id: uuidv4(),
241
+ content: "Research has been canceled.",
242
+ role: "assistant",
243
+ timestamp: new Date(),
244
+ });
245
+
246
+ return {
247
+ ...prevState,
248
+ isLoading: false,
249
+ messages,
250
+ };
251
+ });
252
+ });
253
+
254
+ socket.on("error", (error: { message: string }) => {
255
+ setChatState((prevState) => ({
256
+ ...prevState,
257
+ error: error.message,
258
+ isLoading: false,
259
+ }));
260
+ });
261
+
262
+ return () => {
263
+ disconnectSocket();
264
+ };
265
+ }, []);
266
+
267
+ // Save data whenever conversations or messages change
268
+ useEffect(() => {
269
+ const data: ChatData = {
270
+ conversations: conversations.map((conv) => ({
271
+ ...conv,
272
+ messages: conv.id === currentConversationId ? chatState.messages : conv.messages || [],
273
+ })),
274
+ currentConversationId,
275
+ };
276
+
277
+ saveToStorage(data);
278
+ }, [conversations, currentConversationId, chatState.messages]);
279
+
280
+ // Action handlers
281
+ const sendMessage = useCallback(
282
+ (content: string) => {
283
+ if (!content.trim()) return;
284
+
285
+ let conversationId = currentConversationId;
286
+ const newMessage: Message = {
287
+ id: uuidv4(),
288
+ content,
289
+ role: "user",
290
+ timestamp: new Date(),
291
+ };
292
+ const newLoadingMessage: Message = {
293
+ id: uuidv4(),
294
+ content: "Loading...",
295
+ role: "assistant",
296
+ timestamp: new Date(),
297
+ isProgress: true,
298
+ };
299
+
300
+ // Create a new conversation if none exists
301
+ if (!conversationId) {
302
+ conversationId = uuidv4();
303
+ setCurrentConversationId(conversationId);
304
+ setConversations((prev) => [
305
+ {
306
+ id: conversationId as string,
307
+ title: content.length > 30 ? `${content.substring(0, 30)}...` : content,
308
+ lastUpdated: new Date().toISOString(),
309
+ messages: [newMessage],
310
+ active: true,
311
+ },
312
+ ...prev.map((c) => ({ ...c, active: false })),
313
+ ]);
314
+ } else {
315
+ // Update the existing conversation
316
+ setConversations((prev) =>
317
+ prev.map((conv) => ({
318
+ ...conv,
319
+ lastUpdated: conv.id === conversationId ? new Date().toISOString() : conv.lastUpdated,
320
+ active: conv.id === conversationId,
321
+ messages: conv.id === conversationId ? [...(conv.messages || []), newMessage] : conv.messages || [],
322
+ }))
323
+ );
324
+ }
325
+
326
+ setChatState((prevState) => ({
327
+ ...prevState,
328
+ messages: [...prevState.messages, newMessage, newLoadingMessage],
329
+ isLoading: true,
330
+ error: null,
331
+ }));
332
+
333
+ // Send message to server via socket
334
+ try {
335
+ const socket = getSocket();
336
+ socket.emit("start_research", {
337
+ topic: content,
338
+ max_depth: researchOptions.max_depth,
339
+ num_sites_per_query: researchOptions.num_sites_per_query,
340
+ });
341
+ } catch (error) {
342
+ setChatState((prevState) => ({
343
+ ...prevState,
344
+ error: "Failed to connect to research server",
345
+ isLoading: false,
346
+ }));
347
+ }
348
+ },
349
+ [currentConversationId, conversations, researchOptions]
350
+ );
351
+
352
+ const newConversation = useCallback(() => {
353
+ userInputRef.current?.focus();
354
+ setCurrentConversationId(null);
355
+ setChatState(() => ({
356
+ messages: [],
357
+ isLoading: false,
358
+ error: null,
359
+ }));
360
+ }, []);
361
+
362
+ const selectConversation = useCallback((id: string) => {
363
+ const data = loadFromStorage();
364
+ const conversation = data.conversations.find((c) => c.id === id);
365
+
366
+ setCurrentConversationId(id);
367
+ setChatState((prev) => ({
368
+ ...prev,
369
+ messages: conversation?.messages || [],
370
+ isLoading: false,
371
+ error: null,
372
+ }));
373
+ setConversations((prev) =>
374
+ prev.map((conv) => ({
375
+ ...conv,
376
+ active: conv.id === id,
377
+ }))
378
+ );
379
+ }, []);
380
+
381
+ const deleteConversation = useCallback(
382
+ (id: string) => {
383
+ setConversations((prev) => prev.filter((conv) => conv.id !== id));
384
+ if (currentConversationId === id) {
385
+ newConversation();
386
+ }
387
+ },
388
+ [currentConversationId, newConversation]
389
+ );
390
+
391
+ const deleteAllConversations = useCallback(() => {
392
+ setConversations([]);
393
+ newConversation();
394
+ }, [newConversation]);
395
+
396
+ const abortResearch = useCallback(() => {
397
+ try {
398
+ const socket = getSocket();
399
+ socket.emit("abort_research");
400
+
401
+ setChatState((prevState) => ({
402
+ ...prevState,
403
+ isLoading: true,
404
+ }));
405
+ } catch (error) {
406
+ console.error("Failed to abort research:", error);
407
+ setChatState((prevState) => ({
408
+ ...prevState,
409
+ error: "Failed to abort research",
410
+ }));
411
+ }
412
+ }, []);
413
+
414
+ // Keyboard shortcuts | Ctrl + I to new chat
415
+ useEffect(() => {
416
+ const handleKeyDown = (event: KeyboardEvent) => {
417
+ if (event.ctrlKey && event.key === "i") {
418
+ event.preventDefault();
419
+ newConversation();
420
+ }
421
+ };
422
+
423
+ window.addEventListener("keydown", handleKeyDown);
424
+ return () => {
425
+ window.removeEventListener("keydown", handleKeyDown);
426
+ };
427
+ }, []);
428
+
429
+ const value = {
430
+ chatState,
431
+ conversations,
432
+ currentConversationId,
433
+ researchOptions,
434
+ userInputRef,
435
+ setResearchOptions,
436
+ sendMessage,
437
+ newConversation,
438
+ selectConversation,
439
+ deleteConversation,
440
+ deleteAllConversations,
441
+ abortResearch,
442
+ };
443
+
444
+ return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
445
+ };
446
+
447
+ // Custom hook for using the chat context
448
+ export const useChatContext = () => {
449
+ const context = useContext(ChatContext);
450
+ if (context === undefined) {
451
+ throw new Error("useChatContext must be used within a ChatProvider");
452
+ }
453
+ return context;
454
+ };
frontend/src/lib/types.ts CHANGED
@@ -1,17 +1,15 @@
1
  export interface Message {
2
  id: string;
3
  content: string;
4
- role: "user" | "assistant" | "system";
5
  timestamp: Date;
6
  media?: {
 
7
  images?: string[];
8
- videos?: string[];
9
- links?: Array<{
10
- text: string;
11
- url: string;
12
- }>;
13
- references?: any[];
14
  };
 
 
 
15
  }
16
 
17
  export interface ChatState {
@@ -46,28 +44,28 @@ export interface StatusUpdate {
46
  progress: number;
47
  }
48
 
49
- export interface ResearchNode {
50
- query: string;
51
- children?: ResearchNode[];
52
- }
53
-
54
- export interface ResearchMetadata {
55
- total_queries: number;
56
- total_sources: number;
57
- max_depth_reached: number;
58
- total_tokens: number;
59
- }
60
-
61
  export interface ResearchResults {
62
  topic: string;
63
  timestamp: string;
64
- content: string;
 
65
  media?: {
66
  images?: string[];
67
  videos?: string[];
 
 
 
 
68
  };
69
- research_tree: ResearchNode;
70
- metadata: ResearchMetadata;
 
 
 
 
 
 
71
  }
72
 
73
  export interface ConversationListProps {
 
1
  export interface Message {
2
  id: string;
3
  content: string;
4
+ role: "user" | "assistant";
5
  timestamp: Date;
6
  media?: {
7
+ links?: Array<{ text: string; url: string }>;
8
  images?: string[];
 
 
 
 
 
 
9
  };
10
+ research_tree?: ResearchTree;
11
+ progress?: number;
12
+ isProgress?: boolean;
13
  }
14
 
15
  export interface ChatState {
 
44
  progress: number;
45
  }
46
 
47
+ // Simplified research results based on actual server output
 
 
 
 
 
 
 
 
 
 
 
48
  export interface ResearchResults {
49
  topic: string;
50
  timestamp: string;
51
+ // Optional fields not present in basic server response
52
+ content?: string;
53
  media?: {
54
  images?: string[];
55
  videos?: string[];
56
+ links?: Array<{
57
+ text: string;
58
+ url: string;
59
+ }>;
60
  };
61
+ research_tree?: ResearchTree;
62
+ }
63
+
64
+ export interface ResearchTree {
65
+ query: string;
66
+ depth: number;
67
+ sources: string[];
68
+ children: ResearchTree[];
69
  }
70
 
71
  export interface ConversationListProps {