diff --git a/surfsense_backend/app/agents/new_chat/chat_deepagent.py b/surfsense_backend/app/agents/new_chat/chat_deepagent.py index 73a39ccbf..ddf87cf2a 100644 --- a/surfsense_backend/app/agents/new_chat/chat_deepagent.py +++ b/surfsense_backend/app/agents/new_chat/chat_deepagent.py @@ -50,7 +50,10 @@ from app.agents.new_chat.system_prompt import ( build_configurable_system_prompt, build_surfsense_system_prompt, ) -from app.agents.new_chat.tools.registry import build_tools_async, get_connector_gated_tools +from app.agents.new_chat.tools.registry import ( + build_tools_async, + get_connector_gated_tools, +) from app.db import ChatVisibility from app.services.connector_service import ConnectorService from app.utils.perf import get_perf_logger @@ -294,9 +297,7 @@ async def create_surfsense_deep_agent( } modified_disabled_tools = list(disabled_tools) if disabled_tools else [] - modified_disabled_tools.extend( - get_connector_gated_tools(available_connectors) - ) + modified_disabled_tools.extend(get_connector_gated_tools(available_connectors)) # Remove direct KB search tool; we now pre-seed a scoped filesystem via middleware. if "search_knowledge_base" not in modified_disabled_tools: @@ -328,7 +329,8 @@ async def create_surfsense_deep_agent( meta = getattr(t, "metadata", None) or {} if meta.get("mcp_is_generic") and meta.get("mcp_connector_name"): _mcp_connector_tools.setdefault( - meta["mcp_connector_name"], [], + meta["mcp_connector_name"], + [], ).append(t.name) if _mcp_connector_tools: diff --git a/surfsense_backend/app/agents/new_chat/middleware/__init__.py b/surfsense_backend/app/agents/new_chat/middleware/__init__.py index 5a24b2f9e..6e4542e1a 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/__init__.py +++ b/surfsense_backend/app/agents/new_chat/middleware/__init__.py @@ -3,12 +3,12 @@ from app.agents.new_chat.middleware.dedup_tool_calls import ( DedupHITLToolCallsMiddleware, ) -from app.agents.new_chat.middleware.filesystem import ( - SurfSenseFilesystemMiddleware, -) from app.agents.new_chat.middleware.file_intent import ( FileIntentMiddleware, ) +from app.agents.new_chat.middleware.filesystem import ( + SurfSenseFilesystemMiddleware, +) from app.agents.new_chat.middleware.knowledge_search import ( KnowledgeBaseSearchMiddleware, ) diff --git a/surfsense_backend/app/agents/new_chat/middleware/file_intent.py b/surfsense_backend/app/agents/new_chat/middleware/file_intent.py index 4bf5dcfe4..05cb230ce 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/file_intent.py +++ b/surfsense_backend/app/agents/new_chat/middleware/file_intent.py @@ -213,7 +213,9 @@ def _build_classifier_prompt(*, recent_conversation: str, user_text: str) -> str ) -def _build_recent_conversation(messages: list[BaseMessage], *, max_messages: int = 6) -> str: +def _build_recent_conversation( + messages: list[BaseMessage], *, max_messages: int = 6 +) -> str: rows: list[str] = [] for msg in messages[-max_messages:]: role = "user" if isinstance(msg, HumanMessage) else "assistant" @@ -246,7 +248,9 @@ class FileIntentMiddleware(AgentMiddleware): # type: ignore[type-arg] [HumanMessage(content=prompt)], config={"tags": ["surfsense:internal"]}, ) - payload = json.loads(_extract_json_payload(_extract_text_from_message(response))) + payload = json.loads( + _extract_json_payload(_extract_text_from_message(response)) + ) plan = FileIntentPlan.model_validate(payload) return plan except (json.JSONDecodeError, ValidationError, ValueError) as exc: @@ -317,4 +321,3 @@ class FileIntentMiddleware(AgentMiddleware): # type: ignore[type-arg] insert_at = max(len(new_messages) - 1, 0) new_messages.insert(insert_at, contract_msg) return {"messages": new_messages, "file_operation_contract": contract} - diff --git a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py index 8dfa89ef2..cb50693f1 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py +++ b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py @@ -877,7 +877,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): suggested_path = contract.get("suggested_path") if isinstance(suggested_path, str) and suggested_path.strip(): normalized_suggested = self._normalize_absolute_path(suggested_path) - suggested_mount = self._extract_mount_from_path(normalized_suggested, mounts) + suggested_mount = self._extract_mount_from_path( + normalized_suggested, mounts + ) matching_mounts = [ mount @@ -1071,14 +1073,18 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ] = False, ) -> Command | str: if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: - return "Error: move_file is only available in desktop local-folder mode." + return ( + "Error: move_file is only available in desktop local-folder mode." + ) if not source_path.strip() or not destination_path.strip(): return "Error: source_path and destination_path are required." resolved_backend = self._get_backend(runtime) source_target = self._resolve_move_target_path(source_path, runtime) - destination_target = self._resolve_move_target_path(destination_path, runtime) + destination_target = self._resolve_move_target_path( + destination_path, runtime + ) try: validated_source = validate_path(source_target) validated_destination = validate_path(destination_target) @@ -1106,7 +1112,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ], } ) - return f"Moved '{validated_source}' to '{res.path or validated_destination}'" + return ( + f"Moved '{validated_source}' to '{res.path or validated_destination}'" + ) async def async_move_file( source_path: Annotated[ @@ -1125,14 +1133,18 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ] = False, ) -> Command | str: if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: - return "Error: move_file is only available in desktop local-folder mode." + return ( + "Error: move_file is only available in desktop local-folder mode." + ) if not source_path.strip() or not destination_path.strip(): return "Error: source_path and destination_path are required." resolved_backend = self._get_backend(runtime) source_target = self._resolve_move_target_path(source_path, runtime) - destination_target = self._resolve_move_target_path(destination_path, runtime) + destination_target = self._resolve_move_target_path( + destination_path, runtime + ) try: validated_source = validate_path(source_target) validated_destination = validate_path(destination_target) @@ -1160,7 +1172,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ], } ) - return f"Moved '{validated_source}' to '{res.path or validated_destination}'" + return ( + f"Moved '{validated_source}' to '{res.path or validated_destination}'" + ) return StructuredTool.from_function( name="move_file", @@ -1201,7 +1215,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ] = True, ) -> str: if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: - return "Error: list_tree is only available in desktop local-folder mode." + return ( + "Error: list_tree is only available in desktop local-folder mode." + ) if max_depth < 0: return "Error: max_depth must be >= 0." if page_size < 1: @@ -1253,7 +1269,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ] = True, ) -> str: if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: - return "Error: list_tree is only available in desktop local-folder mode." + return ( + "Error: list_tree is only available in desktop local-folder mode." + ) if max_depth < 0: return "Error: max_depth must be >= 0." if page_size < 1: diff --git a/surfsense_backend/app/agents/new_chat/middleware/knowledge_search.py b/surfsense_backend/app/agents/new_chat/middleware/knowledge_search.py index 51378a013..6df317aaa 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/knowledge_search.py +++ b/surfsense_backend/app/agents/new_chat/middleware/knowledge_search.py @@ -27,8 +27,8 @@ from pydantic import BaseModel, Field, ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.agents.new_chat.utils import parse_date_or_datetime, resolve_date_range from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.utils import parse_date_or_datetime, resolve_date_range from app.db import ( NATIVE_TO_LEGACY_DOCTYPE, Chunk, diff --git a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py index 0cee3e007..565fcb48b 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py @@ -120,7 +120,9 @@ class LocalFolderBackend: if not target.exists() or not target.is_dir(): return [] infos: list[FileInfo] = [] - for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): + for child in sorted( + target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()) + ): infos.append( FileInfo( path=self._to_virtual(child, self._root), @@ -317,7 +319,9 @@ class LocalFolderBackend: return WriteResult(error="Error: source and destination paths are the same") with self._acquire_path_locks(source_path, destination_path): if not source.exists(): - return WriteResult(error=f"Error: source path '{source_path}' not found") + return WriteResult( + error=f"Error: source path '{source_path}' not found" + ) if destination.exists(): if not overwrite: return WriteResult( @@ -339,8 +343,12 @@ class LocalFolderBackend: else: source.rename(destination) except OSError as exc: - return WriteResult(error=f"Error: failed to move '{source_path}': {exc}") - return WriteResult(path=self._to_virtual(destination, self._root), files_update=None) + return WriteResult( + error=f"Error: failed to move '{source_path}': {exc}" + ) + return WriteResult( + path=self._to_virtual(destination, self._root), files_update=None + ) async def amove( self, @@ -368,12 +376,16 @@ class LocalFolderBackend: if not path.exists() or not path.is_file(): return EditResult(error=f"Error: File '{file_path}' not found") content = path.read_text(encoding="utf-8", errors="replace") - result = perform_string_replacement(content, old_string, new_string, replace_all) + result = perform_string_replacement( + content, old_string, new_string, replace_all + ) if isinstance(result, str): return EditResult(error=result) updated_content, occurrences = result self._write_text_atomic(path, updated_content) - return EditResult(path=file_path, files_update=None, occurrences=int(occurrences)) + return EditResult( + path=file_path, files_update=None, occurrences=int(occurrences) + ) async def aedit( self, @@ -447,7 +459,9 @@ class LocalFolderBackend: matches: list[GrepMatch] = [] for file_path in self._iter_candidate_files(path, glob): try: - lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines() + lines = file_path.read_text( + encoding="utf-8", errors="replace" + ).splitlines() except Exception: continue for idx, line in enumerate(lines, start=1): @@ -481,12 +495,18 @@ class LocalFolderBackend: FileUploadResponse(path=virtual_path, error=_FILE_NOT_FOUND) ) except IsADirectoryError: - responses.append(FileUploadResponse(path=virtual_path, error=_IS_DIRECTORY)) + responses.append( + FileUploadResponse(path=virtual_path, error=_IS_DIRECTORY) + ) except Exception: - responses.append(FileUploadResponse(path=virtual_path, error=_INVALID_PATH)) + responses.append( + FileUploadResponse(path=virtual_path, error=_INVALID_PATH) + ) return responses - async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + async def aupload_files( + self, files: list[tuple[str, bytes]] + ) -> list[FileUploadResponse]: return await asyncio.to_thread(self.upload_files, files) def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: @@ -515,7 +535,9 @@ class LocalFolderBackend: ) except Exception: responses.append( - FileDownloadResponse(path=virtual_path, content=None, error=_INVALID_PATH) + FileDownloadResponse( + path=virtual_path, content=None, error=_INVALID_PATH + ) ) return responses diff --git a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py index 82914f9ce..93eabe6ff 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py @@ -127,7 +127,9 @@ class MultiRootLocalFolderBackend: mount, local_path = self._split_mount_path(path) except ValueError: return [] - return self._transform_infos(mount, self._mount_to_backend[mount].ls_info(local_path)) + return self._transform_infos( + mount, self._mount_to_backend[mount].ls_info(local_path) + ) async def als_info(self, path: str) -> list[FileInfo]: return await asyncio.to_thread(self.ls_info, path) @@ -355,7 +357,9 @@ class MultiRootLocalFolderBackend: all_matches.extend( [ GrepMatch( - path=self._prefix_mount_path(mount, self._get_str(match, "path")), + path=self._prefix_mount_path( + mount, self._get_str(match, "path") + ), line=self._get_int(match, "line"), text=self._get_str(match, "text"), ) @@ -394,7 +398,9 @@ class MultiRootLocalFolderBackend: try: mount, local_path = self._split_mount_path(virtual_path) except ValueError: - invalid.append(FileUploadResponse(path=virtual_path, error=_INVALID_PATH)) + invalid.append( + FileUploadResponse(path=virtual_path, error=_INVALID_PATH) + ) continue grouped.setdefault(mount, []).append((local_path, content)) @@ -404,7 +410,9 @@ class MultiRootLocalFolderBackend: responses.extend( [ FileUploadResponse( - path=self._prefix_mount_path(mount, self._get_str(item, "path")), + path=self._prefix_mount_path( + mount, self._get_str(item, "path") + ), error=self._get_str(item, "error") or None, ) for item in result @@ -412,7 +420,9 @@ class MultiRootLocalFolderBackend: ) return responses - async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + async def aupload_files( + self, files: list[tuple[str, bytes]] + ) -> list[FileUploadResponse]: return await asyncio.to_thread(self.upload_files, files) def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: @@ -423,7 +433,9 @@ class MultiRootLocalFolderBackend: mount, local_path = self._split_mount_path(virtual_path) except ValueError: invalid.append( - FileDownloadResponse(path=virtual_path, content=None, error=_INVALID_PATH) + FileDownloadResponse( + path=virtual_path, content=None, error=_INVALID_PATH + ) ) continue grouped.setdefault(mount, []).append(local_path) @@ -434,7 +446,9 @@ class MultiRootLocalFolderBackend: responses.extend( [ FileDownloadResponse( - path=self._prefix_mount_path(mount, self._get_str(item, "path")), + path=self._prefix_mount_path( + mount, self._get_str(item, "path") + ), content=self._get_value(item, "content"), error=self._get_str(item, "error") or None, ) diff --git a/surfsense_backend/app/agents/new_chat/tools/connected_accounts.py b/surfsense_backend/app/agents/new_chat/tools/connected_accounts.py index e0b1978e1..5675a42e6 100644 --- a/surfsense_backend/app/agents/new_chat/tools/connected_accounts.py +++ b/surfsense_backend/app/agents/new_chat/tools/connected_accounts.py @@ -57,7 +57,11 @@ def create_get_connected_accounts_tool( async def _run(service: str) -> list[dict[str, Any]]: svc_cfg = MCP_SERVICES.get(service) if not svc_cfg: - return [{"error": f"Unknown service '{service}'. Valid: {', '.join(sorted(MCP_SERVICES.keys()))}"}] + return [ + { + "error": f"Unknown service '{service}'. Valid: {', '.join(sorted(MCP_SERVICES.keys()))}" + } + ] try: connector_type = SearchSourceConnectorType(svc_cfg.connector_type) @@ -74,7 +78,11 @@ def create_get_connected_accounts_tool( connectors = result.scalars().all() if not connectors: - return [{"error": f"No {svc_cfg.name} accounts connected. Ask the user to connect one in settings."}] + return [ + { + "error": f"No {svc_cfg.name} accounts connected. Ask the user to connect one in settings." + } + ] is_multi = len(connectors) > 1 diff --git a/surfsense_backend/app/agents/new_chat/tools/discord/_auth.py b/surfsense_backend/app/agents/new_chat/tools/discord/_auth.py index 1f51e3660..c345f8a5e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/discord/_auth.py +++ b/surfsense_backend/app/agents/new_chat/tools/discord/_auth.py @@ -19,7 +19,8 @@ async def get_discord_connector( select(SearchSourceConnector).filter( SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.DISCORD_CONNECTOR, ) ) return result.scalars().first() diff --git a/surfsense_backend/app/agents/new_chat/tools/discord/list_channels.py b/surfsense_backend/app/agents/new_chat/tools/discord/list_channels.py index a33b88aa0..3cc99ac17 100644 --- a/surfsense_backend/app/agents/new_chat/tools/discord/list_channels.py +++ b/surfsense_backend/app/agents/new_chat/tools/discord/list_channels.py @@ -23,16 +23,24 @@ def create_list_discord_channels_tool( Dictionary with status and a list of channels (id, name). """ if db_session is None or search_space_id is None or user_id is None: - return {"status": "error", "message": "Discord tool not properly configured."} + return { + "status": "error", + "message": "Discord tool not properly configured.", + } try: - connector = await get_discord_connector(db_session, search_space_id, user_id) + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) if not connector: return {"status": "error", "message": "No Discord connector found."} guild_id = get_guild_id(connector) if not guild_id: - return {"status": "error", "message": "No guild ID in Discord connector config."} + return { + "status": "error", + "message": "No guild ID in Discord connector config.", + } token = get_bot_token(connector) @@ -44,9 +52,16 @@ def create_list_discord_channels_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Discord bot token is invalid.", "connector_type": "discord"} + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } if resp.status_code != 200: - return {"status": "error", "message": f"Discord API error: {resp.status_code}"} + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } # Type 0 = text channel channels = [ @@ -54,7 +69,12 @@ def create_list_discord_channels_tool( for ch in resp.json() if ch.get("type") == 0 ] - return {"status": "success", "guild_id": guild_id, "channels": channels, "total": len(channels)} + return { + "status": "success", + "guild_id": guild_id, + "channels": channels, + "total": len(channels), + } except Exception as e: from langgraph.errors import GraphInterrupt diff --git a/surfsense_backend/app/agents/new_chat/tools/discord/read_messages.py b/surfsense_backend/app/agents/new_chat/tools/discord/read_messages.py index 852a9297b..d8bf989a1 100644 --- a/surfsense_backend/app/agents/new_chat/tools/discord/read_messages.py +++ b/surfsense_backend/app/agents/new_chat/tools/discord/read_messages.py @@ -31,12 +31,17 @@ def create_read_discord_messages_tool( id, author, content, timestamp. """ if db_session is None or search_space_id is None or user_id is None: - return {"status": "error", "message": "Discord tool not properly configured."} + return { + "status": "error", + "message": "Discord tool not properly configured.", + } limit = min(limit, 50) try: - connector = await get_discord_connector(db_session, search_space_id, user_id) + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) if not connector: return {"status": "error", "message": "No Discord connector found."} @@ -51,11 +56,21 @@ def create_read_discord_messages_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Discord bot token is invalid.", "connector_type": "discord"} + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } if resp.status_code == 403: - return {"status": "error", "message": "Bot lacks permission to read this channel."} + return { + "status": "error", + "message": "Bot lacks permission to read this channel.", + } if resp.status_code != 200: - return {"status": "error", "message": f"Discord API error: {resp.status_code}"} + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } messages = [ { @@ -67,7 +82,12 @@ def create_read_discord_messages_tool( for m in resp.json() ] - return {"status": "success", "channel_id": channel_id, "messages": messages, "total": len(messages)} + return { + "status": "success", + "channel_id": channel_id, + "messages": messages, + "total": len(messages), + } except Exception as e: from langgraph.errors import GraphInterrupt diff --git a/surfsense_backend/app/agents/new_chat/tools/discord/send_message.py b/surfsense_backend/app/agents/new_chat/tools/discord/send_message.py index be4e6fdb2..236cd017a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/discord/send_message.py +++ b/surfsense_backend/app/agents/new_chat/tools/discord/send_message.py @@ -35,13 +35,21 @@ def create_send_discord_message_tool( - If status is "rejected", the user explicitly declined. Do NOT retry. """ if db_session is None or search_space_id is None or user_id is None: - return {"status": "error", "message": "Discord tool not properly configured."} + return { + "status": "error", + "message": "Discord tool not properly configured.", + } if len(content) > 2000: - return {"status": "error", "message": "Message exceeds Discord's 2000-character limit."} + return { + "status": "error", + "message": "Message exceeds Discord's 2000-character limit.", + } try: - connector = await get_discord_connector(db_session, search_space_id, user_id) + connector = await get_discord_connector( + db_session, search_space_id, user_id + ) if not connector: return {"status": "error", "message": "No Discord connector found."} @@ -53,7 +61,10 @@ def create_send_discord_message_tool( ) if result.rejected: - return {"status": "rejected", "message": "User declined. Message was not sent."} + return { + "status": "rejected", + "message": "User declined. Message was not sent.", + } final_content = result.params.get("content", content) final_channel = result.params.get("channel_id", channel_id) @@ -72,11 +83,21 @@ def create_send_discord_message_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Discord bot token is invalid.", "connector_type": "discord"} + return { + "status": "auth_error", + "message": "Discord bot token is invalid.", + "connector_type": "discord", + } if resp.status_code == 403: - return {"status": "error", "message": "Bot lacks permission to send messages in this channel."} + return { + "status": "error", + "message": "Bot lacks permission to send messages in this channel.", + } if resp.status_code not in (200, 201): - return {"status": "error", "message": f"Discord API error: {resp.status_code}"} + return { + "status": "error", + "message": f"Discord API error: {resp.status_code}", + } msg_data = resp.json() return { diff --git a/surfsense_backend/app/agents/new_chat/tools/gmail/read_email.py b/surfsense_backend/app/agents/new_chat/tools/gmail/read_email.py index 9071f129a..deec1627c 100644 --- a/surfsense_backend/app/agents/new_chat/tools/gmail/read_email.py +++ b/surfsense_backend/app/agents/new_chat/tools/gmail/read_email.py @@ -65,12 +65,22 @@ def create_read_gmail_email_tool( detail, error = await gmail.get_message_details(message_id) if error: - if "re-authenticate" in error.lower() or "authentication failed" in error.lower(): - return {"status": "auth_error", "message": error, "connector_type": "gmail"} + if ( + "re-authenticate" in error.lower() + or "authentication failed" in error.lower() + ): + return { + "status": "auth_error", + "message": error, + "connector_type": "gmail", + } return {"status": "error", "message": error} if not detail: - return {"status": "not_found", "message": f"Email with ID '{message_id}' not found."} + return { + "status": "not_found", + "message": f"Email with ID '{message_id}' not found.", + } content = gmail.format_message_to_markdown(detail) @@ -82,6 +92,9 @@ def create_read_gmail_email_tool( if isinstance(e, GraphInterrupt): raise logger.error("Error reading Gmail email: %s", e, exc_info=True) - return {"status": "error", "message": "Failed to read email. Please try again."} + return { + "status": "error", + "message": "Failed to read email. Please try again.", + } return read_gmail_email diff --git a/surfsense_backend/app/agents/new_chat/tools/gmail/search_emails.py b/surfsense_backend/app/agents/new_chat/tools/gmail/search_emails.py index de43f03d0..2e363609e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/gmail/search_emails.py +++ b/surfsense_backend/app/agents/new_chat/tools/gmail/search_emails.py @@ -125,12 +125,24 @@ def create_search_gmail_tool( max_results=max_results, query=query ) if error: - if "re-authenticate" in error.lower() or "authentication failed" in error.lower(): - return {"status": "auth_error", "message": error, "connector_type": "gmail"} + if ( + "re-authenticate" in error.lower() + or "authentication failed" in error.lower() + ): + return { + "status": "auth_error", + "message": error, + "connector_type": "gmail", + } return {"status": "error", "message": error} if not messages_list: - return {"status": "success", "emails": [], "total": 0, "message": "No emails found."} + return { + "status": "success", + "emails": [], + "total": 0, + "message": "No emails found.", + } emails = [] for msg in messages_list: @@ -141,16 +153,18 @@ def create_search_gmail_tool( h["name"].lower(): h["value"] for h in detail.get("payload", {}).get("headers", []) } - emails.append({ - "message_id": detail.get("id"), - "thread_id": detail.get("threadId"), - "subject": headers.get("subject", "No Subject"), - "from": headers.get("from", "Unknown"), - "to": headers.get("to", ""), - "date": headers.get("date", ""), - "snippet": detail.get("snippet", ""), - "labels": detail.get("labelIds", []), - }) + emails.append( + { + "message_id": detail.get("id"), + "thread_id": detail.get("threadId"), + "subject": headers.get("subject", "No Subject"), + "from": headers.get("from", "Unknown"), + "to": headers.get("to", ""), + "date": headers.get("date", ""), + "snippet": detail.get("snippet", ""), + "labels": detail.get("labelIds", []), + } + ) return {"status": "success", "emails": emails, "total": len(emails)} @@ -160,6 +174,9 @@ def create_search_gmail_tool( if isinstance(e, GraphInterrupt): raise logger.error("Error searching Gmail: %s", e, exc_info=True) - return {"status": "error", "message": "Failed to search Gmail. Please try again."} + return { + "status": "error", + "message": "Failed to search Gmail. Please try again.", + } return search_gmail diff --git a/surfsense_backend/app/agents/new_chat/tools/google_calendar/search_events.py b/surfsense_backend/app/agents/new_chat/tools/google_calendar/search_events.py index a622b0efa..dc6adb822 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_calendar/search_events.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_calendar/search_events.py @@ -39,7 +39,10 @@ def create_search_calendar_events_tool( event_id, summary, start, end, location, attendees. """ if db_session is None or search_space_id is None or user_id is None: - return {"status": "error", "message": "Calendar tool not properly configured."} + return { + "status": "error", + "message": "Calendar tool not properly configured.", + } max_results = min(max_results, 50) @@ -76,10 +79,22 @@ def create_search_calendar_events_tool( ) if error: - if "re-authenticate" in error.lower() or "authentication failed" in error.lower(): - return {"status": "auth_error", "message": error, "connector_type": "google_calendar"} + if ( + "re-authenticate" in error.lower() + or "authentication failed" in error.lower() + ): + return { + "status": "auth_error", + "message": error, + "connector_type": "google_calendar", + } if "no events found" in error.lower(): - return {"status": "success", "events": [], "total": 0, "message": error} + return { + "status": "success", + "events": [], + "total": 0, + "message": error, + } return {"status": "error", "message": error} events = [] @@ -87,19 +102,19 @@ def create_search_calendar_events_tool( start = ev.get("start", {}) end = ev.get("end", {}) attendees_raw = ev.get("attendees", []) - events.append({ - "event_id": ev.get("id"), - "summary": ev.get("summary", "No Title"), - "start": start.get("dateTime") or start.get("date", ""), - "end": end.get("dateTime") or end.get("date", ""), - "location": ev.get("location", ""), - "description": ev.get("description", ""), - "html_link": ev.get("htmlLink", ""), - "attendees": [ - a.get("email", "") for a in attendees_raw[:10] - ], - "status": ev.get("status", ""), - }) + events.append( + { + "event_id": ev.get("id"), + "summary": ev.get("summary", "No Title"), + "start": start.get("dateTime") or start.get("date", ""), + "end": end.get("dateTime") or end.get("date", ""), + "location": ev.get("location", ""), + "description": ev.get("description", ""), + "html_link": ev.get("htmlLink", ""), + "attendees": [a.get("email", "") for a in attendees_raw[:10]], + "status": ev.get("status", ""), + } + ) return {"status": "success", "events": events, "total": len(events)} @@ -109,6 +124,9 @@ def create_search_calendar_events_tool( if isinstance(e, GraphInterrupt): raise logger.error("Error searching calendar events: %s", e, exc_info=True) - return {"status": "error", "message": "Failed to search calendar events. Please try again."} + return { + "status": "error", + "message": "Failed to search calendar events. Please try again.", + } return search_calendar_events diff --git a/surfsense_backend/app/agents/new_chat/tools/hitl.py b/surfsense_backend/app/agents/new_chat/tools/hitl.py index 89f02abf6..8480e57b1 100644 --- a/surfsense_backend/app/agents/new_chat/tools/hitl.py +++ b/surfsense_backend/app/agents/new_chat/tools/hitl.py @@ -130,7 +130,9 @@ def request_approval( try: decision_type, edited_params = _parse_decision(approval) except ValueError: - logger.warning("No approval decision received for %s — rejecting for safety", tool_name) + logger.warning( + "No approval decision received for %s — rejecting for safety", tool_name + ) return HITLResult(rejected=True, decision_type="error", params=params) logger.info("User decision for %s: %s", tool_name, decision_type) diff --git a/surfsense_backend/app/agents/new_chat/tools/luma/_auth.py b/surfsense_backend/app/agents/new_chat/tools/luma/_auth.py index 1d88161d6..37deb1525 100644 --- a/surfsense_backend/app/agents/new_chat/tools/luma/_auth.py +++ b/surfsense_backend/app/agents/new_chat/tools/luma/_auth.py @@ -17,7 +17,8 @@ async def get_luma_connector( select(SearchSourceConnector).filter( SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type == SearchSourceConnectorType.LUMA_CONNECTOR, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LUMA_CONNECTOR, ) ) return result.scalars().first() diff --git a/surfsense_backend/app/agents/new_chat/tools/luma/create_event.py b/surfsense_backend/app/agents/new_chat/tools/luma/create_event.py index 2217d29e6..0a24a988f 100644 --- a/surfsense_backend/app/agents/new_chat/tools/luma/create_event.py +++ b/surfsense_backend/app/agents/new_chat/tools/luma/create_event.py @@ -62,7 +62,10 @@ def create_create_luma_event_tool( ) if result.rejected: - return {"status": "rejected", "message": "User declined. Event was not created."} + return { + "status": "rejected", + "message": "User declined. Event was not created.", + } final_name = result.params.get("name", name) final_start = result.params.get("start_at", start_at) @@ -90,11 +93,21 @@ def create_create_luma_event_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Luma API key is invalid.", "connector_type": "luma"} + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } if resp.status_code == 403: - return {"status": "error", "message": "Luma Plus subscription required to create events via API."} + return { + "status": "error", + "message": "Luma Plus subscription required to create events via API.", + } if resp.status_code not in (200, 201): - return {"status": "error", "message": f"Luma API error: {resp.status_code} — {resp.text[:200]}"} + return { + "status": "error", + "message": f"Luma API error: {resp.status_code} — {resp.text[:200]}", + } data = resp.json() event_id = data.get("api_id") or data.get("event", {}).get("api_id") diff --git a/surfsense_backend/app/agents/new_chat/tools/luma/list_events.py b/surfsense_backend/app/agents/new_chat/tools/luma/list_events.py index cd4721758..aec5ad220 100644 --- a/surfsense_backend/app/agents/new_chat/tools/luma/list_events.py +++ b/surfsense_backend/app/agents/new_chat/tools/luma/list_events.py @@ -46,7 +46,9 @@ def create_list_luma_events_tool( async with httpx.AsyncClient(timeout=20.0) as client: while len(all_entries) < max_results: - params: dict[str, Any] = {"limit": min(100, max_results - len(all_entries))} + params: dict[str, Any] = { + "limit": min(100, max_results - len(all_entries)) + } if cursor: params["cursor"] = cursor @@ -57,9 +59,16 @@ def create_list_luma_events_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Luma API key is invalid.", "connector_type": "luma"} + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } if resp.status_code != 200: - return {"status": "error", "message": f"Luma API error: {resp.status_code}"} + return { + "status": "error", + "message": f"Luma API error: {resp.status_code}", + } data = resp.json() entries = data.get("entries", []) @@ -76,16 +85,18 @@ def create_list_luma_events_tool( for entry in all_entries[:max_results]: ev = entry.get("event", {}) geo = ev.get("geo_info", {}) - events.append({ - "event_id": entry.get("api_id"), - "name": ev.get("name", "Untitled"), - "start_at": ev.get("start_at", ""), - "end_at": ev.get("end_at", ""), - "timezone": ev.get("timezone", ""), - "location": geo.get("name", ""), - "url": ev.get("url", ""), - "visibility": ev.get("visibility", ""), - }) + events.append( + { + "event_id": entry.get("api_id"), + "name": ev.get("name", "Untitled"), + "start_at": ev.get("start_at", ""), + "end_at": ev.get("end_at", ""), + "timezone": ev.get("timezone", ""), + "location": geo.get("name", ""), + "url": ev.get("url", ""), + "visibility": ev.get("visibility", ""), + } + ) return {"status": "success", "events": events, "total": len(events)} diff --git a/surfsense_backend/app/agents/new_chat/tools/luma/read_event.py b/surfsense_backend/app/agents/new_chat/tools/luma/read_event.py index eb3ac55c6..b37a9d617 100644 --- a/surfsense_backend/app/agents/new_chat/tools/luma/read_event.py +++ b/surfsense_backend/app/agents/new_chat/tools/luma/read_event.py @@ -44,11 +44,21 @@ def create_read_luma_event_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Luma API key is invalid.", "connector_type": "luma"} + return { + "status": "auth_error", + "message": "Luma API key is invalid.", + "connector_type": "luma", + } if resp.status_code == 404: - return {"status": "not_found", "message": f"Event '{event_id}' not found."} + return { + "status": "not_found", + "message": f"Event '{event_id}' not found.", + } if resp.status_code != 200: - return {"status": "error", "message": f"Luma API error: {resp.status_code}"} + return { + "status": "error", + "message": f"Luma API error: {resp.status_code}", + } data = resp.json() ev = data.get("event", data) diff --git a/surfsense_backend/app/agents/new_chat/tools/mcp_client.py b/surfsense_backend/app/agents/new_chat/tools/mcp_client.py index b46ddbcc5..e28ac8bda 100644 --- a/surfsense_backend/app/agents/new_chat/tools/mcp_client.py +++ b/surfsense_backend/app/agents/new_chat/tools/mcp_client.py @@ -220,10 +220,8 @@ class MCPClient: logger.info("MCP tool '%s' succeeded: %s", tool_name, result_str[:200]) return result_str - except asyncio.TimeoutError: - logger.error( - "MCP tool '%s' timed out after %.0fs", tool_name, timeout - ) + except TimeoutError: + logger.error("MCP tool '%s' timed out after %.0fs", tool_name, timeout) return f"Error: MCP tool '{tool_name}' timed out after {timeout:.0f}s" except RuntimeError as e: if "Invalid structured content" in str(e): diff --git a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py index dfee24516..5b96ab374 100644 --- a/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py +++ b/surfsense_backend/app/agents/new_chat/tools/mcp_tool.py @@ -35,7 +35,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.agents.new_chat.tools.hitl import request_approval from app.agents.new_chat.tools.mcp_client import MCPClient -from app.db import SearchSourceConnector, SearchSourceConnectorType +from app.db import SearchSourceConnector from app.services.mcp_oauth.registry import MCP_SERVICES, get_service_by_connector_type logger = logging.getLogger(__name__) @@ -105,13 +105,15 @@ def _create_dynamic_input_model_from_schema( description=( "Arguments to pass to this tool as a JSON object. " "Infer sensible key names from the tool name and description " - "(e.g. {\"search\": \"my query\"} for a search tool)." + '(e.g. {"search": "my query"} for a search tool).' ), ), ) model_name = f"{tool_name.replace(' ', '').replace('-', '_')}Input" - model = create_model(model_name, __config__=ConfigDict(extra="allow"), **field_definitions) + model = create_model( + model_name, __config__=ConfigDict(extra="allow"), **field_definitions + ) return model @@ -187,16 +189,23 @@ async def _create_mcp_tool_from_definition_stdio( except Exception as e: last_error = e if attempt < _TOOL_CALL_MAX_RETRIES - 1: - delay = _TOOL_CALL_RETRY_DELAY * (2 ** attempt) + delay = _TOOL_CALL_RETRY_DELAY * (2**attempt) logger.warning( "MCP tool '%s' failed (attempt %d/%d): %s. Retrying in %.1fs...", - tool_name, attempt + 1, _TOOL_CALL_MAX_RETRIES, e, delay, + tool_name, + attempt + 1, + _TOOL_CALL_MAX_RETRIES, + e, + delay, ) await asyncio.sleep(delay) else: logger.error( "MCP tool '%s' failed after %d attempts: %s", - tool_name, _TOOL_CALL_MAX_RETRIES, e, exc_info=True, + tool_name, + _TOOL_CALL_MAX_RETRIES, + e, + exc_info=True, ) return f"Error: MCP tool '{tool_name}' failed after {_TOOL_CALL_MAX_RETRIES} attempts: {last_error!s}" @@ -318,17 +327,22 @@ async def _create_mcp_tool_from_definition_http( try: result_str = await _do_mcp_call(headers, call_kwargs) - logger.debug("MCP HTTP tool '%s' succeeded (len=%d)", exposed_name, len(result_str)) + logger.debug( + "MCP HTTP tool '%s' succeeded (len=%d)", exposed_name, len(result_str) + ) return result_str except Exception as first_err: if not _is_auth_error(first_err) or connector_id is None: - logger.exception("MCP HTTP tool '%s' execution failed: %s", exposed_name, first_err) + logger.exception( + "MCP HTTP tool '%s' execution failed: %s", exposed_name, first_err + ) return f"Error: MCP HTTP tool '{exposed_name}' execution failed: {first_err!s}" logger.warning( "MCP HTTP tool '%s' got 401 — attempting token refresh for connector %s", - exposed_name, connector_id, + exposed_name, + connector_id, ) fresh_headers = await _force_refresh_and_get_headers(connector_id) if fresh_headers is None: @@ -348,7 +362,8 @@ async def _create_mcp_tool_from_definition_http( except Exception as retry_err: logger.exception( "MCP HTTP tool '%s' still failing after token refresh: %s", - exposed_name, retry_err, + exposed_name, + retry_err, ) if _is_auth_error(retry_err): await _mark_connector_auth_expired(connector_id) @@ -393,7 +408,8 @@ async def _load_stdio_mcp_tools( if not command or not isinstance(command, str): logger.warning( "MCP connector %d (name: '%s') missing or invalid command field, skipping", - connector_id, connector_name, + connector_id, + connector_name, ) return tools @@ -401,7 +417,8 @@ async def _load_stdio_mcp_tools( if not isinstance(args, list): logger.warning( "MCP connector %d (name: '%s') has invalid args field (must be list), skipping", - connector_id, connector_name, + connector_id, + connector_name, ) return tools @@ -409,7 +426,8 @@ async def _load_stdio_mcp_tools( if not isinstance(env, dict): logger.warning( "MCP connector %d (name: '%s') has invalid env field (must be dict), skipping", - connector_id, connector_name, + connector_id, + connector_name, ) return tools @@ -420,7 +438,9 @@ async def _load_stdio_mcp_tools( logger.info( "Discovered %d tools from stdio MCP server '%s' (connector %d)", - len(tool_definitions), command, connector_id, + len(tool_definitions), + command, + connector_id, ) for tool_def in tool_definitions: @@ -436,7 +456,9 @@ async def _load_stdio_mcp_tools( except Exception as e: logger.exception( "Failed to create tool '%s' from connector %d: %s", - tool_def.get("name"), connector_id, e, + tool_def.get("name"), + connector_id, + e, ) return tools @@ -468,7 +490,8 @@ async def _load_http_mcp_tools( if not url or not isinstance(url, str): logger.warning( "MCP connector %d (name: '%s') missing or invalid url field, skipping", - connector_id, connector_name, + connector_id, + connector_name, ) return tools @@ -476,7 +499,8 @@ async def _load_http_mcp_tools( if not isinstance(headers, dict): logger.warning( "MCP connector %d (name: '%s') has invalid headers field (must be dict), skipping", - connector_id, connector_name, + connector_id, + connector_name, ) return tools @@ -507,7 +531,9 @@ async def _load_http_mcp_tools( if not _is_auth_error(first_err) or connector_id is None: logger.exception( "Failed to connect to HTTP MCP server at '%s' (connector %d): %s", - url, connector_id, first_err, + url, + connector_id, + first_err, ) return tools @@ -534,7 +560,8 @@ async def _load_http_mcp_tools( except Exception as retry_err: logger.exception( "HTTP MCP discovery for connector %d still failing after refresh: %s", - connector_id, retry_err, + connector_id, + retry_err, ) if _is_auth_error(retry_err): await _mark_connector_auth_expired(connector_id) @@ -543,17 +570,20 @@ async def _load_http_mcp_tools( total_discovered = len(tool_definitions) if allowed_set: - tool_definitions = [ - td for td in tool_definitions if td["name"] in allowed_set - ] + tool_definitions = [td for td in tool_definitions if td["name"] in allowed_set] logger.info( "HTTP MCP server '%s' (connector %d): %d/%d tools after allowlist filter", - url, connector_id, len(tool_definitions), total_discovered, + url, + connector_id, + len(tool_definitions), + total_discovered, ) else: logger.info( "Discovered %d tools from HTTP MCP server '%s' (connector %d) — no allowlist, loading all", - total_discovered, url, connector_id, + total_discovered, + url, + connector_id, ) for tool_def in tool_definitions: @@ -573,7 +603,9 @@ async def _load_http_mcp_tools( except Exception as e: logger.exception( "Failed to create HTTP tool '%s' from connector %d: %s", - tool_def.get("name"), connector_id, e, + tool_def.get("name"), + connector_id, + e, ) return tools @@ -628,7 +660,7 @@ def _inject_oauth_headers( async def _refresh_connector_token( session: AsyncSession, - connector: "SearchSourceConnector", + connector: SearchSourceConnector, ) -> str | None: """Refresh the OAuth token for an MCP connector and persist the result. @@ -692,12 +724,8 @@ async def _refresh_connector_token( updated_oauth = dict(mcp_oauth) updated_oauth["access_token"] = enc.encrypt_token(new_access) if token_json.get("refresh_token"): - updated_oauth["refresh_token"] = enc.encrypt_token( - token_json["refresh_token"] - ) - updated_oauth["expires_at"] = ( - new_expires_at.isoformat() if new_expires_at else None - ) + updated_oauth["refresh_token"] = enc.encrypt_token(token_json["refresh_token"]) + updated_oauth["expires_at"] = new_expires_at.isoformat() if new_expires_at else None updated_cfg = {**cfg, "mcp_oauth": updated_oauth} updated_cfg.pop("auth_expired", None) @@ -713,7 +741,7 @@ async def _refresh_connector_token( async def _maybe_refresh_mcp_oauth_token( session: AsyncSession, - connector: "SearchSourceConnector", + connector: SearchSourceConnector, cfg: dict[str, Any], server_config: dict[str, Any], ) -> dict[str, Any]: @@ -731,10 +759,11 @@ async def _maybe_refresh_mcp_oauth_token( try: expires_at = datetime.fromisoformat(expires_at_str) if expires_at.tzinfo is None: - from datetime import timezone - expires_at = expires_at.replace(tzinfo=timezone.utc) + expires_at = expires_at.replace(tzinfo=UTC) - if datetime.now(UTC) < expires_at - timedelta(seconds=_TOKEN_REFRESH_BUFFER_SECONDS): + if datetime.now(UTC) < expires_at - timedelta( + seconds=_TOKEN_REFRESH_BUFFER_SECONDS + ): return server_config except (ValueError, TypeError): return server_config @@ -744,7 +773,9 @@ async def _maybe_refresh_mcp_oauth_token( if not new_access: return server_config - logger.info("Proactively refreshed MCP OAuth token for connector %s", connector.id) + logger.info( + "Proactively refreshed MCP OAuth token for connector %s", connector.id + ) refreshed_config = dict(server_config) refreshed_config["headers"] = { @@ -920,7 +951,7 @@ async def load_mcp_tools( result = await session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.search_space_id == search_space_id, - cast(SearchSourceConnector.config, JSONB).has_key("server_config"), # noqa: W601 + cast(SearchSourceConnector.config, JSONB).has_key("server_config"), ), ) @@ -956,13 +987,17 @@ async def load_mcp_tools( if not server_config or not isinstance(server_config, dict): logger.warning( "MCP connector %d (name: '%s') has invalid or missing server_config, skipping", - connector.id, connector.name, + connector.id, + connector.name, ) continue if cfg.get("mcp_oauth"): server_config = await _maybe_refresh_mcp_oauth_token( - session, connector, cfg, server_config, + session, + connector, + cfg, + server_config, ) cfg = connector.config or {} server_config = _inject_oauth_headers(cfg, server_config) @@ -995,22 +1030,25 @@ async def load_mcp_tools( if service_key: tool_name_prefix = f"{service_key}_{connector.id}" - discovery_tasks.append({ - "connector_id": connector.id, - "connector_name": connector.name, - "server_config": server_config, - "trusted_tools": trusted_tools, - "allowed_tools": allowed_tools, - "readonly_tools": readonly_tools, - "tool_name_prefix": tool_name_prefix, - "transport": server_config.get("transport", "stdio"), - "is_generic_mcp": svc_cfg is None, - }) + discovery_tasks.append( + { + "connector_id": connector.id, + "connector_name": connector.name, + "server_config": server_config, + "trusted_tools": trusted_tools, + "allowed_tools": allowed_tools, + "readonly_tools": readonly_tools, + "tool_name_prefix": tool_name_prefix, + "transport": server_config.get("transport", "stdio"), + "is_generic_mcp": svc_cfg is None, + } + ) except Exception as e: logger.exception( "Failed to prepare MCP connector %d: %s", - connector.id, e, + connector.id, + e, ) async def _discover_one(task: dict[str, Any]) -> list[StructuredTool]: @@ -1039,23 +1077,23 @@ async def load_mcp_tools( ), timeout=_MCP_DISCOVERY_TIMEOUT_SECONDS, ) - except asyncio.TimeoutError: + except TimeoutError: logger.error( "MCP connector %d timed out after %ds during discovery", - task["connector_id"], _MCP_DISCOVERY_TIMEOUT_SECONDS, + task["connector_id"], + _MCP_DISCOVERY_TIMEOUT_SECONDS, ) return [] except Exception as e: logger.exception( "Failed to load tools from MCP connector %d: %s", - task["connector_id"], e, + task["connector_id"], + e, ) return [] results = await asyncio.gather(*[_discover_one(t) for t in discovery_tasks]) - tools: list[StructuredTool] = [ - tool for sublist in results for tool in sublist - ] + tools: list[StructuredTool] = [tool for sublist in results for tool in sublist] _mcp_tools_cache[search_space_id] = (now, tools) @@ -1063,7 +1101,9 @@ async def load_mcp_tools( oldest_key = min(_mcp_tools_cache, key=lambda k: _mcp_tools_cache[k][0]) del _mcp_tools_cache[oldest_key] - logger.info("Loaded %d MCP tools for search space %d", len(tools), search_space_id) + logger.info( + "Loaded %d MCP tools for search space %d", len(tools), search_space_id + ) return tools except Exception as e: diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 85c89b114..3ac8677b9 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -50,6 +50,7 @@ from .confluence import ( create_delete_confluence_page_tool, create_update_confluence_page_tool, ) +from .connected_accounts import create_get_connected_accounts_tool from .discord import ( create_list_discord_channels_tool, create_read_discord_messages_tool, @@ -78,7 +79,6 @@ from .google_drive import ( create_create_google_drive_file_tool, create_delete_google_drive_file_tool, ) -from .connected_accounts import create_get_connected_accounts_tool from .luma import ( create_create_luma_event_tool, create_list_luma_events_tool, @@ -675,10 +675,7 @@ def get_connector_gated_tools( available_connectors: list[str] | None, ) -> list[str]: """Return tool names to disable""" - if available_connectors is None: - available = set() - else: - available = set(available_connectors) + available = set() if available_connectors is None else set(available_connectors) disabled: list[str] = [] for tool_def in BUILTIN_TOOLS: @@ -829,14 +826,16 @@ async def build_tools_async( tools.extend(mcp_tools) logging.info( "Registered %d MCP tools: %s", - len(mcp_tools), [t.name for t in mcp_tools], + len(mcp_tools), + [t.name for t in mcp_tools], ) except Exception as e: logging.exception("Failed to load MCP tools: %s", e) logging.info( "Total tools for agent: %d — %s", - len(tools), [t.name for t in tools], + len(tools), + [t.name for t in tools], ) return tools diff --git a/surfsense_backend/app/agents/new_chat/tools/teams/_auth.py b/surfsense_backend/app/agents/new_chat/tools/teams/_auth.py index f24f5502e..4345bb476 100644 --- a/surfsense_backend/app/agents/new_chat/tools/teams/_auth.py +++ b/surfsense_backend/app/agents/new_chat/tools/teams/_auth.py @@ -17,7 +17,8 @@ async def get_teams_connector( select(SearchSourceConnector).filter( SearchSourceConnector.search_space_id == search_space_id, SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.TEAMS_CONNECTOR, ) ) return result.scalars().first() diff --git a/surfsense_backend/app/agents/new_chat/tools/teams/list_channels.py b/surfsense_backend/app/agents/new_chat/tools/teams/list_channels.py index a676595c1..d7b000853 100644 --- a/surfsense_backend/app/agents/new_chat/tools/teams/list_channels.py +++ b/surfsense_backend/app/agents/new_chat/tools/teams/list_channels.py @@ -35,12 +35,21 @@ def create_list_teams_channels_tool( headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=20.0) as client: - teams_resp = await client.get(f"{GRAPH_API}/me/joinedTeams", headers=headers) + teams_resp = await client.get( + f"{GRAPH_API}/me/joinedTeams", headers=headers + ) if teams_resp.status_code == 401: - return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"} + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } if teams_resp.status_code != 200: - return {"status": "error", "message": f"Graph API error: {teams_resp.status_code}"} + return { + "status": "error", + "message": f"Graph API error: {teams_resp.status_code}", + } teams_data = teams_resp.json().get("value", []) result_teams = [] @@ -58,13 +67,19 @@ def create_list_teams_channels_tool( {"id": ch["id"], "name": ch.get("displayName", "")} for ch in ch_resp.json().get("value", []) ] - result_teams.append({ - "team_id": team_id, - "team_name": team.get("displayName", ""), - "channels": channels, - }) + result_teams.append( + { + "team_id": team_id, + "team_name": team.get("displayName", ""), + "channels": channels, + } + ) - return {"status": "success", "teams": result_teams, "total_teams": len(result_teams)} + return { + "status": "success", + "teams": result_teams, + "total_teams": len(result_teams), + } except Exception as e: from langgraph.errors import GraphInterrupt diff --git a/surfsense_backend/app/agents/new_chat/tools/teams/read_messages.py b/surfsense_backend/app/agents/new_chat/tools/teams/read_messages.py index 90896cb95..d24a7e4d3 100644 --- a/surfsense_backend/app/agents/new_chat/tools/teams/read_messages.py +++ b/surfsense_backend/app/agents/new_chat/tools/teams/read_messages.py @@ -52,11 +52,21 @@ def create_read_teams_messages_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"} + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } if resp.status_code == 403: - return {"status": "error", "message": "Insufficient permissions to read this channel."} + return { + "status": "error", + "message": "Insufficient permissions to read this channel.", + } if resp.status_code != 200: - return {"status": "error", "message": f"Graph API error: {resp.status_code}"} + return { + "status": "error", + "message": f"Graph API error: {resp.status_code}", + } raw_msgs = resp.json().get("value", []) messages = [] @@ -64,13 +74,15 @@ def create_read_teams_messages_tool( sender = m.get("from", {}) user_info = sender.get("user", {}) if sender else {} body = m.get("body", {}) - messages.append({ - "id": m.get("id"), - "sender": user_info.get("displayName", "Unknown"), - "content": body.get("content", ""), - "content_type": body.get("contentType", "text"), - "timestamp": m.get("createdDateTime", ""), - }) + messages.append( + { + "id": m.get("id"), + "sender": user_info.get("displayName", "Unknown"), + "content": body.get("content", ""), + "content_type": body.get("contentType", "text"), + "timestamp": m.get("createdDateTime", ""), + } + ) return { "status": "success", diff --git a/surfsense_backend/app/agents/new_chat/tools/teams/send_message.py b/surfsense_backend/app/agents/new_chat/tools/teams/send_message.py index ba3a515d9..fd8d00870 100644 --- a/surfsense_backend/app/agents/new_chat/tools/teams/send_message.py +++ b/surfsense_backend/app/agents/new_chat/tools/teams/send_message.py @@ -50,12 +50,19 @@ def create_send_teams_message_tool( result = request_approval( action_type="teams_send_message", tool_name="send_teams_message", - params={"team_id": team_id, "channel_id": channel_id, "content": content}, + params={ + "team_id": team_id, + "channel_id": channel_id, + "content": content, + }, context={"connector_id": connector.id}, ) if result.rejected: - return {"status": "rejected", "message": "User declined. Message was not sent."} + return { + "status": "rejected", + "message": "User declined. Message was not sent.", + } final_content = result.params.get("content", content) final_team = result.params.get("team_id", team_id) @@ -74,20 +81,27 @@ def create_send_teams_message_tool( ) if resp.status_code == 401: - return {"status": "auth_error", "message": "Teams token expired. Please re-authenticate.", "connector_type": "teams"} + return { + "status": "auth_error", + "message": "Teams token expired. Please re-authenticate.", + "connector_type": "teams", + } if resp.status_code == 403: return { "status": "insufficient_permissions", "message": "Missing ChannelMessage.Send permission. Please re-authenticate with updated scopes.", } if resp.status_code not in (200, 201): - return {"status": "error", "message": f"Graph API error: {resp.status_code} — {resp.text[:200]}"} + return { + "status": "error", + "message": f"Graph API error: {resp.status_code} — {resp.text[:200]}", + } msg_data = resp.json() return { "status": "success", "message_id": msg_data.get("id"), - "message": f"Message sent to Teams channel.", + "message": "Message sent to Teams channel.", } except Exception as e: diff --git a/surfsense_backend/app/agents/new_chat/tools/tool_response.py b/surfsense_backend/app/agents/new_chat/tools/tool_response.py index 5fb1864b7..8644ada5c 100644 --- a/surfsense_backend/app/agents/new_chat/tools/tool_response.py +++ b/surfsense_backend/app/agents/new_chat/tools/tool_response.py @@ -6,7 +6,6 @@ from typing import Any class ToolResponse: - @staticmethod def success(message: str, **data: Any) -> dict[str, Any]: return {"status": "success", "message": message, **data} @@ -31,9 +30,7 @@ class ToolResponse: return {"status": "rejected", "message": message} @staticmethod - def not_found( - resource: str, identifier: str, **data: Any - ) -> dict[str, Any]: + def not_found(resource: str, identifier: str, **data: Any) -> dict[str, Any]: return { "status": "not_found", "error": f"{resource} '{identifier}' was not found.", diff --git a/surfsense_backend/app/connectors/exceptions.py b/surfsense_backend/app/connectors/exceptions.py index 32a1e7bdc..027adbb87 100644 --- a/surfsense_backend/app/connectors/exceptions.py +++ b/surfsense_backend/app/connectors/exceptions.py @@ -13,7 +13,6 @@ from typing import Any class ConnectorError(Exception): - def __init__( self, message: str, diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 8df930f30..de4e05423 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -98,7 +98,9 @@ router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(surfsense_docs_router) # Surfsense documentation for citations router.include_router(notifications_router) # Notifications with Zero sync -router.include_router(mcp_oauth_router) # MCP OAuth 2.1 for Linear, Jira, ClickUp, Slack, Airtable +router.include_router( + mcp_oauth_router +) # MCP OAuth 2.1 for Linear, Jira, ClickUp, Slack, Airtable router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages diff --git a/surfsense_backend/app/routes/mcp_oauth_route.py b/surfsense_backend/app/routes/mcp_oauth_route.py index e14be83d0..1abc1f1ec 100644 --- a/surfsense_backend/app/routes/mcp_oauth_route.py +++ b/surfsense_backend/app/routes/mcp_oauth_route.py @@ -29,7 +29,11 @@ from app.db import ( ) from app.users import current_active_user from app.utils.connector_naming import generate_unique_connector_name -from app.utils.oauth_security import OAuthStateManager, TokenEncryption, generate_pkce_pair +from app.utils.oauth_security import ( + OAuthStateManager, + TokenEncryption, + generate_pkce_pair, +) logger = logging.getLogger(__name__) @@ -37,7 +41,9 @@ router = APIRouter() async def _fetch_account_metadata( - service_key: str, access_token: str, token_json: dict[str, Any], + service_key: str, + access_token: str, + token_json: dict[str, Any], ) -> dict[str, Any]: """Fetch display-friendly account metadata after a successful token exchange. @@ -86,7 +92,8 @@ async def _fetch_account_metadata( meta["display_name"] = whoami.get("email", "Airtable") else: logger.warning( - "Airtable whoami API returned %d (non-blocking)", resp.status_code, + "Airtable whoami API returned %d (non-blocking)", + resp.status_code, ) except Exception: @@ -98,6 +105,7 @@ async def _fetch_account_metadata( return meta + _state_manager: OAuthStateManager | None = None _token_encryption: TokenEncryption | None = None @@ -151,6 +159,7 @@ def _frontend_redirect( # /add — start MCP OAuth flow # --------------------------------------------------------------------------- + @router.get("/auth/mcp/{service}/connector/add") async def connect_mcp_service( service: str, @@ -170,9 +179,12 @@ async def connect_mcp_service( ) metadata = await discover_oauth_metadata( - svc.mcp_url, origin_override=svc.oauth_discovery_origin, + svc.mcp_url, + origin_override=svc.oauth_discovery_origin, + ) + auth_endpoint = svc.auth_endpoint_override or metadata.get( + "authorization_endpoint" ) - auth_endpoint = svc.auth_endpoint_override or metadata.get("authorization_endpoint") token_endpoint = svc.token_endpoint_override or metadata.get("token_endpoint") registration_endpoint = metadata.get("registration_endpoint") @@ -236,7 +248,9 @@ async def connect_mcp_service( logger.info( "Generated %s MCP OAuth URL for user %s, space %s", - svc.name, user.id, space_id, + svc.name, + user.id, + space_id, ) return {"auth_url": auth_url} @@ -245,7 +259,8 @@ async def connect_mcp_service( except Exception as e: logger.error("Failed to initiate %s MCP OAuth: %s", service, e, exc_info=True) raise HTTPException( - status_code=500, detail=f"Failed to initiate {service} MCP OAuth.", + status_code=500, + detail=f"Failed to initiate {service} MCP OAuth.", ) from e @@ -253,6 +268,7 @@ async def connect_mcp_service( # /callback — handle OAuth redirect # --------------------------------------------------------------------------- + @router.get("/auth/mcp/{service}/connector/callback") async def mcp_oauth_callback( service: str, @@ -271,7 +287,9 @@ async def mcp_oauth_callback( except Exception: pass return _frontend_redirect( - space_id, error=f"{service}_mcp_oauth_denied", service=service, + space_id, + error=f"{service}_mcp_oauth_denied", + service=service, ) if not code: @@ -337,9 +355,7 @@ async def mcp_oauth_callback( expires_at = None if expires_in: - expires_at = datetime.now(UTC) + timedelta( - seconds=int(expires_in) - ) + expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) connector_config = { "server_config": { @@ -349,10 +365,14 @@ async def mcp_oauth_callback( "mcp_service": svc_key, "mcp_oauth": { "client_id": client_id, - "client_secret": enc.encrypt_token(client_secret) if client_secret else "", + "client_secret": enc.encrypt_token(client_secret) + if client_secret + else "", "token_endpoint": token_endpoint, "access_token": enc.encrypt_token(access_token), - "refresh_token": enc.encrypt_token(refresh_token) if refresh_token else None, + "refresh_token": enc.encrypt_token(refresh_token) + if refresh_token + else None, "expires_at": expires_at.isoformat() if expires_at else None, "scope": scope, }, @@ -361,15 +381,27 @@ async def mcp_oauth_callback( account_meta = await _fetch_account_metadata(svc_key, access_token, token_json) if account_meta: - _SAFE_META_KEYS = {"display_name", "team_id", "team_name", "user_id", "user_email", - "workspace_id", "workspace_name", "organization_name", - "organization_url_key", "cloud_id", "site_name", "base_url"} + safe_meta_keys = { + "display_name", + "team_id", + "team_name", + "user_id", + "user_email", + "workspace_id", + "workspace_name", + "organization_name", + "organization_url_key", + "cloud_id", + "site_name", + "base_url", + } for k, v in account_meta.items(): - if k in _SAFE_META_KEYS: + if k in safe_meta_keys: connector_config[k] = v logger.info( "Stored account metadata for %s: display_name=%s", - svc_key, account_meta.get("display_name", ""), + svc_key, + account_meta.get("display_name", ""), ) # ---- Re-auth path ---- @@ -400,15 +432,24 @@ async def mcp_oauth_callback( logger.info( "Re-authenticated %s MCP connector %s for user %s", - svc.name, db_connector.id, user_id, + svc.name, + db_connector.id, + user_id, ) reauth_return_url = data.get("return_url") - if reauth_return_url and reauth_return_url.startswith("/") and not reauth_return_url.startswith("//"): + if ( + reauth_return_url + and reauth_return_url.startswith("/") + and not reauth_return_url.startswith("//") + ): return RedirectResponse( url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}" ) return _frontend_redirect( - space_id, success=True, connector_id=db_connector.id, service=service, + space_id, + success=True, + connector_id=db_connector.id, + service=service, ) # ---- New connector path ---- @@ -436,24 +477,34 @@ async def mcp_oauth_callback( except IntegrityError as e: await session.rollback() raise HTTPException( - status_code=409, detail="A connector for this service already exists.", + status_code=409, + detail="A connector for this service already exists.", ) from e _invalidate_cache(space_id) logger.info( "Created %s MCP connector %s for user %s in space %s", - svc.name, new_connector.id, user_id, space_id, + svc.name, + new_connector.id, + user_id, + space_id, ) return _frontend_redirect( - space_id, success=True, connector_id=new_connector.id, service=service, + space_id, + success=True, + connector_id=new_connector.id, + service=service, ) except HTTPException: raise except Exception as e: logger.error( - "Failed to complete %s MCP OAuth: %s", service, e, exc_info=True, + "Failed to complete %s MCP OAuth: %s", + service, + e, + exc_info=True, ) raise HTTPException( status_code=500, @@ -465,6 +516,7 @@ async def mcp_oauth_callback( # /reauth — re-authenticate an existing MCP connector # --------------------------------------------------------------------------- + @router.get("/auth/mcp/{service}/connector/reauth") async def reauth_mcp_service( service: str, @@ -491,7 +543,8 @@ async def reauth_mcp_service( ) if not result.scalars().first(): raise HTTPException( - status_code=404, detail="Connector not found or access denied", + status_code=404, + detail="Connector not found or access denied", ) try: @@ -501,9 +554,12 @@ async def reauth_mcp_service( ) metadata = await discover_oauth_metadata( - svc.mcp_url, origin_override=svc.oauth_discovery_origin, + svc.mcp_url, + origin_override=svc.oauth_discovery_origin, + ) + auth_endpoint = svc.auth_endpoint_override or metadata.get( + "authorization_endpoint" ) - auth_endpoint = svc.auth_endpoint_override or metadata.get("authorization_endpoint") token_endpoint = svc.token_endpoint_override or metadata.get("token_endpoint") registration_endpoint = metadata.get("registration_endpoint") @@ -545,7 +601,9 @@ async def reauth_mcp_service( "service": service, "code_verifier": verifier, "mcp_client_id": client_id, - "mcp_client_secret": enc.encrypt_token(client_secret) if client_secret else "", + "mcp_client_secret": enc.encrypt_token(client_secret) + if client_secret + else "", "mcp_token_endpoint": token_endpoint, "mcp_url": svc.mcp_url, "connector_id": connector_id, @@ -554,7 +612,9 @@ async def reauth_mcp_service( extra["return_url"] = return_url state = _get_state_manager().generate_secure_state( - space_id, user.id, **extra, + space_id, + user.id, + **extra, ) auth_params: dict[str, str] = { @@ -572,7 +632,9 @@ async def reauth_mcp_service( logger.info( "Initiating %s MCP re-auth for user %s, connector %s", - svc.name, user.id, connector_id, + svc.name, + user.id, + connector_id, ) return {"auth_url": auth_url} @@ -580,7 +642,10 @@ async def reauth_mcp_service( raise except Exception as e: logger.error( - "Failed to initiate %s MCP re-auth: %s", service, e, exc_info=True, + "Failed to initiate %s MCP re-auth: %s", + service, + e, + exc_info=True, ) raise HTTPException( status_code=500, @@ -592,6 +657,7 @@ async def reauth_mcp_service( # Helpers # --------------------------------------------------------------------------- + def _invalidate_cache(space_id: int) -> None: try: from app.agents.new_chat.tools.mcp_tool import invalidate_mcp_tools_cache diff --git a/surfsense_backend/app/routes/new_chat_routes.py b/surfsense_backend/app/routes/new_chat_routes.py index 85a8658ec..091e87737 100644 --- a/surfsense_backend/app/routes/new_chat_routes.py +++ b/surfsense_backend/app/routes/new_chat_routes.py @@ -24,9 +24,9 @@ from sqlalchemy.orm import selectinload from app.agents.new_chat.filesystem_selection import ( ClientPlatform, - LocalFilesystemMount, FilesystemMode, FilesystemSelection, + LocalFilesystemMount, ) from app.config import config from app.db import ( diff --git a/surfsense_backend/app/routes/oauth_connector_base.py b/surfsense_backend/app/routes/oauth_connector_base.py index 0638e8f34..5b75d8519 100644 --- a/surfsense_backend/app/routes/oauth_connector_base.py +++ b/surfsense_backend/app/routes/oauth_connector_base.py @@ -9,6 +9,7 @@ Call ``build_router()`` to get a FastAPI ``APIRouter`` with ``/connector/add``, from __future__ import annotations import base64 +import contextlib import logging from datetime import UTC, datetime, timedelta from typing import Any @@ -41,7 +42,6 @@ logger = logging.getLogger(__name__) class OAuthConnectorRoute: - def __init__( self, *, @@ -244,10 +244,8 @@ class OAuthConnectorRoute: if resp.status_code != 200: detail = resp.text - try: + with contextlib.suppress(Exception): detail = resp.json().get("error_description", detail) - except Exception: - pass raise HTTPException( status_code=400, detail=f"Token exchange failed: {detail}" ) @@ -430,7 +428,11 @@ class OAuthConnectorRoute: state_mgr = oauth._get_state_manager() extra: dict[str, Any] = {"connector_id": connector_id} - if return_url and return_url.startswith("/") and not return_url.startswith("//"): + if ( + return_url + and return_url.startswith("/") + and not return_url.startswith("//") + ): extra["return_url"] = return_url auth_params: dict[str, str] = { @@ -450,9 +452,7 @@ class OAuthConnectorRoute: auth_params.update(oauth.extra_auth_params) - state_encoded = state_mgr.generate_secure_state( - space_id, user.id, **extra - ) + state_encoded = state_mgr.generate_secure_state(space_id, user.id, **extra) auth_params["state"] = state_encoded auth_url = f"{oauth.authorize_url}?{urlencode(auth_params)}" @@ -489,9 +489,7 @@ class OAuthConnectorRoute: status_code=400, detail="Missing authorization code" ) if not state: - raise HTTPException( - status_code=400, detail="Missing state parameter" - ) + raise HTTPException(status_code=400, detail="Missing state parameter") state_mgr = oauth._get_state_manager() try: @@ -552,7 +550,11 @@ class OAuthConnectorRoute: db_connector.id, user_id, ) - if reauth_return_url and reauth_return_url.startswith("/") and not reauth_return_url.startswith("//"): + if ( + reauth_return_url + and reauth_return_url.startswith("/") + and not reauth_return_url.startswith("//") + ): return RedirectResponse( url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}" ) @@ -603,7 +605,8 @@ class OAuthConnectorRoute: except IntegrityError as e: await session.rollback() raise HTTPException( - status_code=409, detail="A connector for this service already exists." + status_code=409, + detail="A connector for this service already exists.", ) from e logger.info( diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index d42a7fa1a..9037d275a 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -3092,7 +3092,7 @@ async def trust_mcp_tool( select(SearchSourceConnector).filter( SearchSourceConnector.id == connector_id, SearchSourceConnector.user_id == user.id, - cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"), # noqa: W601 + cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"), ) ) connector = result.scalars().first() @@ -3147,7 +3147,7 @@ async def untrust_mcp_tool( select(SearchSourceConnector).filter( SearchSourceConnector.id == connector_id, SearchSourceConnector.user_id == user.id, - cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"), # noqa: W601 + cast(SearchSourceConnector.config, PG_JSONB).has_key("server_config"), ) ) connector = result.scalars().first() diff --git a/surfsense_backend/app/services/mcp_oauth/discovery.py b/surfsense_backend/app/services/mcp_oauth/discovery.py index b0f3fef2a..dc21443bc 100644 --- a/surfsense_backend/app/services/mcp_oauth/discovery.py +++ b/surfsense_backend/app/services/mcp_oauth/discovery.py @@ -55,7 +55,9 @@ async def register_client( async with httpx.AsyncClient(follow_redirects=True) as client: resp = await client.post( - registration_endpoint, json=payload, timeout=timeout, + registration_endpoint, + json=payload, + timeout=timeout, ) resp.raise_for_status() return resp.json() diff --git a/surfsense_backend/app/services/mcp_oauth/registry.py b/surfsense_backend/app/services/mcp_oauth/registry.py index 49bc74d3d..835d70184 100644 --- a/surfsense_backend/app/services/mcp_oauth/registry.py +++ b/surfsense_backend/app/services/mcp_oauth/registry.py @@ -70,12 +70,14 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = { "createJiraIssue", "editJiraIssue", ], - readonly_tools=frozenset({ - "getAccessibleAtlassianResources", - "searchJiraIssuesUsingJql", - "getVisibleJiraProjects", - "getJiraProjectIssueTypesMetadata", - }), + readonly_tools=frozenset( + { + "getAccessibleAtlassianResources", + "searchJiraIssuesUsingJql", + "getVisibleJiraProjects", + "getJiraProjectIssueTypesMetadata", + } + ), account_metadata_keys=["cloud_id", "site_name", "base_url"], ), "clickup": MCPServiceConfig( @@ -99,15 +101,23 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = { auth_endpoint_override="https://slack.com/oauth/v2_user/authorize", token_endpoint_override="https://slack.com/api/oauth.v2.user.access", scopes=[ - "search:read.public", "search:read.private", "search:read.mpim", "search:read.im", - "channels:history", "groups:history", "mpim:history", "im:history", + "search:read.public", + "search:read.private", + "search:read.mpim", + "search:read.im", + "channels:history", + "groups:history", + "mpim:history", + "im:history", ], allowed_tools=[ "slack_search_channels", "slack_read_channel", "slack_read_thread", ], - readonly_tools=frozenset({"slack_search_channels", "slack_read_channel", "slack_read_thread"}), + readonly_tools=frozenset( + {"slack_search_channels", "slack_read_channel", "slack_read_thread"} + ), # TODO: oauth.v2.user.access only returns team.id, not team.name. # To populate team_name, either add "team:read" scope and call # GET /api/team.info during OAuth callback, or switch to oauth.v2.access. @@ -127,7 +137,9 @@ MCP_SERVICES: dict[str, MCPServiceConfig] = { "list_tables_for_base", "list_records_for_table", ], - readonly_tools=frozenset({"list_bases", "list_tables_for_base", "list_records_for_table"}), + readonly_tools=frozenset( + {"list_bases", "list_tables_for_base", "list_records_for_table"} + ), account_metadata_keys=["user_id", "user_email"], ), } @@ -136,20 +148,22 @@ _CONNECTOR_TYPE_TO_SERVICE: dict[str, MCPServiceConfig] = { svc.connector_type: svc for svc in MCP_SERVICES.values() } -LIVE_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset({ - SearchSourceConnectorType.SLACK_CONNECTOR, - SearchSourceConnectorType.TEAMS_CONNECTOR, - SearchSourceConnectorType.LINEAR_CONNECTOR, - SearchSourceConnectorType.JIRA_CONNECTOR, - SearchSourceConnectorType.CLICKUP_CONNECTOR, - SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, - SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, - SearchSourceConnectorType.AIRTABLE_CONNECTOR, - SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, - SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, - SearchSourceConnectorType.DISCORD_CONNECTOR, - SearchSourceConnectorType.LUMA_CONNECTOR, -}) +LIVE_CONNECTOR_TYPES: frozenset[SearchSourceConnectorType] = frozenset( + { + SearchSourceConnectorType.SLACK_CONNECTOR, + SearchSourceConnectorType.TEAMS_CONNECTOR, + SearchSourceConnectorType.LINEAR_CONNECTOR, + SearchSourceConnectorType.JIRA_CONNECTOR, + SearchSourceConnectorType.CLICKUP_CONNECTOR, + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.AIRTABLE_CONNECTOR, + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR, + SearchSourceConnectorType.DISCORD_CONNECTOR, + SearchSourceConnectorType.LUMA_CONNECTOR, + } +) def get_service(key: str) -> MCPServiceConfig | None: diff --git a/surfsense_backend/app/services/obsidian_plugin_indexer.py b/surfsense_backend/app/services/obsidian_plugin_indexer.py index 8fbdad269..0fc4f30f4 100644 --- a/surfsense_backend/app/services/obsidian_plugin_indexer.py +++ b/surfsense_backend/app/services/obsidian_plugin_indexer.py @@ -156,7 +156,9 @@ async def _extract_binary_attachment_markdown( try: raw_bytes = base64.b64decode(payload.binary_base64, validate=True) except Exception: - logger.warning("obsidian attachment payload had invalid base64: %s", payload.path) + logger.warning( + "obsidian attachment payload had invalid base64: %s", payload.path + ) return "", {"attachment_extraction_status": "invalid_binary_payload"} suffix = f".{payload.extension.lstrip('.')}" @@ -180,7 +182,10 @@ async def _extract_binary_attachment_markdown( return result.markdown_content, metadata except Exception as exc: logger.warning( - "obsidian attachment ETL failed for %s: %s", payload.path, exc, exc_info=True + "obsidian attachment ETL failed for %s: %s", + payload.path, + exc, + exc_info=True, ) return "", { "attachment_extraction_status": "etl_failed", diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 5a6117808..7239c57a5 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -31,7 +31,6 @@ from sqlalchemy.orm import selectinload from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent from app.agents.new_chat.checkpointer import get_checkpointer from app.agents.new_chat.filesystem_selection import FilesystemSelection -from app.config import config from app.agents.new_chat.llm_config import ( AgentConfig, create_chat_litellm_from_agent_config, @@ -182,9 +181,9 @@ def _tool_output_has_error(tool_output: Any) -> bool: if tool_output.get("error"): return True result = tool_output.get("result") - if isinstance(result, str) and result.strip().lower().startswith("error:"): - return True - return False + return bool( + isinstance(result, str) and result.strip().lower().startswith("error:") + ) if isinstance(tool_output, str): return tool_output.strip().lower().startswith("error:") return False @@ -230,7 +229,9 @@ def _log_file_contract(stage: str, result: StreamResult, **extra: Any) -> None: "stage": stage, "request_id": result.request_id or "unknown", "turn_id": result.turn_id or "unknown", - "chat_id": result.turn_id.split(":", 1)[0] if ":" in result.turn_id else "unknown", + "chat_id": result.turn_id.split(":", 1)[0] + if ":" in result.turn_id + else "unknown", "filesystem_mode": result.filesystem_mode, "client_platform": result.client_platform, "intent_detected": result.intent_detected, @@ -242,7 +243,9 @@ def _log_file_contract(stage: str, result: StreamResult, **extra: Any) -> None: "commit_gate_reason": result.commit_gate_reason or None, } payload.update(extra) - _perf_log.info("[file_operation_contract] %s", json.dumps(payload, ensure_ascii=False)) + _perf_log.info( + "[file_operation_contract] %s", json.dumps(payload, ensure_ascii=False) + ) async def _stream_agent_events( @@ -1289,7 +1292,8 @@ async def _stream_agent_events( result.intent_detected = intent_value if ( isinstance(intent_value, str) - and intent_value in ( + and intent_value + in ( "chat_only", "file_write", "file_read", @@ -1308,18 +1312,17 @@ async def _stream_agent_events( result.commit_gate_passed, result.commit_gate_reason = ( _evaluate_file_contract_outcome(result) ) - if not result.commit_gate_passed: - if _contract_enforcement_active(result): - gate_notice = ( - "I could not complete the requested file write because no successful " - "write_file/edit_file operation was confirmed." - ) - gate_text_id = streaming_service.generate_text_id() - yield streaming_service.format_text_start(gate_text_id) - yield streaming_service.format_text_delta(gate_text_id, gate_notice) - yield streaming_service.format_text_end(gate_text_id) - yield streaming_service.format_terminal_info(gate_notice, "error") - accumulated_text = gate_notice + if not result.commit_gate_passed and _contract_enforcement_active(result): + gate_notice = ( + "I could not complete the requested file write because no successful " + "write_file/edit_file operation was confirmed." + ) + gate_text_id = streaming_service.generate_text_id() + yield streaming_service.format_text_start(gate_text_id) + yield streaming_service.format_text_delta(gate_text_id, gate_notice) + yield streaming_service.format_text_end(gate_text_id) + yield streaming_service.format_terminal_info(gate_notice, "error") + accumulated_text = gate_notice else: result.commit_gate_passed = True result.commit_gate_reason = "" diff --git a/surfsense_backend/app/utils/async_retry.py b/surfsense_backend/app/utils/async_retry.py index c3bdd5386..a56f6550a 100644 --- a/surfsense_backend/app/utils/async_retry.py +++ b/surfsense_backend/app/utils/async_retry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import logging from collections.abc import Callable from typing import TypeVar @@ -32,9 +33,7 @@ F = TypeVar("F", bound=Callable) def _is_retryable(exc: BaseException) -> bool: if isinstance(exc, ConnectorError): return exc.retryable - if isinstance(exc, (httpx.TimeoutException, httpx.ConnectError)): - return True - return False + return bool(isinstance(exc, (httpx.TimeoutException, httpx.ConnectError))) def build_retry( @@ -86,10 +85,8 @@ def raise_for_status( retry_after_raw = response.headers.get("Retry-After") retry_after: float | None = None if retry_after_raw: - try: + with contextlib.suppress(ValueError, TypeError): retry_after = float(retry_after_raw) - except (ValueError, TypeError): - pass raise ConnectorRateLimitError( f"{service} rate limited (429)", service=service, diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py index 889bf1464..99c8243a5 100644 --- a/surfsense_backend/app/utils/connector_naming.py +++ b/surfsense_backend/app/utils/connector_naming.py @@ -233,7 +233,10 @@ async def generate_unique_connector_name( if identifier: name = f"{base} - {identifier}" return await ensure_unique_connector_name( - session, name, search_space_id, user_id, + session, + name, + search_space_id, + user_id, ) count = await count_connectors_of_type( diff --git a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py index 41779a570..22f6c6de5 100644 --- a/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py +++ b/surfsense_backend/tests/integration/test_obsidian_plugin_routes.py @@ -499,7 +499,9 @@ class TestWireContractSmoke: "app.routes.obsidian_plugin_routes.upsert_note", new=AsyncMock(return_value=fake_doc), ) as upsert_mock, - patch("app.routes.obsidian_plugin_routes._queue_obsidian_attachment") as queue_mock, + patch( + "app.routes.obsidian_plugin_routes._queue_obsidian_attachment" + ) as queue_mock, ): sync_resp = await obsidian_sync( SyncBatchRequest( @@ -548,7 +550,9 @@ class TestWireContractSmoke: "app.routes.obsidian_plugin_routes.upsert_note", new=AsyncMock(return_value=fake_doc), ), - patch("app.routes.obsidian_plugin_routes._queue_obsidian_attachment") as queue_mock, + patch( + "app.routes.obsidian_plugin_routes._queue_obsidian_attachment" + ) as queue_mock, ): sync_resp = await obsidian_sync( SyncBatchRequest( @@ -600,7 +604,9 @@ class TestWireContractSmoke: "app.routes.obsidian_plugin_routes.upsert_note", new=AsyncMock(return_value=fake_doc), ), - patch("app.routes.obsidian_plugin_routes._queue_obsidian_attachment") as queue_mock, + patch( + "app.routes.obsidian_plugin_routes._queue_obsidian_attachment" + ) as queue_mock, ): sync_resp = await obsidian_sync( SyncBatchRequest( @@ -619,7 +625,5 @@ class TestWireContractSmoke: items_by_path = {it.path: it for it in sync_resp.items} assert items_by_path["ok.md"].status == "ok" assert items_by_path["image.png"].status == "error" - assert "does not match extension" in ( - items_by_path["image.png"].error or "" - ) + assert "does not match extension" in (items_by_path["image.png"].error or "") queue_mock.assert_not_called() diff --git a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py index 673331b0a..7fd3fe4a7 100644 --- a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py +++ b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py @@ -45,9 +45,7 @@ async def test_file_write_intent_injects_contract_message(): @pytest.mark.asyncio async def test_non_write_intent_does_not_inject_contract_message(): - llm = _FakeLLM( - '{"intent":"file_read","confidence":0.88,"suggested_filename":null}' - ) + llm = _FakeLLM('{"intent":"file_read","confidence":0.88,"suggested_filename":null}') middleware = FileIntentMiddleware(llm=llm) original_messages = [HumanMessage(content="Read /notes.md")] state = {"messages": original_messages, "turn_id": "abc:def"} @@ -55,7 +53,10 @@ async def test_non_write_intent_does_not_inject_contract_message(): result = await middleware.abefore_agent(state, runtime=None) # type: ignore[arg-type] assert result is not None - assert result["file_operation_contract"]["intent"] == FileOperationIntent.FILE_READ.value + assert ( + result["file_operation_contract"]["intent"] + == FileOperationIntent.FILE_READ.value + ) assert "messages" not in result @@ -211,4 +212,3 @@ def test_fallback_path_keeps_posix_style_absolute_path_for_linux_and_macos() -> ) assert resolved == "/var/log/surfsense/notes.md" - diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py index d00365032..cca15e789 100644 --- a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py +++ b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py @@ -2,11 +2,11 @@ from pathlib import Path import pytest +from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( MultiRootLocalFolderBackend, ) -from app.agents.new_chat.filesystem_selection import FilesystemMode -from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware pytestmark = pytest.mark.unit diff --git a/surfsense_backend/tests/unit/test_obsidian_plugin_indexer.py b/surfsense_backend/tests/unit/test_obsidian_plugin_indexer.py index 7ab3c52e0..20795c739 100644 --- a/surfsense_backend/tests/unit/test_obsidian_plugin_indexer.py +++ b/surfsense_backend/tests/unit/test_obsidian_plugin_indexer.py @@ -15,7 +15,6 @@ from app.services.obsidian_plugin_indexer import ( _require_extracted_attachment_content, ) - _FAKE_PNG_B64 = base64.b64encode(b"\x89PNG\r\n\x1a\n").decode("ascii") @@ -102,9 +101,7 @@ async def test_extract_binary_attachment_markdown_uses_etl(monkeypatch) -> None: mime_type="application/pdf", ) - async def _fake_run_etl_extract( # noqa: ANN001 - *, file_path, filename, vision_llm - ): + async def _fake_run_etl_extract(*, file_path, filename, vision_llm): assert filename == "spec.pdf" assert file_path assert vision_llm is None @@ -216,7 +213,7 @@ def test_note_payload_rejects_markdown_with_binary_fields() -> None: def test_require_extracted_attachment_content_rejects_empty_content() -> None: with pytest.raises( - RuntimeError, match="Attachment extraction failed for assets/img.png" + RuntimeError, match=r"Attachment extraction failed for assets/img\.png" ): _require_extracted_attachment_content( content=" ", diff --git a/surfsense_backend/tests/unit/test_stream_new_chat_contract.py b/surfsense_backend/tests/unit/test_stream_new_chat_contract.py index f4adc3d73..034aa484c 100644 --- a/surfsense_backend/tests/unit/test_stream_new_chat_contract.py +++ b/surfsense_backend/tests/unit/test_stream_new_chat_contract.py @@ -45,4 +45,3 @@ def test_contract_enforcement_local_only(): result.filesystem_mode = "cloud" assert not _contract_enforcement_active(result) - diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 06f3bf79f..9f569398e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -45,8 +45,8 @@ import { } from "@/components/assistant-ui/token-usage-context"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useMessagesSync } from "@/hooks/use-messages-sync"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; import { getAgentFilesystemSelection } from "@/lib/agent-filesystem"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; import { getBearerToken } from "@/lib/auth-utils"; import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { @@ -661,8 +661,7 @@ export default function NewChatPage() { const selection = await getAgentFilesystemSelection(searchSpaceId); if ( selection.filesystem_mode === "desktop_local_folder" && - (!selection.local_filesystem_mounts || - selection.local_filesystem_mounts.length === 0) + (!selection.local_filesystem_mounts || selection.local_filesystem_mounts.length === 0) ) { toast.error("Select a local folder before using Local Folder mode."); return; @@ -842,14 +841,7 @@ export default function NewChatPage() { }); } else { const tcId = `interrupt-${action.name}`; - addToolCall( - contentPartsState, - toolsWithUI, - tcId, - action.name, - action.args, - true - ); + addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true); updateToolCall(contentPartsState, tcId, { result: { __interrupt__: true, ...interruptData }, }); @@ -1189,14 +1181,7 @@ export default function NewChatPage() { }); } else { const tcId = `interrupt-${action.name}`; - addToolCall( - contentPartsState, - toolsWithUI, - tcId, - action.name, - action.args, - true - ); + addToolCall(contentPartsState, toolsWithUI, tcId, action.name, action.args, true); updateToolCall(contentPartsState, tcId, { result: { __interrupt__: true, diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx index 6207457c4..12a7d00f0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent.tsx @@ -111,9 +111,7 @@ function HotkeyRow({ } > {recording ? ( - - Press hotkeys... - + Press hotkeys... ) : ( )} @@ -155,7 +153,9 @@ export function DesktopShortcutsContent() { if (!api) { return (
-

Hotkeys are only available in the SurfSense desktop app.

+

+ Hotkeys are only available in the SurfSense desktop app. +

); } @@ -178,28 +178,26 @@ export function DesktopShortcutsContent() { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; - return ( - shortcutsLoaded ? ( -
-
- {HOTKEY_ROWS.map((row) => ( - updateShortcut(row.key, accel)} - onReset={() => resetShortcut(row.key)} - /> - ))} -
+ return shortcutsLoaded ? ( +
+
+ {HOTKEY_ROWS.map((row) => ( + updateShortcut(row.key, accel)} + onReset={() => resetShortcut(row.key)} + /> + ))}
- ) : ( -
- -
- ) +
+ ) : ( +
+ +
); } diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx index 451143949..c64eb65f8 100644 --- a/surfsense_web/app/desktop/login/page.tsx +++ b/surfsense_web/app/desktop/login/page.tsx @@ -24,7 +24,12 @@ const isGoogleAuth = AUTH_TYPE === "GOOGLE"; type ShortcutKey = "generalAssist" | "quickAsk" | "autocomplete"; type ShortcutMap = typeof DEFAULT_SHORTCUTS; -const HOTKEY_ROWS: Array<{ key: ShortcutKey; label: string; description: string; icon: React.ElementType }> = [ +const HOTKEY_ROWS: Array<{ + key: ShortcutKey; + label: string; + description: string; + icon: React.ElementType; +}> = [ { key: "generalAssist", label: "General Assist", @@ -369,7 +374,9 @@ export default function DesktopLoginPage() { )} diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 66333a9ef..32943142a 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -123,9 +123,9 @@ export const ConnectorIndicator = forwardRef ) : viewingMCPList ? ( - handleDisconnectFromList(connector, () => refreshConnectors())} - onAddAccount={handleAddNewMCPFromList} - addButtonText="Add New MCP Server" - /> + + handleDisconnectFromList(connector, () => refreshConnectors()) + } + onAddAccount={handleAddNewMCPFromList} + addButtonText="Add New MCP Server" + /> ) : viewingAccountsType ? ( - handleDisconnectFromList(connector, () => refreshConnectors())} - onAddAccount={() => { + + handleDisconnectFromList(connector, () => refreshConnectors()) + } + onAddAccount={() => { // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS const oauthConnector = OAUTH_CONNECTORS.find( diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx index fc9812240..d9a740af2 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/mcp-connect-form.tsx @@ -213,13 +213,13 @@ export const MCPConnectForm: FC = ({ onSubmit, isSubmitting }) className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80" > {isTesting ? ( - <> - - Testing Connection... - - ) : ( - "Test Connection" - )} + <> + + Testing Connection... + + ) : ( + "Test Connection" + )}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx index d6f60e824..97b5de675 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/mcp-config.tsx @@ -218,13 +218,13 @@ export const MCPConfig: FC = ({ connector, onConfigChange, onNam className="w-full h-8 text-[13px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80" > {isTesting ? ( - <> - - Testing Connection... - - ) : ( - "Test Connection" - )} + <> + + Testing Connection... + + ) : ( + "Test Connection" + )} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config.tsx index e96ddfd29..06ce21dae 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/teams-config.tsx @@ -18,9 +18,9 @@ export const TeamsConfig: FC = () => {

Microsoft Teams Access

- Your agent can search and read messages from Teams channels you have access to, - and send messages on your behalf. Make sure you're a member of the teams - you want to interact with. + Your agent can search and read messages from Teams channels you have access to, and send + messages on your behalf. Make sure you're a member of the teams you want to interact + with.

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index b2b40dfd6..c104f140a 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -16,7 +16,7 @@ import { DateRangeSelector } from "../../components/date-range-selector"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; import { SummaryConfig } from "../../components/summary-config"; import { VisionLLMConfig } from "../../components/vision-llm-config"; -import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../../constants/connector-constants"; +import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; import { MCPServiceConfig } from "../components/mcp-service-config"; import { getConnectorConfigComponent } from "../index"; @@ -314,8 +314,7 @@ export const ConnectorEditView: FC = ({ {connector.is_indexable && (() => { - const isGoogleDrive = - connector.connector_type === "GOOGLE_DRIVE_CONNECTOR"; + const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR"; const isComposioGoogleDrive = connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"; const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive; @@ -327,8 +326,7 @@ export const ConnectorEditView: FC = ({ (connector.config?.selected_files as | Array<{ id: string; name: string }> | undefined) || []; - const hasItemsSelected = - selectedFolders.length > 0 || selectedFiles.length > 0; + const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0; const isDisabled = requiresFolderSelection && !hasItemsSelected; return ( @@ -380,8 +378,8 @@ export const ConnectorEditView: FC = ({ {/* Fixed Footer - Action buttons */}
- {showDisconnectConfirm ? ( -
+ {showDisconnectConfirm ? ( +
{isLive ? "Your agent will lose access to this service." diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index 690333523..982b0be11 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -12,7 +12,10 @@ import { DateRangeSelector } from "../../components/date-range-selector"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; import { SummaryConfig } from "../../components/summary-config"; import { VisionLLMConfig } from "../../components/vision-llm-config"; -import { LIVE_CONNECTOR_TYPES, type IndexingConfigState } from "../../constants/connector-constants"; +import { + type IndexingConfigState, + LIVE_CONNECTOR_TYPES, +} from "../../constants/connector-constants"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; import { getConnectorConfigComponent } from "../index"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index fe9aab14f..755086ba5 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -9,7 +9,11 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; import { cn } from "@/lib/utils"; -import { COMPOSIO_CONNECTORS, LIVE_CONNECTOR_TYPES, OAUTH_CONNECTORS } from "../constants/connector-constants"; +import { + COMPOSIO_CONNECTORS, + LIVE_CONNECTOR_TYPES, + OAUTH_CONNECTORS, +} from "../constants/connector-constants"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getConnectorDisplayName } from "./all-connectors-tab"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index b3c087599..8aee7e005 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -13,7 +13,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { authenticatedFetch } from "@/lib/auth-utils"; import { formatRelativeDate } from "@/lib/format-date"; import { cn } from "@/lib/utils"; -import { LIVE_CONNECTOR_TYPES, getReauthEndpoint } from "../constants/connector-constants"; +import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../constants/connector-constants"; import { useConnectorStatus } from "../hooks/use-connector-status"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; @@ -182,11 +182,14 @@ export const ConnectorAccountsListView: FC = ({
) : (
- {typeConnectors.map((connector) => { - const isIndexing = indexingConnectorIds.has(connector.id); - const connectorReauthEndpoint = getReauthEndpoint(connector); - const isAuthExpired = !!connectorReauthEndpoint && connector.config?.auth_expired === true; - const isLive = LIVE_CONNECTOR_TYPES.has(connector.connector_type) || Boolean(connector.config?.server_config); + {typeConnectors.map((connector) => { + const isIndexing = indexingConnectorIds.has(connector.id); + const connectorReauthEndpoint = getReauthEndpoint(connector); + const isAuthExpired = + !!connectorReauthEndpoint && connector.config?.auth_expired === true; + const isLive = + LIVE_CONNECTOR_TYPES.has(connector.connector_type) || + Boolean(connector.config?.server_config); return (
= ({

) : null}
- {isAuthExpired ? ( - - ) : isLive && onDisconnect ? ( - confirmDisconnectId === connector.id ? ( -
+ {isAuthExpired ? ( + + ) : isLive && onDisconnect ? ( + confirmDisconnectId === connector.id ? ( +
+ + +
+ ) : ( - -
+ ) ) : ( - ) - ) : ( - - )} + )}
); })} diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 2707e8956..8bb228580 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -20,7 +20,6 @@ import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image"; import "katex/dist/katex.min.css"; import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation"; -import { useElectronAPI } from "@/hooks/use-platform"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, @@ -30,6 +29,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; function MarkdownCodeBlockSkeleton() { @@ -493,10 +493,7 @@ const defaultComponents = memoizeMarkdownComponents({ const mounts = (await electronAPI.getAgentFilesystemMounts( resolvedSearchSpaceId )) as AgentFilesystemMount[]; - resolvedLocalPath = normalizeLocalVirtualPathForEditor( - inlineValue, - mounts - ); + resolvedLocalPath = normalizeLocalVirtualPathForEditor(inlineValue, mounts); } catch { // Fall back to the raw inline path if mount lookup fails. } diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 2fa980d27..3b69ae6e0 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -248,7 +248,15 @@ export function EditorPanelContent({ doFetch().catch(() => {}); return () => controller.abort(); - }, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]); + }, [ + documentId, + electronAPI, + isLocalFileMode, + localFilePath, + resolveLocalVirtualPath, + searchSpaceId, + title, + ]); useEffect(() => { return () => { @@ -282,69 +290,77 @@ export function EditorPanelContent({ } }, [editorDoc?.source_markdown]); - const handleSave = useCallback(async (_options?: { silent?: boolean }) => { - setSaving(true); - try { - if (isLocalFileMode) { - if (!localFilePath) { - throw new Error("Missing local file path"); + const handleSave = useCallback( + async (_options?: { silent?: boolean }) => { + setSaving(true); + try { + if (isLocalFileMode) { + if (!localFilePath) { + throw new Error("Missing local file path"); + } + if (!electronAPI?.writeAgentLocalFileText) { + throw new Error("Local file editor is available only in desktop mode."); + } + const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath); + const contentToSave = markdownRef.current; + const writeResult = await electronAPI.writeAgentLocalFileText( + resolvedLocalPath, + contentToSave, + searchSpaceId + ); + if (!writeResult.ok) { + throw new Error(writeResult.error || "Failed to save local file"); + } + setEditorDoc((prev) => (prev ? { ...prev, source_markdown: contentToSave } : prev)); + setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current); + return true; } - if (!electronAPI?.writeAgentLocalFileText) { - throw new Error("Local file editor is available only in desktop mode."); + if (!searchSpaceId || !documentId) { + throw new Error("Missing document context"); } - const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath); - const contentToSave = markdownRef.current; - const writeResult = await electronAPI.writeAgentLocalFileText( - resolvedLocalPath, - contentToSave, - searchSpaceId + const token = getBearerToken(); + if (!token) { + toast.error("Please login to save"); + redirectToLogin(); + return; + } + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_markdown: markdownRef.current }), + } ); - if (!writeResult.ok) { - throw new Error(writeResult.error || "Failed to save local file"); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); } - setEditorDoc((prev) => - prev ? { ...prev, source_markdown: contentToSave } : prev - ); - setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current); + + setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); + setEditedMarkdown(null); + toast.success("Document saved! Reindexing in background..."); return true; + } catch (err) { + console.error("Error saving document:", err); + toast.error(err instanceof Error ? err.message : "Failed to save document"); + return false; + } finally { + setSaving(false); } - if (!searchSpaceId || !documentId) { - throw new Error("Missing document context"); - } - const token = getBearerToken(); - if (!token) { - toast.error("Please login to save"); - redirectToLogin(); - return; - } - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_markdown: markdownRef.current }), - } - ); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); - } - - setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev)); - setEditedMarkdown(null); - toast.success("Document saved! Reindexing in background..."); - return true; - } catch (err) { - console.error("Error saving document:", err); - toast.error(err instanceof Error ? err.message : "Failed to save document"); - return false; - } finally { - setSaving(false); - } - }, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]); + }, + [ + documentId, + electronAPI, + isLocalFileMode, + localFilePath, + resolveLocalVirtualPath, + searchSpaceId, + ] + ); const isEditableType = editorDoc ? (editorRenderMode === "source_code" || @@ -594,9 +610,7 @@ export function EditorPanelContent({ } }} > - + Download .md @@ -626,7 +640,7 @@ export function EditorPanelContent({
) : isEditableType ? ( ; } diff --git a/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx b/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx index bdda0263d..346fe0378 100644 --- a/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx +++ b/surfsense_web/components/editor/plugins/fixed-toolbar-kit.tsx @@ -1,7 +1,6 @@ "use client"; -import { createPlatePlugin } from "platejs/react"; -import { useEditorReadOnly } from "platejs/react"; +import { createPlatePlugin, useEditorReadOnly } from "platejs/react"; import { useEditorSave } from "@/components/editor/editor-save-context"; import { FixedToolbar } from "@/components/ui/fixed-toolbar"; diff --git a/surfsense_web/components/editor/source-code-editor.tsx b/surfsense_web/components/editor/source-code-editor.tsx index dd4b3bd8e..9102dffe9 100644 --- a/surfsense_web/components/editor/source-code-editor.tsx +++ b/surfsense_web/components/editor/source-code-editor.tsx @@ -1,8 +1,8 @@ "use client"; import dynamic from "next/dynamic"; -import { useEffect, useRef } from "react"; import { useTheme } from "next-themes"; +import { useEffect, useRef } from "react"; import { Spinner } from "@/components/ui/spinner"; const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index c26cc9b23..04bae010c 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -72,9 +72,7 @@ export function RightPanelExpandButton() { const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && - (editorState.kind === "document" - ? !!editorState.documentId - : !!editorState.localFilePath); + (editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen; @@ -116,9 +114,7 @@ export function RightPanel({ documentsPanel }: RightPanelProps) { const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && - (editorState.kind === "document" - ? !!editorState.documentId - : !!editorState.localFilePath); + (editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath); const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; useEffect(() => { diff --git a/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx b/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx index dd7520d24..cd8fca331 100644 --- a/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx @@ -1,11 +1,9 @@ "use client"; -import { Folder, FolderPlus, Search, X } from "lucide-react"; import { useAtom } from "jotai"; +import { Folder, FolderPlus, Search, X } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; import { DropdownMenu, DropdownMenuContent, @@ -14,6 +12,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser"; diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index b9c174d71..0a147f7b7 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -71,7 +71,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { usePlatform, useElectronAPI } from "@/hooks/use-platform"; +import { useElectronAPI, usePlatform } from "@/hooks/use-platform"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; @@ -208,7 +208,8 @@ function AuthenticatedDocumentsSidebarBase({ const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom); const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom); - const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI; + const isElectron = + desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI; useEffect(() => { if (!electronAPI?.getAgentFilesystemSettings) return; @@ -250,10 +251,13 @@ function AuthenticatedDocumentsSidebarBase({ .filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index) .slice(0, MAX_LOCAL_FILESYSTEM_ROOTS); if (nextLocalRootPaths.length === localRootPaths.length) return; - const updated = await electronAPI.setAgentFilesystemSettings({ - mode: "desktop_local_folder", - localRootPaths: nextLocalRootPaths, - }, searchSpaceId); + const updated = await electronAPI.setAgentFilesystemSettings( + { + mode: "desktop_local_folder", + localRootPaths: nextLocalRootPaths, + }, + searchSpaceId + ); setFilesystemSettings(updated); }, [electronAPI, localRootPaths, searchSpaceId] @@ -282,10 +286,13 @@ function AuthenticatedDocumentsSidebarBase({ const handleRemoveFilesystemRoot = useCallback( async (rootPathToRemove: string) => { if (!electronAPI?.setAgentFilesystemSettings) return; - const updated = await electronAPI.setAgentFilesystemSettings({ - mode: "desktop_local_folder", - localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove), - }, searchSpaceId); + const updated = await electronAPI.setAgentFilesystemSettings( + { + mode: "desktop_local_folder", + localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove), + }, + searchSpaceId + ); setFilesystemSettings(updated); }, [electronAPI, localRootPaths, searchSpaceId] @@ -293,19 +300,25 @@ function AuthenticatedDocumentsSidebarBase({ const handleClearFilesystemRoots = useCallback(async () => { if (!electronAPI?.setAgentFilesystemSettings) return; - const updated = await electronAPI.setAgentFilesystemSettings({ - mode: "desktop_local_folder", - localRootPaths: [], - }, searchSpaceId); + const updated = await electronAPI.setAgentFilesystemSettings( + { + mode: "desktop_local_folder", + localRootPaths: [], + }, + searchSpaceId + ); setFilesystemSettings(updated); }, [electronAPI, searchSpaceId]); const handleFilesystemTabChange = useCallback( async (tab: "cloud" | "local") => { if (!electronAPI?.setAgentFilesystemSettings) return; - const updated = await electronAPI.setAgentFilesystemSettings({ - mode: tab === "cloud" ? "cloud" : "desktop_local_folder", - }, searchSpaceId); + const updated = await electronAPI.setAgentFilesystemSettings( + { + mode: tab === "cloud" ? "cloud" : "desktop_local_folder", + }, + searchSpaceId + ); setFilesystemSettings(updated); }, [electronAPI, searchSpaceId] @@ -552,7 +565,9 @@ function AuthenticatedDocumentsSidebarBase({ if (!electronAPI) return; const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[]; - const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id); + const matched = watchedFolders.find( + (wf: WatchedFolderEntry) => wf.rootFolderId === folder.id + ); if (!matched) { toast.error("This folder is not being watched"); return; @@ -582,7 +597,9 @@ function AuthenticatedDocumentsSidebarBase({ if (!electronAPI) return; const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[]; - const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id); + const matched = watchedFolders.find( + (wf: WatchedFolderEntry) => wf.rootFolderId === folder.id + ); if (!matched) { toast.error("This folder is not being watched"); return; @@ -1015,7 +1032,8 @@ function AuthenticatedDocumentsSidebarBase({ }, [open, onOpenChange, isMobile, setRightPanelCollapsed]); const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings; - const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud"; + const currentFilesystemTab = + filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud"; const showCloudSkeleton = currentFilesystemTab === "cloud" && (zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"); @@ -1331,8 +1349,8 @@ function AuthenticatedDocumentsSidebarBase({ Trust this workspace? - Local mode can read and edit files inside the folders you select. Continue only if - you trust this workspace and its contents. + Local mode can read and edit files inside the folders you select. Continue only if you + trust this workspace and its contents. {pendingLocalPath && ( diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index 6bfb1d3f1..19c47d605 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -141,7 +141,9 @@ export function LocalFilesystemBrowser({ }: LocalFilesystemBrowserProps) { const electronAPI = useElectronAPI(); const [rootStateMap, setRootStateMap] = useState>({}); - const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState>(new Set()); + const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState>( + new Set() + ); const [mountByRootKey, setMountByRootKey] = useState>(new Map()); const [mountStatus, setMountStatus] = useState("idle"); const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); @@ -188,10 +190,7 @@ export function LocalFilesystemBrowser({ } for (const { rootKey } of rootsToReload) { const nonce = reloadNonceByRoot[rootKey] ?? 0; - lastLoadedSignatureByRootRef.current.set( - rootKey, - `${searchSpaceId}:${rootKey}:${nonce}` - ); + lastLoadedSignatureByRootRef.current.set(rootKey, `${searchSpaceId}:${rootKey}:${nonce}`); } let cancelled = false; @@ -257,35 +256,37 @@ export function LocalFilesystemBrowser({ return; } - const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: { - searchSpaceId: number | null; - reason: "watcher_event" | "safety_poll"; - rootPath: string; - changedPath: string | null; - timestamp: number; - }) => { - if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) { - return; + const unsubscribe = electronAPI.onAgentFilesystemTreeDirty( + (event: { + searchSpaceId: number | null; + reason: "watcher_event" | "safety_poll"; + rootPath: string; + changedPath: string | null; + timestamp: number; + }) => { + if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) { + return; + } + const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform); + const knownRootKeys = new Set( + rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) + ); + if (!knownRootKeys.has(eventRootKey)) { + setReloadNonceByRoot((prev) => { + const next = { ...prev }; + for (const rootKey of knownRootKeys) { + next[rootKey] = (prev[rootKey] ?? 0) + 1; + } + return next; + }); + return; + } + setReloadNonceByRoot((prev) => ({ + ...prev, + [eventRootKey]: (prev[eventRootKey] ?? 0) + 1, + })); } - const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform); - const knownRootKeys = new Set( - rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) - ); - if (!knownRootKeys.has(eventRootKey)) { - setReloadNonceByRoot((prev) => { - const next = { ...prev }; - for (const rootKey of knownRootKeys) { - next[rootKey] = (prev[rootKey] ?? 0) + 1; - } - return next; - }); - return; - } - setReloadNonceByRoot((prev) => ({ - ...prev, - [eventRootKey]: (prev[eventRootKey] ?? 0) + 1, - })); - }); + ); void electronAPI.startAgentFilesystemTreeWatch({ searchSpaceId, rootPaths, @@ -378,22 +379,25 @@ export function LocalFilesystemBrowser({ }); }, [rootPaths, rootStateMap, searchQuery]); - const toggleFolder = useCallback((folderKey: string) => { - const update = (prev: Set) => { - const next = new Set(prev); - if (next.has(folderKey)) { - next.delete(folderKey); - } else { - next.add(folderKey); + const toggleFolder = useCallback( + (folderKey: string) => { + const update = (prev: Set) => { + const next = new Set(prev); + if (next.has(folderKey)) { + next.delete(folderKey); + } else { + next.add(folderKey); + } + return next; + }; + if (onExpandedFolderKeysChange) { + onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys)); + return; } - return next; - }; - if (onExpandedFolderKeysChange) { - onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys)); - return; - } - setInternalExpandedFolderKeys(update); - }, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]); + setInternalExpandedFolderKeys(update); + }, + [effectiveExpandedFolderKeys, onExpandedFolderKeysChange] + ); const renderFolder = useCallback( (folder: LocalFolderNode, depth: number, mount: string) => { @@ -436,9 +440,7 @@ export function LocalFilesystemBrowser({ : undefined } className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${ - isOpenable - ? "hover:bg-muted/60" - : "cursor-not-allowed opacity-60" + isOpenable ? "hover:bg-muted/60" : "cursor-not-allowed opacity-60" }`} style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }} title={ @@ -528,7 +530,10 @@ export function LocalFilesystemBrowser({ } if (state.error) { return ( -
+

Failed to load local folder

{state.error}

diff --git a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx index 77668a93d..ac5463873 100644 --- a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx +++ b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx @@ -308,9 +308,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen } }} > - + Download .md diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index 3f5a5fa8c..9fe9dd8da 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -8,9 +8,9 @@ import { ChevronLeft, ChevronRight, ChevronUp, - Pencil, ImageIcon, Layers, + Pencil, Plus, ScanEye, Search, @@ -741,9 +741,7 @@ export function ModelSelector({
{!isMobile && ( @@ -769,9 +767,7 @@ export function ModelSelector({
diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index ede63d902..621cf13ce 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -398,7 +398,8 @@ export function ReportPanelContent({ ); - const editingActions = showReportEditingTier && + const editingActions = + showReportEditingTier && !isReadOnly && (isEditing ? ( <> diff --git a/surfsense_web/components/settings/agent-model-manager.tsx b/surfsense_web/components/settings/agent-model-manager.tsx index 988befdd0..a0b700c2d 100644 --- a/surfsense_web/components/settings/agent-model-manager.tsx +++ b/surfsense_web/components/settings/agent-model-manager.tsx @@ -1,15 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; -import { - AlertCircle, - Dot, - FileText, - Info, - Pencil, - RefreshCw, - Trash2, -} from "lucide-react"; +import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; diff --git a/surfsense_web/components/settings/roles-manager.tsx b/surfsense_web/components/settings/roles-manager.tsx index e7dadc20f..335cfc8a9 100644 --- a/surfsense_web/components/settings/roles-manager.tsx +++ b/surfsense_web/components/settings/roles-manager.tsx @@ -5,10 +5,8 @@ import { useAtomValue } from "jotai"; import { Bot, ChevronRight, - ScanEye, - Pencil, - FileText, Earth, + FileText, Image, Logs, type LucideIcon, @@ -16,11 +14,13 @@ import { MessageSquare, Mic, MoreHorizontal, - Unplug, + Pencil, + ScanEye, Settings, Shield, SlidersHorizontal, Trash2, + Unplug, Users, Video, } from "lucide-react"; @@ -462,9 +462,19 @@ function RolesContent({ return (
+ {/* biome-ignore lint/a11y/useSemanticElements: row contains nested interactive elements (DropdownMenu); using a