From 2220314041349502767ea5e7bfa144d9828b23d4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 16:05:54 +0200 Subject: [PATCH 001/105] add google drive tool metadata service --- .../app/services/google_drive/__init__.py | 11 ++ .../google_drive/tool_metadata_service.py | 147 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 surfsense_backend/app/services/google_drive/__init__.py create mode 100644 surfsense_backend/app/services/google_drive/tool_metadata_service.py diff --git a/surfsense_backend/app/services/google_drive/__init__.py b/surfsense_backend/app/services/google_drive/__init__.py new file mode 100644 index 000000000..5958a1761 --- /dev/null +++ b/surfsense_backend/app/services/google_drive/__init__.py @@ -0,0 +1,11 @@ +from app.services.google_drive.tool_metadata_service import ( + GoogleDriveAccount, + GoogleDriveFile, + GoogleDriveToolMetadataService, +) + +__all__ = [ + "GoogleDriveAccount", + "GoogleDriveFile", + "GoogleDriveToolMetadataService", +] diff --git a/surfsense_backend/app/services/google_drive/tool_metadata_service.py b/surfsense_backend/app/services/google_drive/tool_metadata_service.py new file mode 100644 index 000000000..d7b0a01c3 --- /dev/null +++ b/surfsense_backend/app/services/google_drive/tool_metadata_service.py @@ -0,0 +1,147 @@ +from dataclasses import dataclass + +from sqlalchemy import and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import ( + Document, + DocumentType, + SearchSourceConnector, + SearchSourceConnectorType, +) + + +@dataclass +class GoogleDriveAccount: + id: int + name: str + + @classmethod + def from_connector(cls, connector: SearchSourceConnector) -> "GoogleDriveAccount": + return cls(id=connector.id, name=connector.name) + + def to_dict(self) -> dict: + return {"id": self.id, "name": self.name} + + +@dataclass +class GoogleDriveFile: + file_id: str + name: str + mime_type: str + web_view_link: str + connector_id: int + document_id: int + + @classmethod + def from_document(cls, document: Document) -> "GoogleDriveFile": + meta = document.document_metadata or {} + return cls( + file_id=meta.get("google_drive_file_id", ""), + name=meta.get("google_drive_file_name", document.title), + mime_type=meta.get("google_drive_mime_type", ""), + web_view_link=meta.get("web_view_link", ""), + connector_id=document.connector_id, + document_id=document.id, + ) + + def to_dict(self) -> dict: + return { + "file_id": self.file_id, + "name": self.name, + "mime_type": self.mime_type, + "web_view_link": self.web_view_link, + "connector_id": self.connector_id, + "document_id": self.document_id, + } + + +class GoogleDriveToolMetadataService: + def __init__(self, db_session: AsyncSession): + self._db_session = db_session + + async def get_creation_context(self, search_space_id: int, user_id: str) -> dict: + accounts = await self._get_google_drive_accounts(search_space_id, user_id) + + if not accounts: + return { + "accounts": [], + "supported_types": [], + "error": "No Google Drive account connected", + } + + return { + "accounts": [acc.to_dict() for acc in accounts], + "supported_types": ["google_doc", "google_sheet"], + } + + async def get_trash_context( + self, search_space_id: int, user_id: str, file_name: str + ) -> dict: + result = await self._db_session.execute( + select(Document) + .join( + SearchSourceConnector, Document.connector_id == SearchSourceConnector.id + ) + .filter( + and_( + Document.search_space_id == search_space_id, + Document.document_type == DocumentType.GOOGLE_DRIVE_FILE, + func.lower(Document.title) == func.lower(file_name), + SearchSourceConnector.user_id == user_id, + ) + ) + ) + document = result.scalars().first() + + if not document: + return { + "error": ( + f"File '{file_name}' not found in your indexed Google Drive files. " + "This could mean: (1) the file doesn't exist, (2) it hasn't been indexed yet, " + "or (3) the file name is different." + ) + } + + if not document.connector_id: + return {"error": "Document has no associated connector"} + + result = await self._db_session.execute( + select(SearchSourceConnector).filter( + and_( + SearchSourceConnector.id == document.connector_id, + SearchSourceConnector.user_id == user_id, + ) + ) + ) + connector = result.scalars().first() + + if not connector: + return {"error": "Connector not found or access denied"} + + account = GoogleDriveAccount.from_connector(connector) + file = GoogleDriveFile.from_document(document) + + return { + "account": account.to_dict(), + "file": file.to_dict(), + } + + async def _get_google_drive_accounts( + self, search_space_id: int, user_id: str + ) -> list[GoogleDriveAccount]: + result = await self._db_session.execute( + select(SearchSourceConnector) + .filter( + and_( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + ) + ) + .order_by(SearchSourceConnector.last_indexed_at.desc()) + ) + connectors = result.scalars().all() + return [GoogleDriveAccount.from_connector(c) for c in connectors] From f1fac7dedc2f32f1f78ddb28c3b999aaf2ab7cf1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 16:25:25 +0200 Subject: [PATCH 002/105] add create_file and trash_file to GoogleDriveClient --- .../app/connectors/google_drive/client.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/surfsense_backend/app/connectors/google_drive/client.py b/surfsense_backend/app/connectors/google_drive/client.py index a001e42be..2910320b2 100644 --- a/surfsense_backend/app/connectors/google_drive/client.py +++ b/surfsense_backend/app/connectors/google_drive/client.py @@ -1,12 +1,15 @@ """Google Drive API client.""" +import io from typing import Any from googleapiclient.discovery import build from googleapiclient.errors import HttpError +from googleapiclient.http import MediaIoBaseUpload from sqlalchemy.ext.asyncio import AsyncSession from .credentials import get_valid_credentials +from .file_types import GOOGLE_DOC, GOOGLE_SHEET class GoogleDriveClient: @@ -179,3 +182,65 @@ class GoogleDriveClient: return None, f"HTTP error exporting file: {e.resp.status}" except Exception as e: return None, f"Error exporting file: {e!s}" + + async def create_file( + self, + name: str, + mime_type: str, + parent_folder_id: str | None = None, + content: str | None = None, + ) -> dict[str, Any]: + service = await self.get_service() + + body: dict[str, Any] = {"name": name, "mimeType": mime_type} + if parent_folder_id: + body["parents"] = [parent_folder_id] + + media: MediaIoBaseUpload | None = None + if content: + if mime_type == GOOGLE_DOC: + import markdown as md_lib + + html = md_lib.markdown(content) + media = MediaIoBaseUpload( + io.BytesIO(html.encode("utf-8")), + mimetype="text/html", + resumable=False, + ) + elif mime_type == GOOGLE_SHEET: + media = MediaIoBaseUpload( + io.BytesIO(content.encode("utf-8")), + mimetype="text/csv", + resumable=False, + ) + + if media: + return ( + service.files() + .create( + body=body, + media_body=media, + fields="id,name,mimeType,webViewLink", + supportsAllDrives=True, + ) + .execute() + ) + + return ( + service.files() + .create( + body=body, + fields="id,name,mimeType,webViewLink", + supportsAllDrives=True, + ) + .execute() + ) + + async def trash_file(self, file_id: str) -> bool: + service = await self.get_service() + service.files().update( + fileId=file_id, + body={"trashed": True}, + supportsAllDrives=True, + ).execute() + return True From ed98af188cb82642cbefdfb1dcd26ee1acc0838e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 16:25:30 +0200 Subject: [PATCH 003/105] add Markdown dependency --- surfsense_backend/pyproject.toml | 1 + surfsense_backend/uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index fe997ee16..d5f08cdf5 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "kokoro>=0.9.4", "linkup-sdk>=0.2.4", "llama-cloud-services>=0.6.25", + "Markdown>=3.7", "markdownify>=0.14.1", "notion-client>=2.3.0", "numpy>=1.24.0", diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index ac2576d74..b8e6e31b2 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -6874,6 +6874,7 @@ dependencies = [ { name = "linkup-sdk" }, { name = "litellm" }, { name = "llama-cloud-services" }, + { name = "markdown" }, { name = "markdownify" }, { name = "mcp" }, { name = "notion-client" }, @@ -6944,6 +6945,7 @@ requires-dist = [ { name = "linkup-sdk", specifier = ">=0.2.4" }, { name = "litellm", specifier = ">=1.80.10" }, { name = "llama-cloud-services", specifier = ">=0.6.25" }, + { name = "markdown", specifier = ">=3.7" }, { name = "markdownify", specifier = ">=0.14.1" }, { name = "mcp", specifier = ">=1.25.0" }, { name = "notion-client", specifier = ">=2.3.0" }, From cd7ebd60d02f51a05ecadfee3aaf9f9e90ce15aa Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 19:21:24 +0200 Subject: [PATCH 004/105] add google drive create/trash agent tools --- .../new_chat/tools/google_drive/__init__.py | 11 + .../tools/google_drive/create_file.py | 207 ++++++++++++++++++ .../new_chat/tools/google_drive/trash_file.py | 163 ++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 surfsense_backend/app/agents/new_chat/tools/google_drive/__init__.py create mode 100644 surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py create mode 100644 surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/__init__.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/__init__.py new file mode 100644 index 000000000..c148c0afb --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/__init__.py @@ -0,0 +1,11 @@ +from app.agents.new_chat.tools.google_drive.create_file import ( + create_create_google_drive_file_tool, +) +from app.agents.new_chat.tools.google_drive.trash_file import ( + create_trash_google_drive_file_tool, +) + +__all__ = [ + "create_create_google_drive_file_tool", + "create_trash_google_drive_file_tool", +] diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py new file mode 100644 index 000000000..0995852a8 --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py @@ -0,0 +1,207 @@ +import logging +from typing import Any, Literal + +from langchain_core.tools import tool +from langgraph.types import interrupt +from sqlalchemy.ext.asyncio import AsyncSession + +from app.connectors.google_drive.client import GoogleDriveClient +from app.connectors.google_drive.file_types import GOOGLE_DOC, GOOGLE_SHEET +from app.services.google_drive import GoogleDriveToolMetadataService + +logger = logging.getLogger(__name__) + +_MIME_MAP: dict[str, str] = { + "google_doc": GOOGLE_DOC, + "google_sheet": GOOGLE_SHEET, +} + + +def create_create_google_drive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def create_google_drive_file( + name: str, + file_type: Literal["google_doc", "google_sheet"], + content: str | None = None, + ) -> dict[str, Any]: + """Create a new Google Doc or Google Sheet in Google Drive. + + Use this tool when the user explicitly asks to create a new document + or spreadsheet in Google Drive. + + Args: + name: The file name (without extension). + file_type: Either "google_doc" or "google_sheet". + content: Optional initial content. For google_doc, provide markdown text. + For google_sheet, provide CSV-formatted text. + + Returns: + Dictionary with: + - status: "success", "rejected", or "error" + - file_id: Google Drive file ID (if success) + - name: File name (if success) + - web_view_link: URL to open the file (if success) + - message: Result message + + IMPORTANT: If status is "rejected", the user explicitly declined the action. + Respond with a brief acknowledgment and do NOT retry or suggest alternatives. + + Examples: + - "Create a Google Doc called 'Meeting Notes'" + - "Create a spreadsheet named 'Budget 2026' with some sample data" + """ + logger.info(f"create_google_drive_file called: name='{name}', type='{file_type}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Drive tool not properly configured. Please contact support.", + } + + if file_type not in _MIME_MAP: + return { + "status": "error", + "message": f"Unsupported file type '{file_type}'. Use 'google_doc' or 'google_sheet'.", + } + + try: + metadata_service = GoogleDriveToolMetadataService(db_session) + context = await metadata_service.get_creation_context(search_space_id, user_id) + + if "error" in context: + logger.error(f"Failed to fetch creation context: {context['error']}") + return {"status": "error", "message": context["error"]} + + approval = interrupt( + { + "type": "google_drive_file_creation", + "action": { + "tool": "create_google_drive_file", + "params": { + "name": name, + "file_type": file_type, + "content": content, + "connector_id": None, + "parent_folder_id": None, + }, + }, + "context": context, + } + ) + + decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] + decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw] + decisions = [d for d in decisions if isinstance(d, dict)] + if not decisions: + return {"status": "error", "message": "No approval decision received"} + + decision = decisions[0] + decision_type = decision.get("type") or decision.get("decision_type") + logger.info(f"User decision: {decision_type}") + + if decision_type == "reject": + return { + "status": "rejected", + "message": "User declined. The file was not created. Do not ask again or suggest alternatives.", + } + + final_params: dict[str, Any] = {} + edited_action = decision.get("edited_action") + if isinstance(edited_action, dict): + edited_args = edited_action.get("args") + if isinstance(edited_args, dict): + final_params = edited_args + elif isinstance(decision.get("args"), dict): + final_params = decision["args"] + + final_name = final_params.get("name", name) + final_file_type = final_params.get("file_type", file_type) + final_content = final_params.get("content", content) + final_connector_id = final_params.get("connector_id") + final_parent_folder_id = final_params.get("parent_folder_id") + + if not final_name or not final_name.strip(): + return {"status": "error", "message": "File name cannot be empty."} + + mime_type = _MIME_MAP.get(final_file_type) + if not mime_type: + return { + "status": "error", + "message": f"Unsupported file type '{final_file_type}'.", + } + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + if final_connector_id is not None: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Drive connector is invalid or has been disconnected.", + } + actual_connector_id = connector.id + else: + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "No Google Drive connector found. Please connect Google Drive in your workspace settings.", + } + actual_connector_id = connector.id + + logger.info( + f"Creating Google Drive file: name='{final_name}', type='{final_file_type}', connector={actual_connector_id}" + ) + client = GoogleDriveClient(session=db_session, connector_id=actual_connector_id) + created = await client.create_file( + name=final_name, + mime_type=mime_type, + parent_folder_id=final_parent_folder_id, + content=final_content, + ) + + logger.info(f"Google Drive file created: id={created.get('id')}, name={created.get('name')}") + return { + "status": "success", + "file_id": created.get("id"), + "name": created.get("name"), + "web_view_link": created.get("webViewLink"), + "message": f"Successfully created '{created.get('name')}' in Google Drive.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error creating Google Drive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while creating the file. Please try again.", + } + + return create_google_drive_file diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py new file mode 100644 index 000000000..376fdab6c --- /dev/null +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py @@ -0,0 +1,163 @@ +import logging +from typing import Any + +from langchain_core.tools import tool +from langgraph.types import interrupt +from sqlalchemy.ext.asyncio import AsyncSession + +from app.connectors.google_drive.client import GoogleDriveClient +from app.services.google_drive import GoogleDriveToolMetadataService + +logger = logging.getLogger(__name__) + + +def create_trash_google_drive_file_tool( + db_session: AsyncSession | None = None, + search_space_id: int | None = None, + user_id: str | None = None, +): + @tool + async def trash_google_drive_file( + file_name: str, + ) -> dict[str, Any]: + """Move a Google Drive file to trash. + + Use this tool when the user explicitly asks to delete, remove, or trash + a file in Google Drive. + + Args: + file_name: The exact name of the file to trash (as it appears in Drive). + + Returns: + Dictionary with: + - status: "success", "rejected", "not_found", or "error" + - file_id: Google Drive file ID (if success) + - message: Result message + + IMPORTANT: + - If status is "rejected", the user explicitly declined. Respond with a brief + acknowledgment and do NOT retry or suggest alternatives. + - If status is "not_found", relay the exact message to the user and ask them + to verify the file name or check if it has been indexed. + + Examples: + - "Delete the 'Meeting Notes' file from Google Drive" + - "Trash the 'Old Budget' spreadsheet" + """ + logger.info(f"trash_google_drive_file called: file_name='{file_name}'") + + if db_session is None or search_space_id is None or user_id is None: + return { + "status": "error", + "message": "Google Drive tool not properly configured. Please contact support.", + } + + try: + metadata_service = GoogleDriveToolMetadataService(db_session) + context = await metadata_service.get_trash_context( + search_space_id, user_id, file_name + ) + + if "error" in context: + error_msg = context["error"] + if "not found" in error_msg.lower(): + logger.warning(f"File not found: {error_msg}") + return {"status": "not_found", "message": error_msg} + logger.error(f"Failed to fetch trash context: {error_msg}") + return {"status": "error", "message": error_msg} + + file = context["file"] + file_id = file["file_id"] + connector_id_from_context = context["account"]["id"] + + approval = interrupt( + { + "type": "google_drive_file_trash", + "action": { + "tool": "trash_google_drive_file", + "params": { + "file_id": file_id, + "connector_id": connector_id_from_context, + }, + }, + "context": context, + } + ) + + decisions_raw = approval.get("decisions", []) if isinstance(approval, dict) else [] + decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw] + decisions = [d for d in decisions if isinstance(d, dict)] + if not decisions: + return {"status": "error", "message": "No approval decision received"} + + decision = decisions[0] + decision_type = decision.get("type") or decision.get("decision_type") + logger.info(f"User decision: {decision_type}") + + if decision_type == "reject": + return { + "status": "rejected", + "message": "User declined. The file was not trashed. Do not ask again or suggest alternatives.", + } + + edited_action = decision.get("edited_action") + final_params: dict[str, Any] = {} + if isinstance(edited_action, dict): + edited_args = edited_action.get("args") + if isinstance(edited_args, dict): + final_params = edited_args + elif isinstance(decision.get("args"), dict): + final_params = decision["args"] + + final_file_id = final_params.get("file_id", file_id) + final_connector_id = final_params.get("connector_id", connector_id_from_context) + + if not final_connector_id: + return {"status": "error", "message": "No connector found for this file."} + + from sqlalchemy.future import select + + from app.db import SearchSourceConnector, SearchSourceConnectorType + + result = await db_session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == final_connector_id, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + return { + "status": "error", + "message": "Selected Google Drive connector is invalid or has been disconnected.", + } + + logger.info( + f"Trashing Google Drive file: file_id='{final_file_id}', connector={final_connector_id}" + ) + client = GoogleDriveClient(session=db_session, connector_id=connector.id) + await client.trash_file(file_id=final_file_id) + + logger.info(f"Google Drive file trashed: file_id={final_file_id}") + return { + "status": "success", + "file_id": final_file_id, + "message": f"Successfully moved '{file['name']}' to trash.", + } + + except Exception as e: + from langgraph.errors import GraphInterrupt + + if isinstance(e, GraphInterrupt): + raise + + logger.error(f"Error trashing Google Drive file: {e}", exc_info=True) + return { + "status": "error", + "message": "Something went wrong while trashing the file. Please try again.", + } + + return trash_google_drive_file From 9abe136646c81427c4268da6a39f027bc145e8b7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 19:22:22 +0200 Subject: [PATCH 005/105] register google drive tools in registry --- .../app/agents/new_chat/tools/registry.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/surfsense_backend/app/agents/new_chat/tools/registry.py b/surfsense_backend/app/agents/new_chat/tools/registry.py index 4c6345bc3..f2557eec0 100644 --- a/surfsense_backend/app/agents/new_chat/tools/registry.py +++ b/surfsense_backend/app/agents/new_chat/tools/registry.py @@ -55,6 +55,10 @@ from .linear import ( ) from .link_preview import create_link_preview_tool from .mcp_tool import load_mcp_tools +from .google_drive import ( + create_create_google_drive_file_tool, + create_trash_google_drive_file_tool, +) from .notion import ( create_create_notion_page_tool, create_delete_notion_page_tool, @@ -292,6 +296,29 @@ BUILTIN_TOOLS: list[ToolDefinition] = [ ), requires=["db_session", "search_space_id", "user_id"], ), + # ========================================================================= + # GOOGLE DRIVE TOOLS - create files, trash files + # ========================================================================= + ToolDefinition( + name="create_google_drive_file", + description="Create a new Google Doc or Google Sheet in Google Drive", + factory=lambda deps: create_create_google_drive_file_tool( + db_session=deps["db_session"], + search_space_id=deps["search_space_id"], + user_id=deps["user_id"], + ), + requires=["db_session", "search_space_id", "user_id"], + ), + ToolDefinition( + name="trash_google_drive_file", + description="Move an indexed Google Drive file to trash", + factory=lambda deps: create_trash_google_drive_file_tool( + db_session=deps["db_session"], + search_space_id=deps["search_space_id"], + user_id=deps["user_id"], + ), + requires=["db_session", "search_space_id", "user_id"], + ), ] From 856d2583181952e9020b526843cd5f0057777bc2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 19:41:29 +0200 Subject: [PATCH 006/105] fix bugs and pattern inconsistencies in google drive tools --- .../agents/new_chat/tools/google_drive/create_file.py | 4 ++++ .../agents/new_chat/tools/google_drive/trash_file.py | 10 ++++++++++ .../app/services/google_drive/tool_metadata_service.py | 2 ++ 3 files changed, 16 insertions(+) diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py index 0995852a8..df7c103f1 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py @@ -76,6 +76,9 @@ def create_create_google_drive_file_tool( logger.error(f"Failed to fetch creation context: {context['error']}") return {"status": "error", "message": context["error"]} + logger.info( + f"Requesting approval for creating Google Drive file: name='{name}', type='{file_type}'" + ) approval = interrupt( { "type": "google_drive_file_creation", @@ -97,6 +100,7 @@ def create_create_google_drive_file_tool( decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw] decisions = [d for d in decisions if isinstance(d, dict)] if not decisions: + logger.warning("No approval decision received") return {"status": "error", "message": "No approval decision received"} decision = decisions[0] diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py index 376fdab6c..a6ce43eb2 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py @@ -70,6 +70,15 @@ def create_trash_google_drive_file_tool( file_id = file["file_id"] connector_id_from_context = context["account"]["id"] + if not file_id: + return { + "status": "error", + "message": "File ID is missing from the indexed document. Please re-index the file and try again.", + } + + logger.info( + f"Requesting approval for trashing Google Drive file: '{file_name}' (file_id={file_id})" + ) approval = interrupt( { "type": "google_drive_file_trash", @@ -88,6 +97,7 @@ def create_trash_google_drive_file_tool( decisions = decisions_raw if isinstance(decisions_raw, list) else [decisions_raw] decisions = [d for d in decisions if isinstance(d, dict)] if not decisions: + logger.warning("No approval decision received") return {"status": "error", "message": "No approval decision received"} decision = decisions[0] diff --git a/surfsense_backend/app/services/google_drive/tool_metadata_service.py b/surfsense_backend/app/services/google_drive/tool_metadata_service.py index d7b0a01c3..8bc60ecbc 100644 --- a/surfsense_backend/app/services/google_drive/tool_metadata_service.py +++ b/surfsense_backend/app/services/google_drive/tool_metadata_service.py @@ -112,6 +112,8 @@ class GoogleDriveToolMetadataService: and_( SearchSourceConnector.id == document.connector_id, SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, ) ) ) From 7d257789275ac9c70579536d3bad29e00db508c0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Feb 2026 20:40:30 +0200 Subject: [PATCH 007/105] add create google drive file tool UI component --- .../new-chat/[[...chat_id]]/page.tsx | 2 + .../tool-ui/google-drive/create-file.tsx | 490 ++++++++++++++++++ .../components/tool-ui/google-drive/index.ts | 1 + surfsense_web/components/tool-ui/index.ts | 1 + 4 files changed, 494 insertions(+) create mode 100644 surfsense_web/components/tool-ui/google-drive/create-file.tsx create mode 100644 surfsense_web/components/tool-ui/google-drive/index.ts 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 dd11382a8..15656813d 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 @@ -38,6 +38,7 @@ import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GenerateReportToolUI } from "@/components/tool-ui/generate-report"; +import { CreateGoogleDriveFileToolUI } from "@/components/tool-ui/google-drive"; import { CreateLinearIssueToolUI, DeleteLinearIssueToolUI, @@ -1664,6 +1665,7 @@ export default function NewChatPage() { + {/* Disabled for now */}
diff --git a/surfsense_web/components/tool-ui/google-drive/create-file.tsx b/surfsense_web/components/tool-ui/google-drive/create-file.tsx new file mode 100644 index 000000000..cf0a01319 --- /dev/null +++ b/surfsense_web/components/tool-ui/google-drive/create-file.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { makeAssistantToolUI } from "@assistant-ui/react"; +import { + AlertTriangleIcon, + CheckIcon, + FileIcon, + Loader2Icon, + PencilIcon, + XIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +interface GoogleDriveAccount { + id: number; + name: string; +} + +interface InterruptResult { + __interrupt__: true; + __decided__?: "approve" | "reject" | "edit"; + action_requests: Array<{ + name: string; + args: Record; + }>; + review_configs: Array<{ + action_name: string; + allowed_decisions: Array<"approve" | "edit" | "reject">; + }>; + context?: { + accounts?: GoogleDriveAccount[]; + supported_types?: string[]; + error?: string; + }; +} + +interface SuccessResult { + status: "success"; + file_id: string; + name: string; + web_view_link?: string; + message?: string; +} + +interface ErrorResult { + status: "error"; + message: string; +} + +type CreateGoogleDriveFileResult = InterruptResult | SuccessResult | ErrorResult; + +function isInterruptResult(result: unknown): result is InterruptResult { + return ( + typeof result === "object" && + result !== null && + "__interrupt__" in result && + (result as InterruptResult).__interrupt__ === true + ); +} + +function isErrorResult(result: unknown): result is ErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as ErrorResult).status === "error" + ); +} + +const FILE_TYPE_LABELS: Record = { + google_doc: "Google Doc", + google_sheet: "Google Sheet", +}; + +function ApprovalCard({ + args, + interruptData, + onDecision, +}: { + args: { name: string; file_type: string; content?: string }; + interruptData: InterruptResult; + onDecision: (decision: { + type: "approve" | "reject" | "edit"; + message?: string; + edited_action?: { name: string; args: Record }; + }) => void; +}) { + const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( + interruptData.__decided__ ?? null + ); + const [isEditing, setIsEditing] = useState(false); + const [editedName, setEditedName] = useState(args.name ?? ""); + const [editedContent, setEditedContent] = useState(args.content ?? ""); + + const accounts = interruptData.context?.accounts ?? []; + + const defaultAccountId = useMemo(() => { + if (accounts.length === 1) return String(accounts[0].id); + return ""; + }, [accounts]); + + const [selectedAccountId, setSelectedAccountId] = useState(defaultAccountId); + const [selectedFileType, setSelectedFileType] = useState(args.file_type ?? "google_doc"); + const [parentFolderId, setParentFolderId] = useState(""); + + const isNameValid = useMemo( + () => (isEditing ? editedName.trim().length > 0 : args.name?.trim().length > 0), + [isEditing, editedName, args.name] + ); + + const canApprove = !!selectedAccountId && isNameValid; + + const reviewConfig = interruptData.review_configs[0]; + const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; + const canEdit = allowedDecisions.includes("edit"); + + function buildFinalArgs() { + return { + name: isEditing ? editedName : args.name, + file_type: selectedFileType, + content: isEditing ? editedContent || null : (args.content ?? null), + connector_id: selectedAccountId ? Number(selectedAccountId) : null, + parent_folder_id: parentFolderId.trim() || null, + }; + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Create Google Drive File

+

+ {isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"} +

+
+
+ + {/* Context section */} + {!decided && interruptData.context && ( +
+ {interruptData.context.error ? ( +

{interruptData.context.error}

+ ) : ( + <> + {accounts.length > 0 && ( +
+
+ Google Drive Account * +
+ +
+ )} + +
+
+ File Type * +
+ +
+ +
+
+ Parent Folder ID (optional) +
+ setParentFolderId(e.target.value)} + placeholder="Leave blank to create at Drive root" + /> +

+ Paste a Google Drive folder ID to place the file in a specific folder. +

+
+ + )} +
+ )} + + {/* Display mode */} + {!isEditing && ( +
+
+

Name

+

{args.name}

+
+
+

Type

+

+ {FILE_TYPE_LABELS[args.file_type] ?? args.file_type} +

+
+ {args.content && ( +
+

Content

+

+ {args.content} +

+
+ )} +
+ )} + + {/* Edit mode */} + {isEditing && !decided && ( +
+
+ + setEditedName(e.target.value)} + placeholder="Enter file name" + className={!isNameValid ? "border-destructive" : ""} + /> + {!isNameValid &&

Name is required

} +
+
+ +