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.
- sirchmunk/api/__init__.py +1 -0
- sirchmunk/api/chat.py +1123 -0
- sirchmunk/api/components/__init__.py +0 -0
- sirchmunk/api/components/history_storage.py +402 -0
- sirchmunk/api/components/monitor_tracker.py +518 -0
- sirchmunk/api/components/settings_storage.py +353 -0
- sirchmunk/api/history.py +254 -0
- sirchmunk/api/knowledge.py +411 -0
- sirchmunk/api/main.py +120 -0
- sirchmunk/api/monitor.py +219 -0
- sirchmunk/api/run_server.py +54 -0
- sirchmunk/api/search.py +230 -0
- sirchmunk/api/settings.py +309 -0
- sirchmunk/api/tools.py +315 -0
- sirchmunk/cli/__init__.py +11 -0
- sirchmunk/cli/cli.py +789 -0
- sirchmunk/learnings/knowledge_base.py +5 -2
- sirchmunk/llm/prompts.py +12 -1
- sirchmunk/retrieve/text_retriever.py +186 -2
- sirchmunk/scan/file_scanner.py +2 -2
- sirchmunk/schema/knowledge.py +119 -35
- sirchmunk/search.py +384 -26
- sirchmunk/storage/__init__.py +2 -2
- sirchmunk/storage/{knowledge_manager.py → knowledge_storage.py} +265 -60
- sirchmunk/utils/constants.py +7 -5
- sirchmunk/utils/embedding_util.py +217 -0
- sirchmunk/utils/tokenizer_util.py +36 -1
- sirchmunk/version.py +1 -1
- {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/METADATA +196 -14
- sirchmunk-0.0.2.dist-info/RECORD +69 -0
- {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/WHEEL +1 -1
- sirchmunk-0.0.2.dist-info/top_level.txt +2 -0
- sirchmunk_mcp/__init__.py +25 -0
- sirchmunk_mcp/cli.py +478 -0
- sirchmunk_mcp/config.py +276 -0
- sirchmunk_mcp/server.py +355 -0
- sirchmunk_mcp/service.py +327 -0
- sirchmunk_mcp/setup.py +15 -0
- sirchmunk_mcp/tools.py +410 -0
- sirchmunk-0.0.1.dist-info/RECORD +0 -45
- sirchmunk-0.0.1.dist-info/top_level.txt +0 -1
- {sirchmunk-0.0.1.dist-info → sirchmunk-0.0.2.dist-info}/entry_points.txt +0 -0
- {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
|
+
}
|