sirchmunk 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. sirchmunk/api/__init__.py +1 -0
  2. sirchmunk/api/chat.py +1123 -0
  3. sirchmunk/api/components/__init__.py +0 -0
  4. sirchmunk/api/components/history_storage.py +402 -0
  5. sirchmunk/api/components/monitor_tracker.py +518 -0
  6. sirchmunk/api/components/settings_storage.py +353 -0
  7. sirchmunk/api/history.py +254 -0
  8. sirchmunk/api/knowledge.py +411 -0
  9. sirchmunk/api/main.py +120 -0
  10. sirchmunk/api/monitor.py +219 -0
  11. sirchmunk/api/run_server.py +54 -0
  12. sirchmunk/api/search.py +230 -0
  13. sirchmunk/api/settings.py +309 -0
  14. sirchmunk/api/tools.py +315 -0
  15. sirchmunk/cli/__init__.py +11 -0
  16. sirchmunk/cli/cli.py +789 -0
  17. sirchmunk/learnings/knowledge_base.py +5 -2
  18. sirchmunk/llm/prompts.py +12 -1
  19. sirchmunk/retrieve/text_retriever.py +186 -2
  20. sirchmunk/scan/file_scanner.py +2 -2
  21. sirchmunk/schema/knowledge.py +119 -35
  22. sirchmunk/search.py +384 -26
  23. sirchmunk/storage/__init__.py +2 -2
  24. sirchmunk/storage/{knowledge_manager.py → knowledge_storage.py} +265 -60
  25. sirchmunk/utils/constants.py +7 -5
  26. sirchmunk/utils/embedding_util.py +217 -0
  27. sirchmunk/utils/tokenizer_util.py +36 -1
  28. sirchmunk/version.py +1 -1
  29. {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/METADATA +196 -14
  30. sirchmunk-0.0.2.dist-info/RECORD +69 -0
  31. {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/WHEEL +1 -1
  32. sirchmunk-0.0.2.dist-info/top_level.txt +2 -0
  33. sirchmunk_mcp/__init__.py +25 -0
  34. sirchmunk_mcp/cli.py +478 -0
  35. sirchmunk_mcp/config.py +276 -0
  36. sirchmunk_mcp/server.py +355 -0
  37. sirchmunk_mcp/service.py +327 -0
  38. sirchmunk_mcp/setup.py +15 -0
  39. sirchmunk_mcp/tools.py +410 -0
  40. sirchmunk-0.0.1.dist-info/RECORD +0 -45
  41. sirchmunk-0.0.1.dist-info/top_level.txt +0 -1
  42. {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/entry_points.txt +0 -0
  43. {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/licenses/LICENSE +0 -0
sirchmunk/api/chat.py ADDED
@@ -0,0 +1,1123 @@
1
+ # Copyright (c) ModelScope Contributors. All rights reserved.
2
+ """
3
+ Unified API endpoints for chat and search functionality
4
+ Provides WebSocket endpoint for real-time chat conversations with integrated search
5
+ """
6
+ import platform
7
+ import time
8
+
9
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
10
+ from typing import Dict, Any, List, Optional, Union
11
+ from pydantic import BaseModel
12
+ import json
13
+ import asyncio
14
+ import uuid
15
+ from datetime import datetime
16
+ import random
17
+ import os
18
+ import threading
19
+ from sirchmunk.search import AgenticSearch
20
+ from sirchmunk.llm.openai_chat import OpenAIChat
21
+ from sirchmunk.utils.constants import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL_NAME
22
+ from sirchmunk.api.components.history_storage import HistoryStorage
23
+ from sirchmunk.api.components.settings_storage import SettingsStorage
24
+ from sirchmunk.api.components.monitor_tracker import llm_usage_tracker
25
+
26
+
27
+ # Try to import tkinter for file dialogs
28
+ try:
29
+ import tkinter as tk
30
+ from tkinter import filedialog, messagebox
31
+ TKINTER_AVAILABLE = True
32
+ except ImportError:
33
+ TKINTER_AVAILABLE = False
34
+
35
+ router = APIRouter(prefix="/api/v1", tags=["chat", "search"])
36
+
37
+ # Initialize persistent history storage
38
+ history_storage = HistoryStorage()
39
+
40
+ # Initialize settings storage for LLM configuration (with error handling)
41
+ try:
42
+ settings_storage = SettingsStorage()
43
+ except Exception as e:
44
+ print(f"[WARNING] Failed to initialize SettingsStorage: {e}")
45
+ settings_storage = None
46
+
47
+ # In-memory cache for active sessions (for backward compatibility)
48
+ chat_sessions = {}
49
+
50
+ # Active WebSocket connections
51
+ class ChatConnectionManager:
52
+ def __init__(self):
53
+ self.active_connections: List[WebSocket] = []
54
+
55
+ async def connect(self, websocket: WebSocket):
56
+ await websocket.accept()
57
+ self.active_connections.append(websocket)
58
+
59
+ def disconnect(self, websocket: WebSocket):
60
+ if websocket in self.active_connections:
61
+ self.active_connections.remove(websocket)
62
+
63
+ async def send_personal_message(self, message: str, websocket: WebSocket):
64
+ await websocket.send_text(message)
65
+
66
+ # Unified log callback management
67
+ class WebSocketLogger:
68
+ """
69
+ WebSocket-aware logger that wraps websocket communications.
70
+
71
+ Provides logger-style methods (info, warning, etc.) similar to loguru,
72
+ with support for flush and end parameters for streaming output.
73
+ Compatible with sirchmunk.utils.log_utils.AsyncLogger interface.
74
+ """
75
+
76
+ def __init__(self, websocket: WebSocket, manager: Optional[ChatConnectionManager] = None, log_type: str = "log", task_id: Optional[str] = None):
77
+ """
78
+ Initialize WebSocket logger.
79
+
80
+ Args:
81
+ websocket: WebSocket connection to send logs to
82
+ manager: Optional ConnectionManager for routing messages
83
+ log_type: Type of log message ("log" or "search_log")
84
+ task_id: Optional task ID for grouping related log messages
85
+ """
86
+ self.websocket = websocket
87
+ self.manager = manager
88
+ self.log_type = log_type
89
+ self.task_id = task_id or str(uuid.uuid4()) # Generate unique task ID
90
+
91
+ async def _send_log(self, level: str, message: str, flush: bool = False, end: str = "\n"):
92
+ """
93
+ Send log message through WebSocket.
94
+
95
+ Args:
96
+ level: Log level (info, warning, error, etc.)
97
+ message: Message content
98
+ flush: If True, force immediate output (adds small delay for streaming)
99
+ end: String appended after message (default: "\n")
100
+ """
101
+ # Append end character to message
102
+ full_message = message + end if end else message
103
+
104
+ # Determine if this is a streaming message (no timestamp prefix should be added on frontend)
105
+ # Streaming condition: message should be appended to current line (end is not a newline)
106
+ # This indicates it's part of a multi-chunk streaming output (like LLM responses)
107
+ is_streaming = end != "\n"
108
+
109
+ # Prepare log message
110
+ log_data = {
111
+ "type": self.log_type,
112
+ "level": level,
113
+ "message": full_message,
114
+ "timestamp": datetime.now().isoformat(),
115
+ "is_streaming": is_streaming, # Flag for frontend to know if this is streaming output
116
+ "task_id": self.task_id, # Task ID for grouping related messages
117
+ "flush": flush, # Include flush flag for frontend handling
118
+ }
119
+
120
+ # Send through WebSocket
121
+ if self.manager:
122
+ await self.manager.send_personal_message(json.dumps(log_data), self.websocket)
123
+ else:
124
+ await self.websocket.send_text(json.dumps(log_data))
125
+
126
+ # If flush is requested, add small delay for proper streaming
127
+ if flush:
128
+ await asyncio.sleep(0.01) # Very short delay for streaming (reduced from 0.05s)
129
+ else:
130
+ await asyncio.sleep(0.05) # Standard delay (reduced from 0.1s)
131
+
132
+ async def log(self, level: str, message: str, flush: bool = False, end: str = "\n"):
133
+ """Log a message at the specified level"""
134
+ await self._send_log(level, message, flush=flush, end=end)
135
+
136
+ async def debug(self, message: str, flush: bool = False, end: str = "\n"):
137
+ """Log a debug message"""
138
+ await self._send_log("debug", message, flush=flush, end=end)
139
+
140
+ async def info(self, message: str, flush: bool = False, end: str = "\n"):
141
+ """Log an info message"""
142
+ await self._send_log("info", message, flush=flush, end=end)
143
+
144
+ async def warning(self, message: str, flush: bool = False, end: str = "\n"):
145
+ """Log a warning message"""
146
+ await self._send_log("warning", message, flush=flush, end=end)
147
+
148
+ async def error(self, message: str, flush: bool = False, end: str = "\n"):
149
+ """Log an error message"""
150
+ await self._send_log("error", message, flush=flush, end=end)
151
+
152
+ async def success(self, message: str, flush: bool = False, end: str = "\n"):
153
+ """Log a success message"""
154
+ await self._send_log("success", message, flush=flush, end=end)
155
+
156
+ async def critical(self, message: str, flush: bool = False, end: str = "\n"):
157
+ """Log a critical message"""
158
+ await self._send_log("critical", message, flush=flush, end=end)
159
+
160
+
161
+ class LogCallbackManager:
162
+ """
163
+ Centralized management for all log callback functions.
164
+
165
+ Creates callback functions and logger instances that are compatible with
166
+ sirchmunk.utils.log_utils.AsyncLogger interface, supporting flush and end parameters.
167
+ """
168
+
169
+ @staticmethod
170
+ async def create_search_log_callback(websocket: WebSocket, manager: ChatConnectionManager, task_id: Optional[str] = None):
171
+ """
172
+ Create search log callback for chat WebSocket.
173
+
174
+ Returns a callback function compatible with log_utils signature:
175
+ async def callback(level: str, message: str, end: str, flush: bool)
176
+
177
+ NOTE: The signature MUST match log_utils.LogCallback exactly:
178
+ (level: str, message: str, end: str, flush: bool) -> None
179
+
180
+ Args:
181
+ websocket: WebSocket connection
182
+ manager: Connection manager for routing
183
+ task_id: Optional task ID for grouping related messages (auto-generated if not provided)
184
+
185
+ Returns:
186
+ Async callback function
187
+ """
188
+ # Generate unique task ID for this search session
189
+ if task_id is None:
190
+ task_id = f"search_{uuid.uuid4().hex[:8]}"
191
+
192
+ logger = WebSocketLogger(websocket, manager, log_type="search_log", task_id=task_id)
193
+
194
+ # Track recent messages for deduplication (message -> timestamp)
195
+ recent_messages: Dict[str, float] = {}
196
+ DEDUP_WINDOW_SEC = 0.5 # Messages within this window are considered duplicates
197
+
198
+ # CRITICAL: This callback signature MUST match log_utils.LogCallback
199
+ # Signature: (level: str, message: str, end: str, flush: bool) -> None
200
+ async def search_log_callback(level: str, message: str, end: str, flush: bool):
201
+ """
202
+ Log callback compatible with log_utils.LogCallback type.
203
+
204
+ Args:
205
+ level: Log level (info, warning, error, etc.)
206
+ message: Message content (WITHOUT end character appended)
207
+ end: String to append after message
208
+ flush: Whether to flush immediately
209
+ """
210
+ import time
211
+ nonlocal recent_messages
212
+
213
+ # Create unique key for this message (include level and message content)
214
+ msg_key = f"{level}:{message}"
215
+ current_time = time.time()
216
+
217
+ # Check for duplicate within time window
218
+ if msg_key in recent_messages:
219
+ last_time = recent_messages[msg_key]
220
+ if current_time - last_time < DEDUP_WINDOW_SEC:
221
+ # Skip duplicate message within dedup window
222
+ return
223
+
224
+ # Clean up old entries (older than 2x window)
225
+ cutoff = current_time - (DEDUP_WINDOW_SEC * 2)
226
+ recent_messages = {k: v for k, v in recent_messages.items() if v > cutoff}
227
+
228
+ # Record this message
229
+ recent_messages[msg_key] = current_time
230
+
231
+ await logger._send_log(level, message, flush=flush, end=end)
232
+
233
+ return search_log_callback
234
+
235
+ @staticmethod
236
+ def create_logger(websocket: WebSocket, manager: Optional[ChatConnectionManager] = None, log_type: str = "log", task_id: Optional[str] = None) -> WebSocketLogger:
237
+ """
238
+ Create a WebSocketLogger instance with logger-style methods.
239
+
240
+ This provides a logger interface similar to create_logger from log_utils,
241
+ allowing usage like: await logger.info("message", flush=True, end="")
242
+
243
+ Args:
244
+ websocket: WebSocket connection
245
+ manager: Optional ConnectionManager for routing messages
246
+ log_type: Type of log message ("log" or "search_log")
247
+ task_id: Optional task ID for grouping related messages (auto-generated if not provided)
248
+
249
+ Returns:
250
+ WebSocketLogger instance
251
+
252
+ Example:
253
+ logger = LogCallbackManager.create_logger(websocket, manager, "search_log")
254
+ await logger.info("Processing started")
255
+ await logger.info("Loading", flush=True, end=" -> ")
256
+ await logger.success("Done!", flush=True)
257
+ """
258
+ if task_id is None:
259
+ task_id = f"logger_{uuid.uuid4().hex[:8]}"
260
+ return WebSocketLogger(websocket, manager, log_type, task_id)
261
+
262
+ manager = ChatConnectionManager()
263
+
264
+ # Search-related models and functions
265
+ class SearchRequest(BaseModel):
266
+ query: str
267
+ search_paths: Union[str, List[str]] # Expects absolute file/directory paths from user's local filesystem
268
+ mode: Optional[str] = "DEEP"
269
+ max_depth: Optional[int] = 5
270
+ top_k_files: Optional[int] = 3
271
+
272
+
273
+ def get_envs() -> Dict[str, Any]:
274
+ """
275
+ Get LLM configuration from settings storage or environment variables.
276
+ """
277
+ # Try to get from settings storage first (if available)
278
+ if settings_storage is not None:
279
+ base_url = settings_storage.get_env_variable("LLM_BASE_URL", "")
280
+ api_key = settings_storage.get_env_variable("LLM_API_KEY", "")
281
+ model_name = settings_storage.get_env_variable("LLM_MODEL_NAME", "")
282
+ else:
283
+ base_url = ""
284
+ api_key = ""
285
+ model_name = ""
286
+
287
+ # Fallback to environment variables if not in settings
288
+ if not base_url:
289
+ base_url = LLM_BASE_URL
290
+ if not api_key:
291
+ api_key = LLM_API_KEY
292
+ if not model_name:
293
+ model_name = LLM_MODEL_NAME
294
+
295
+ print(f"[ENV CONFIG] base_url={base_url}, model_name={model_name}, api_key={'***' if api_key else '(not set)'}")
296
+
297
+ return dict(
298
+ base_url=base_url,
299
+ api_key=api_key,
300
+ model_name=model_name,
301
+ )
302
+
303
+
304
+ def get_search_instance(log_callback=None):
305
+ """
306
+ Get configured search instance with optional log callback.
307
+
308
+ Creates OpenAIChat instance with settings from DuckDB (priority) or environment variables (fallback).
309
+
310
+ Args:
311
+ log_callback: Optional callback for logging
312
+
313
+ Returns:
314
+ Configured AgenticSearch instance
315
+ """
316
+ # Get LLM configuration from settings storage (priority) or env variables (fallback)
317
+ try:
318
+ envs = get_envs()
319
+
320
+ # Create OpenAI LLM instance with retrieved configuration
321
+ llm = OpenAIChat(
322
+ base_url=envs["base_url"],
323
+ api_key=envs["api_key"],
324
+ model=envs["model_name"],
325
+ log_callback=log_callback,
326
+ )
327
+
328
+ # Create and return AgenticSearch instance with configured LLM
329
+ return AgenticSearch(llm=llm, log_callback=log_callback)
330
+
331
+ except Exception as e:
332
+ # If settings retrieval fails, fall back to default AgenticSearch initialization
333
+ print(f"[WARNING] Please config ENVs: LLM_BASE_URL, LLM_API_KEY, LLM_MODEL_NAME. Error: {e}")
334
+ return AgenticSearch(log_callback=log_callback)
335
+
336
+
337
+ _COOLDOWN_SECONDS = 1.0
338
+ _DIALOG_LOCK = threading.Lock()
339
+ _LAST_CLOSE_TIME = 0
340
+ _ROOT_INSTANCE = None
341
+
342
+
343
+ def _get_bg_root():
344
+ """
345
+ Retrieves the global root window.
346
+ Initializes it only once (Singleton pattern) to prevent lag.
347
+ """
348
+ global _ROOT_INSTANCE
349
+
350
+ if threading.current_thread() is not threading.main_thread():
351
+ raise RuntimeError("Tkinter must be executed on the Main Thread.")
352
+
353
+ if _ROOT_INSTANCE is None or not _ROOT_INSTANCE.winfo_exists():
354
+ _ROOT_INSTANCE = tk.Tk()
355
+ _ROOT_INSTANCE.title("File Picker")
356
+ _ROOT_INSTANCE.attributes("-alpha", 0.0)
357
+ _ROOT_INSTANCE.withdraw()
358
+
359
+ return _ROOT_INSTANCE
360
+
361
+
362
+ def open_file_dialog(dialog_type: str = "files", multiple: bool = True) -> List[str]:
363
+ """
364
+ Opens a native file picker dialog using tkinter.
365
+ """
366
+ global _LAST_CLOSE_TIME
367
+
368
+ if not _DIALOG_LOCK.acquire(blocking=False):
369
+ return []
370
+
371
+ selected_paths = []
372
+
373
+ try:
374
+ if time.time() - _LAST_CLOSE_TIME < _COOLDOWN_SECONDS:
375
+ return []
376
+
377
+ root = _get_bg_root()
378
+ root.deiconify()
379
+ root.attributes("-topmost", True)
380
+ root.lift()
381
+ root.focus_force()
382
+
383
+ if platform.system() == "Darwin":
384
+ root.update_idletasks()
385
+ else:
386
+ root.update()
387
+
388
+ kwargs = {"parent": root, "title": "Select File(s)"}
389
+
390
+ # Set file types filter
391
+ if dialog_type == "files":
392
+ filetypes = [
393
+ ("All Files", "*.*"),
394
+ ("PDF Documents", "*.pdf"),
395
+ ("Word Documents", "*.docx *.doc"),
396
+ ("Excel Spreadsheets", "*.xlsx *.xls *.csv"),
397
+ ("Images", "*.png *.jpg *.jpeg *.gif *.svg"),
398
+ ("Text Files", "*.txt *.md *.json *.xml"),
399
+ ]
400
+
401
+ if multiple:
402
+ res = filedialog.askopenfilenames(filetypes=filetypes, **kwargs)
403
+ selected_paths = list(res) if res else []
404
+ else:
405
+ res = filedialog.askopenfilename(filetypes=filetypes, **kwargs)
406
+ selected_paths = [res] if res else []
407
+
408
+ elif dialog_type == "directory":
409
+ kwargs["title"] = "Select Directory"
410
+ res = filedialog.askdirectory(**kwargs)
411
+ selected_paths = [res] if res else []
412
+
413
+ except Exception as e:
414
+ print(f"Dialog Error: {e}")
415
+ selected_paths = []
416
+
417
+ finally:
418
+ if _ROOT_INSTANCE is not None and _ROOT_INSTANCE.winfo_exists():
419
+ _ROOT_INSTANCE.attributes("-topmost", False)
420
+ _ROOT_INSTANCE.withdraw()
421
+ _ROOT_INSTANCE.update()
422
+
423
+ _LAST_CLOSE_TIME = time.time()
424
+ _DIALOG_LOCK.release()
425
+
426
+ return selected_paths
427
+
428
+
429
+ async def _perform_web_search(query: str, websocket: WebSocket, manager: ChatConnectionManager) -> Dict[str, Any]:
430
+ """
431
+ Mock web search functionality
432
+ TODO: Replace with actual web search implementation
433
+ """
434
+ await manager.send_personal_message(json.dumps({
435
+ "type": "search_log",
436
+ "level": "info",
437
+ "message": "🌐 Starting web search...",
438
+ "timestamp": datetime.now().isoformat()
439
+ }), websocket)
440
+
441
+ # Simulate web search delay
442
+ await asyncio.sleep(random.uniform(0.5, 1.0))
443
+
444
+ await manager.send_personal_message(json.dumps({
445
+ "type": "search_log",
446
+ "level": "info",
447
+ "message": f"🔎 Searching web for: {query}",
448
+ "timestamp": datetime.now().isoformat()
449
+ }), websocket)
450
+
451
+ await asyncio.sleep(random.uniform(0.5, 1.0))
452
+
453
+ # Mock web search results
454
+ web_results = {
455
+ "sources": [
456
+ {
457
+ "url": "https://example.com/article1",
458
+ "title": "Comprehensive Guide to " + query[:30],
459
+ "snippet": "This article provides detailed information about the subject matter...",
460
+ "relevance_score": 0.95
461
+ },
462
+ {
463
+ "url": "https://example.com/article2",
464
+ "title": "Advanced Concepts and Applications",
465
+ "snippet": "Exploring advanced techniques and real-world applications...",
466
+ "relevance_score": 0.87
467
+ },
468
+ {
469
+ "url": "https://example.com/article3",
470
+ "title": "Latest Research and Findings",
471
+ "snippet": "Recent discoveries and innovations in this field...",
472
+ "relevance_score": 0.82
473
+ }
474
+ ],
475
+ "summary": f"Found 3 relevant web sources for '{query}'. The sources cover comprehensive guides, advanced concepts, and latest research."
476
+ }
477
+
478
+ await manager.send_personal_message(json.dumps({
479
+ "type": "search_log",
480
+ "level": "success",
481
+ "message": f"✅ Web search completed: found {len(web_results['sources'])} sources",
482
+ "timestamp": datetime.now().isoformat()
483
+ }), websocket)
484
+
485
+ return web_results
486
+
487
+ async def _chat_only(
488
+ message: str,
489
+ websocket: WebSocket,
490
+ manager: ChatConnectionManager
491
+ ) -> tuple[str, Dict[str, Any]]:
492
+ """
493
+ Mode 1: Pure chat mode (no RAG, no web search)
494
+ Direct LLM chat without any retrieval augmentation
495
+ """
496
+ try:
497
+ await manager.send_personal_message(json.dumps({
498
+ "type": "status",
499
+ "stage": "generating",
500
+ "message": "💬 Generating response..."
501
+ }), websocket)
502
+
503
+ # Create log callback for streaming LLM output
504
+ llm_log_callback = await LogCallbackManager.create_search_log_callback(websocket, manager)
505
+
506
+ # Initialize OpenAI client with log callback for streaming
507
+ envs: Dict[str, Any] = get_envs()
508
+ llm = OpenAIChat(
509
+ api_key=envs["api_key"],
510
+ base_url=envs["base_url"],
511
+ model=envs["model_name"],
512
+ log_callback=llm_log_callback
513
+ )
514
+
515
+ # Prepare messages for LLM
516
+ messages = [
517
+ {"role": "system", "content": "You are a helpful AI assistant. Provide clear, accurate, and helpful responses."},
518
+ {"role": "user", "content": message}
519
+ ]
520
+
521
+ # Generate response with streaming
522
+ llm_response = await llm.achat(messages=messages, stream=True)
523
+
524
+ # Record LLM usage for monitoring (always record call, even if usage is empty)
525
+ # Some LLM APIs don't return usage in streaming mode
526
+ usage_data = llm_response.usage if llm_response.usage else {}
527
+ llm_usage_tracker.record_usage(
528
+ model=llm_response.model or envs["model_name"],
529
+ usage=usage_data
530
+ )
531
+
532
+ sources = {}
533
+
534
+ return llm_response.content, sources
535
+
536
+ except Exception as e:
537
+ # Send error message to frontend
538
+ await manager.send_personal_message(json.dumps({
539
+ "type": "error",
540
+ "message": f"LLM chat failed: {str(e)}"
541
+ }), websocket)
542
+
543
+ # Re-raise to be caught by outer handler
544
+ raise
545
+
546
+
547
+ async def _chat_rag(
548
+ message: str,
549
+ kb_name: str,
550
+ websocket: WebSocket,
551
+ manager: ChatConnectionManager
552
+ ) -> tuple[str, Dict[str, Any]]:
553
+ """
554
+ Mode 2: Chat + RAG (enable_rag=True, enable_web_search=False)
555
+ LLM chat with knowledge base retrieval
556
+ """
557
+ sources = {}
558
+ if not kb_name:
559
+ await manager.send_personal_message(json.dumps({
560
+ "type": "error",
561
+ "message": "No search paths specified for RAG search."
562
+ }), websocket)
563
+ response = "Please specify search paths for RAG search."
564
+ return response, sources
565
+
566
+ try:
567
+ # Create log callback for streaming search logs
568
+ search_log_callback = await LogCallbackManager.create_search_log_callback(websocket, manager)
569
+
570
+ # Create search instance with log callback
571
+ search_engine = get_search_instance(log_callback=search_log_callback)
572
+
573
+ search_paths = [path.strip() for path in kb_name.split(",")]
574
+ await search_log_callback("info", f"📂 Parsed search paths: {search_paths}", "\n", False)
575
+
576
+ # Execute RAG search
577
+ print(f"[MODE 2] RAG search with query: {message}, paths: {search_paths}")
578
+
579
+ search_result = await search_engine.search(
580
+ query=message,
581
+ search_paths=search_paths,
582
+ max_depth=5,
583
+ top_k_files=3,
584
+ verbose=True
585
+ )
586
+
587
+ # Calculate the llm usage
588
+ for usage in search_engine.llm_usages:
589
+ llm_usage_tracker.record_usage(
590
+ model=search_engine.llm._model,
591
+ usage=usage,
592
+ )
593
+
594
+ # Send search completion
595
+ await manager.send_personal_message(json.dumps({
596
+ "type": "search_complete",
597
+ "message": "✅ Knowledge base search completed"
598
+ }), websocket)
599
+
600
+ # Use search result as response
601
+ response = search_result
602
+
603
+ # Add RAG sources
604
+ sources["rag"] = [
605
+ {
606
+ "kb_name": kb_name,
607
+ "content": f"Retrieved content from {kb_name}",
608
+ "relevance_score": 0.92
609
+ }
610
+ ]
611
+
612
+ except Exception as e:
613
+ # Send search error
614
+ await manager.send_personal_message(json.dumps({
615
+ "type": "search_error",
616
+ "message": f"❌ RAG search failed: {str(e)}"
617
+ }), websocket)
618
+
619
+ # Fallback to chat only
620
+ await manager.send_personal_message(json.dumps({
621
+ "type": "status",
622
+ "stage": "fallback",
623
+ "message": "⚠️ RAG mode did not find relevant results, falling back to pure chat mode..."
624
+ }), websocket)
625
+
626
+ print(f"[MODE 2] RAG search failed, falling back to chat only: {str(e)}")
627
+ response, sources = await _chat_only(message, websocket, manager)
628
+
629
+ return response, sources
630
+
631
+
632
+ async def _chat_web_search(
633
+ message: str,
634
+ websocket: WebSocket,
635
+ manager: ChatConnectionManager
636
+ ) -> tuple[str, Dict[str, Any]]:
637
+ """
638
+ Mode 3: Chat + Web Search (enable_rag=False, enable_web_search=True)
639
+ LLM chat with web search augmentation (currently mock)
640
+ """
641
+ await manager.send_personal_message(json.dumps({
642
+ "type": "status",
643
+ "stage": "web_search",
644
+ "message": "🌐 Searching the web..."
645
+ }), websocket)
646
+
647
+ # Perform mock web search
648
+ web_results = await _perform_web_search(message, websocket, manager)
649
+
650
+ # Check if web search returned valid results
651
+ if not web_results or not web_results.get("sources"):
652
+ # Fallback to chat only
653
+ await manager.send_personal_message(json.dumps({
654
+ "type": "status",
655
+ "stage": "fallback",
656
+ "message": "⚠️ Web search did not return results, falling back to pure chat mode..."
657
+ }), websocket)
658
+
659
+ print(f"[MODE 3] Web search failed, falling back to chat only")
660
+ response, sources = await _chat_only(message, websocket, manager)
661
+ return response, sources
662
+
663
+ # Generate response enhanced with web search results
664
+ web_context = "\n\nBased on web search results:\n"
665
+ for source in web_results["sources"]:
666
+ web_context += f"- {source['title']}: {source['snippet']}\n"
667
+
668
+ # Use LLM to generate response with web context
669
+ await manager.send_personal_message(json.dumps({
670
+ "type": "status",
671
+ "stage": "generating",
672
+ "message": "💬 Generating response with web context..."
673
+ }), websocket)
674
+
675
+ envs: Dict[str, Any] = get_envs()
676
+ llm_log_callback = await LogCallbackManager.create_search_log_callback(websocket, manager)
677
+ llm = OpenAIChat(
678
+ api_key=envs["api_key"],
679
+ base_url=envs["base_url"],
680
+ model=envs["model_name"],
681
+ log_callback=llm_log_callback
682
+ )
683
+
684
+ messages = [
685
+ {"role": "system", "content": "You are a helpful AI assistant. Use the provided web search results to answer the user's question accurately."},
686
+ {"role": "user", "content": f"{message}\n\nWeb search context:\n{web_context}"}
687
+ ]
688
+
689
+ llm_response = await llm.achat(messages=messages, stream=True)
690
+
691
+ # Record LLM usage for monitoring (always record call, even if usage is empty)
692
+ usage_data = llm_response.usage if llm_response.usage else {}
693
+ llm_usage_tracker.record_usage(
694
+ model=llm_response.model or envs["model_name"],
695
+ usage=usage_data
696
+ )
697
+
698
+ sources = {"web": web_results["sources"]}
699
+
700
+ return llm_response.content, sources
701
+
702
+
703
+ async def _chat_rag_web_search(
704
+ message: str,
705
+ kb_name: str,
706
+ websocket: WebSocket,
707
+ manager: ChatConnectionManager
708
+ ) -> tuple[str, Dict[str, Any]]:
709
+ """
710
+ Mode 4: Chat + RAG + Web Search (enable_rag=True, enable_web_search=True)
711
+ LLM chat with both knowledge base retrieval and web search
712
+ """
713
+ sources = {}
714
+ if not kb_name:
715
+ await manager.send_personal_message(json.dumps({
716
+ "type": "error",
717
+ "message": "No search paths specified for RAG search."
718
+ }), websocket)
719
+ response = "Please specify search paths for RAG search."
720
+ return response, sources
721
+
722
+ # Step 1: Perform RAG search
723
+ try:
724
+ search_log_callback = await LogCallbackManager.create_search_log_callback(websocket, manager)
725
+
726
+ search_engine = get_search_instance(log_callback=search_log_callback)
727
+ search_paths = [path.strip() for path in kb_name.split(",")]
728
+ await search_log_callback("info", f"📂 RAG search paths: {search_paths}", "\n", False)
729
+
730
+ print(f"[MODE 4] RAG search with query: {message}, paths: {search_paths}")
731
+
732
+ rag_result = await search_engine.search(
733
+ query=message,
734
+ search_paths=search_paths,
735
+ max_depth=5,
736
+ top_k_files=3,
737
+ verbose=True
738
+ )
739
+
740
+ for rag_usage in search_engine.llm_usages:
741
+ llm_usage_tracker.record_usage(
742
+ model=search_engine.llm._model,
743
+ usage=rag_usage,
744
+ )
745
+
746
+ await manager.send_personal_message(json.dumps({
747
+ "type": "search_complete",
748
+ "message": "✅ Knowledge base search completed"
749
+ }), websocket)
750
+
751
+ sources["rag"] = [
752
+ {
753
+ "kb_name": kb_name,
754
+ "content": f"Retrieved from {kb_name}",
755
+ "relevance_score": 0.92
756
+ }
757
+ ]
758
+
759
+ except Exception as e:
760
+ await manager.send_personal_message(json.dumps({
761
+ "type": "search_error",
762
+ "message": f"⚠️ RAG search failed: {str(e)}, continuing with web search..."
763
+ }), websocket)
764
+ rag_result = f"[RAG search unavailable: {str(e)}]"
765
+ sources["rag"] = [{"error": str(e)}]
766
+
767
+ # Step 2: Perform web search
768
+ await manager.send_personal_message(json.dumps({
769
+ "type": "status",
770
+ "stage": "web_search",
771
+ "message": "🌐 Step 2/2: Searching the web..."
772
+ }), websocket)
773
+
774
+ # TODO: add llm usage
775
+ web_results = await _perform_web_search(message, websocket, manager)
776
+ sources["web"] = web_results["sources"]
777
+
778
+ # Combine results
779
+ web_context = "\n\n## Additional Web Sources:\n"
780
+ for source in web_results["sources"]:
781
+ web_context += f"- [{source['title']}]({source['url']})\n"
782
+
783
+ # If RAG succeeded, use it as primary response; otherwise use web search only
784
+ if rag_result and "[RAG search unavailable" not in rag_result:
785
+ response = rag_result + web_context
786
+ else:
787
+ response = f"Based on web search results:\n{web_context}"
788
+
789
+ return response, sources
790
+
791
+
792
+ # WebSocket endpoint for chat with integrated search
793
+ @router.websocket("/chat")
794
+ async def chat_websocket(websocket: WebSocket):
795
+ """
796
+ WebSocket endpoint for real-time chat conversations with integrated search
797
+
798
+ Supports 4 modes:
799
+ 1. Pure chat: enable_rag=False, enable_web_search=False
800
+ 2. Chat + RAG: enable_rag=True, enable_web_search=False
801
+ 3. Chat + Web Search: enable_rag=False, enable_web_search=True (mock)
802
+ 4. Chat + RAG + Web Search: enable_rag=True, enable_web_search=True (RAG real, web mock)
803
+ """
804
+ await manager.connect(websocket)
805
+
806
+ try:
807
+ while True:
808
+ # Receive message from client
809
+ data = await websocket.receive_text()
810
+ request_data = json.loads(data)
811
+
812
+ message = request_data.get("message", "")
813
+ session_id = request_data.get("session_id")
814
+ history = request_data.get("history", [])
815
+ kb_name = request_data.get("kb_name", "")
816
+ enable_rag = request_data.get("enable_rag", False)
817
+ enable_web_search = request_data.get("enable_web_search", False)
818
+
819
+ print(f"\n{'='*60}")
820
+ print(f"[CHAT REQUEST] Message: {message[:50]}...")
821
+ print(f"[CHAT REQUEST] KB: {kb_name}, RAG: {enable_rag}, Web: {enable_web_search}")
822
+ print(f"{'='*60}\n")
823
+
824
+ # Generate or use existing session ID
825
+ if not session_id:
826
+ session_id = f"chat_{uuid.uuid4().hex[:8]}"
827
+
828
+ # Send session ID to client
829
+ await manager.send_personal_message(json.dumps({
830
+ "type": "session",
831
+ "session_id": session_id
832
+ }), websocket)
833
+
834
+ # Store session data (in-memory + persistent)
835
+ if session_id not in chat_sessions:
836
+ chat_sessions[session_id] = {
837
+ "session_id": session_id,
838
+ "title": f"Chat Session",
839
+ "messages": [],
840
+ "created_at": datetime.now().isoformat(),
841
+ "updated_at": datetime.now().isoformat(),
842
+ "settings": {
843
+ "kb_name": kb_name,
844
+ "enable_rag": enable_rag,
845
+ "enable_web_search": enable_web_search
846
+ }
847
+ }
848
+ # Save new session to persistent storage
849
+ history_storage.save_session(chat_sessions[session_id])
850
+
851
+ # Update session with new message
852
+ session = chat_sessions[session_id]
853
+ user_message = {
854
+ "role": "user",
855
+ "content": message,
856
+ "timestamp": datetime.now().isoformat()
857
+ }
858
+ session["messages"].append(user_message)
859
+ session["updated_at"] = datetime.now().isoformat()
860
+
861
+ # Save user message to persistent storage
862
+ history_storage.save_message(session_id, user_message)
863
+
864
+ # ============================================================
865
+ # Route to appropriate chat mode based on feature flags
866
+ # ============================================================
867
+ response = ""
868
+ sources = {}
869
+
870
+ if enable_rag and enable_web_search:
871
+ # Mode 4: Chat + RAG + Web Search
872
+ print(f"[MODE 4] Chat + RAG + Web Search")
873
+ response, sources = await _chat_rag_web_search(
874
+ message, kb_name, websocket, manager
875
+ )
876
+
877
+ elif enable_rag and not enable_web_search:
878
+ # Mode 2: Chat + RAG
879
+ print(f"[MODE 2] Chat + RAG")
880
+ response, sources = await _chat_rag(
881
+ message, kb_name, websocket, manager
882
+ )
883
+
884
+ elif not enable_rag and enable_web_search:
885
+ # Mode 3: Chat + Web Search only
886
+ print(f"[MODE 3] Chat + Web Search only")
887
+ response, sources = await _chat_web_search(
888
+ message, websocket, manager
889
+ )
890
+
891
+ else:
892
+ # Mode 1: Pure chat (no RAG, no web search)
893
+ print(f"[MODE 1] Pure chat mode")
894
+ response, sources = await _chat_only(
895
+ message, websocket, manager
896
+ )
897
+
898
+ # ============================================================
899
+ # Stream response to client
900
+ # ============================================================
901
+ words = response.split()
902
+
903
+ for i, word in enumerate(words):
904
+ await manager.send_personal_message(json.dumps({
905
+ "type": "stream",
906
+ "content": word + " "
907
+ }), websocket)
908
+
909
+ # Add small delay for realistic streaming
910
+ if i % 3 == 0: # Every 3 words
911
+ await asyncio.sleep(0.05)
912
+
913
+ # Send sources if available
914
+ if sources:
915
+ await manager.send_personal_message(json.dumps({
916
+ "type": "sources",
917
+ **sources
918
+ }), websocket)
919
+
920
+ # Send final result
921
+ await manager.send_personal_message(json.dumps({
922
+ "type": "result",
923
+ "content": response.strip(),
924
+ "session_id": session_id
925
+ }), websocket)
926
+
927
+ # Store assistant response in session
928
+ assistant_message = {
929
+ "role": "assistant",
930
+ "content": response.strip(),
931
+ "sources": sources if sources else None,
932
+ "timestamp": datetime.now().isoformat()
933
+ }
934
+ session["messages"].append(assistant_message)
935
+
936
+ # Save assistant message to persistent storage
937
+ history_storage.save_message(session_id, assistant_message)
938
+
939
+ # Update session in persistent storage
940
+ history_storage.save_session(session)
941
+
942
+ except WebSocketDisconnect:
943
+ manager.disconnect(websocket)
944
+ except Exception as e:
945
+ print(f"[ERROR] WebSocket error: {str(e)}")
946
+ import traceback
947
+ traceback.print_exc()
948
+ try:
949
+ await manager.send_personal_message(json.dumps({
950
+ "type": "error",
951
+ "message": f"An error occurred: {str(e)}"
952
+ }), websocket)
953
+ except:
954
+ pass
955
+ manager.disconnect(websocket)
956
+
957
+
958
+ # File picker endpoints
959
+ @router.post("/file-picker")
960
+ async def open_file_picker(request: Dict[str, Any]):
961
+ """
962
+ Open native file picker dialog using tkinter
963
+ Returns real absolute paths from user's local filesystem
964
+ """
965
+ if not TKINTER_AVAILABLE:
966
+ return {
967
+ "success": False,
968
+ "error": "Tkinter not available on this system",
969
+ "data": []
970
+ }
971
+
972
+ dialog_type = request.get("type", "files") # "files" or "directory"
973
+ multiple = request.get("multiple", True)
974
+
975
+ try:
976
+ # Get absolute paths from user's local filesystem
977
+ selected_paths = open_file_dialog(dialog_type, multiple)
978
+
979
+ # Convert to absolute paths and validate they exist
980
+ validated_paths = []
981
+ for path in selected_paths:
982
+ abs_path = os.path.abspath(path)
983
+ if os.path.exists(abs_path):
984
+ validated_paths.append(abs_path)
985
+
986
+ return {
987
+ "success": True,
988
+ "data": {
989
+ "paths": validated_paths,
990
+ "count": len(validated_paths),
991
+ "type": dialog_type,
992
+ "multiple": multiple
993
+ }
994
+ }
995
+
996
+ except Exception as e:
997
+ return {
998
+ "success": False,
999
+ "error": f"Failed to open file picker: {str(e)}",
1000
+ "data": []
1001
+ }
1002
+
1003
+ @router.get("/file-picker/status")
1004
+ async def get_file_picker_status():
1005
+ """Check if file picker is available on this system"""
1006
+ return {
1007
+ "success": True,
1008
+ "data": {
1009
+ "tkinter_available": TKINTER_AVAILABLE,
1010
+ "supported_types": ["files", "directory"] if TKINTER_AVAILABLE else [],
1011
+ "features": {
1012
+ "multiple_files": TKINTER_AVAILABLE,
1013
+ "directory_selection": TKINTER_AVAILABLE,
1014
+ "absolute_paths": TKINTER_AVAILABLE
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ # Chat session management endpoints
1020
+ @router.get("/chat/sessions")
1021
+ async def get_chat_sessions(limit: int = 20, offset: int = 0):
1022
+ """Get list of chat sessions"""
1023
+ sessions_list = list(chat_sessions.values())
1024
+ # Sort by updated_at (most recent first)
1025
+ sessions_list.sort(key=lambda x: x["updated_at"], reverse=True)
1026
+
1027
+ # Apply pagination
1028
+ paginated_sessions = sessions_list[offset:offset + limit]
1029
+
1030
+ # Format for response
1031
+ formatted_sessions = []
1032
+ for session in paginated_sessions:
1033
+ last_message = ""
1034
+ if session["messages"]:
1035
+ last_msg = session["messages"][-1]
1036
+ last_message = last_msg["content"][:100] + "..." if len(last_msg["content"]) > 100 else last_msg["content"]
1037
+
1038
+ formatted_sessions.append({
1039
+ "session_id": session["session_id"],
1040
+ "title": session.get("title", "Chat Session"),
1041
+ "message_count": len(session["messages"]),
1042
+ "last_message": last_message,
1043
+ "created_at": int(datetime.fromisoformat(session["created_at"]).timestamp()),
1044
+ "updated_at": int(datetime.fromisoformat(session["updated_at"]).timestamp()),
1045
+ "topics": ["AI", "Learning"] # Mock topics
1046
+ })
1047
+
1048
+ return {
1049
+ "success": True,
1050
+ "data": formatted_sessions,
1051
+ "pagination": {
1052
+ "limit": limit,
1053
+ "offset": offset,
1054
+ "total": len(sessions_list)
1055
+ }
1056
+ }
1057
+
1058
+ @router.get("/chat/sessions/{session_id}")
1059
+ async def get_chat_session(session_id: str):
1060
+ """Get specific chat session details"""
1061
+ if session_id not in chat_sessions:
1062
+ raise HTTPException(status_code=404, detail="Chat session not found")
1063
+
1064
+ session = chat_sessions[session_id]
1065
+
1066
+ return {
1067
+ "success": True,
1068
+ "data": {
1069
+ "session_id": session["session_id"],
1070
+ "title": session.get("title", "Chat Session"),
1071
+ "messages": session["messages"],
1072
+ "settings": session.get("settings", {}),
1073
+ "created_at": session["created_at"],
1074
+ "updated_at": session["updated_at"]
1075
+ }
1076
+ }
1077
+
1078
+ @router.post("/chat/sessions/{session_id}/load")
1079
+ async def load_chat_session(session_id: str):
1080
+ """Load chat session for continuation"""
1081
+ if session_id not in chat_sessions:
1082
+ raise HTTPException(status_code=404, detail="Chat session not found")
1083
+
1084
+ session = chat_sessions[session_id]
1085
+
1086
+ return {
1087
+ "success": True,
1088
+ "message": f"Chat session loaded successfully",
1089
+ "data": {
1090
+ "session_id": session_id,
1091
+ "title": session.get("title", "Chat Session"),
1092
+ "message_count": len(session["messages"]),
1093
+ "loaded_at": datetime.now().isoformat()
1094
+ }
1095
+ }
1096
+
1097
+ # Legacy search endpoints for backward compatibility
1098
+ @router.get("/search/{kb_name}/suggestions")
1099
+ async def get_search_suggestions(kb_name: str, query: str, limit: int = 8):
1100
+ """Get search suggestions - kept for backward compatibility"""
1101
+ # For now, return empty suggestions since we're using real file search
1102
+ if not query or len(query.strip()) < 2:
1103
+ return {
1104
+ "success": True,
1105
+ "data": [],
1106
+ "query": query
1107
+ }
1108
+
1109
+ return {
1110
+ "success": True,
1111
+ "data": [],
1112
+ "query": query,
1113
+ "total_matches": 0
1114
+ }
1115
+
1116
+ @router.get("/search/knowledge-bases")
1117
+ async def get_knowledge_bases():
1118
+ """Get list of available knowledge bases for search"""
1119
+ # Return empty list since we're using direct file paths now
1120
+ return {
1121
+ "success": True,
1122
+ "data": []
1123
+ }