Spaces:
Paused
Paused
Soham Waghmare
commited on
Commit
·
79ae05b
1
Parent(s):
62283c0
feat: frontend - QOL; backend - QOL
Browse files- .gitignore +2 -0
- backend/app.py +59 -12
- backend/knet.py +63 -6
- backend/scraper.py +38 -6
- frontend/bun.lock +16 -2
- frontend/package.json +1 -1
- frontend/src/app/layout.tsx +2 -1
- frontend/src/app/page.tsx +1 -1
- frontend/src/components/ChatHistory.tsx +25 -39
- frontend/src/components/ChatInterface.tsx +5 -305
- frontend/src/components/Message.tsx +204 -42
- frontend/src/components/MessageInput.tsx +17 -7
- frontend/src/components/ResearchControls.tsx +13 -16
- frontend/src/components/ui/ChatLayout.tsx +4 -6
- frontend/src/components/ui/ConversationList.tsx +36 -21
- frontend/src/components/ui/badge.tsx +36 -0
- frontend/src/lib/store/ChatContext.tsx +454 -0
- frontend/src/lib/types.ts +20 -22
.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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},
|
| 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": []
|
| 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
|
| 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=
|
| 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.
|
| 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[:
|
| 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.
|
| 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.
|
| 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.
|
| 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
|
| 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 {
|
| 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 |
-
<
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
<div className="
|
| 25 |
-
<
|
| 26 |
-
|
| 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 |
-
|
| 32 |
-
)
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 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 {
|
| 4 |
-
import
|
| 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
|
| 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) => ``).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={
|
| 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={
|
| 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
|
| 49 |
-
<div className="p-2
|
| 50 |
{imageUrls.map((url, index) => (
|
| 51 |
-
<div key={index} className="image-container">
|
| 52 |
<img
|
| 53 |
-
className="lazy-image rounded-md
|
| 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-
|
| 116 |
-
<table className="w-
|
| 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-
|
| 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-
|
| 124 |
};
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
// Extract image URLs from the message content or use the media object
|
| 132 |
useEffect(() => {
|
| 133 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
const copyToClipboard = () => {
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
};
|
| 161 |
|
| 162 |
return (
|
| 163 |
-
<div className="py-2 px-4">
|
| 164 |
-
<div className={`
|
| 165 |
-
<Avatar className={`h-8 w-8
|
| 166 |
|
| 167 |
-
<div className={`max-w-
|
| 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
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 66 |
-
<
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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";
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 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 |
<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 {
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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"
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 65 |
media?: {
|
| 66 |
images?: string[];
|
| 67 |
videos?: string[];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
};
|
| 69 |
-
research_tree
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|