diff --git a/poetry.lock b/poetry.lock index 17c3ef149542..2a575b0b5eb2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6101,6 +6101,7 @@ description = "Nvidia JIT LTO Library" optional = true python-versions = ">=3" files = [ + {file = "nvidia_nvjitlink_cu12-12.6.20-py3-none-manylinux2014_aarch64.whl", hash = "sha256:84fb38465a5bc7c70cbc320cfd0963eb302ee25a5e939e9f512bbba55b6072fb"}, {file = "nvidia_nvjitlink_cu12-12.6.20-py3-none-manylinux2014_x86_64.whl", hash = "sha256:562ab97ea2c23164823b2a89cb328d01d45cb99634b8c65fe7cd60d14562bd79"}, {file = "nvidia_nvjitlink_cu12-12.6.20-py3-none-win_amd64.whl", hash = "sha256:ed3c43a17f37b0c922a919203d2d36cbef24d41cc3e6b625182f8b58203644f6"}, ] @@ -10259,6 +10260,17 @@ files = [ {file = "types_google_cloud_ndb-2.3.0.20240813-py3-none-any.whl", hash = "sha256:79404e04e97324d0b6466f297e92e734a38fb9cd064c2f3816820311bc6c3f57"}, ] +[[package]] +name = "types-markdown" +version = "3.7.0.20240822" +description = "Typing stubs for Markdown" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-Markdown-3.7.0.20240822.tar.gz", hash = "sha256:183557c9f4f865bdefd8f5f96a38145c31819271cde111d35557c3bd2069e78d"}, + {file = "types_Markdown-3.7.0.20240822-py3-none-any.whl", hash = "sha256:bec91c410aaf2470ffdb103e38438fbcc53689b00133f19e64869eb138432ad7"}, +] + [[package]] name = "types-passlib" version = "1.7.7.20240327" @@ -11560,4 +11572,4 @@ local = ["ctransformers", "llama-cpp-python", "sentence-transformers"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "3bdfc3e3b86f7e417c34972e5e2251d079602df87650bdc6d6b56d846dbc8a48" +content-hash = "51dc3a97f0153a6e8a6810bfea823b200a9fc6565b2103b8cafc29c937629e0a" diff --git a/pyproject.toml b/pyproject.toml index 58003e08b616..e53c4bcdb438 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ vulture = "^2.11" dictdiffer = "^0.9.0" pytest-split = "^0.9.0" pytest-flakefinder = "^1.1.0" +types-markdown = "^3.7.0.20240822" [tool.poetry.extras] deploy = ["celery", "redis", "flower"] diff --git a/src/backend/base/langflow/components/Notion/__init__.py b/src/backend/base/langflow/components/Notion/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/backend/base/langflow/components/Notion/add_content_to_page.py b/src/backend/base/langflow/components/Notion/add_content_to_page.py new file mode 100644 index 000000000000..c567eecde520 --- /dev/null +++ b/src/backend/base/langflow/components/Notion/add_content_to_page.py @@ -0,0 +1,268 @@ +import json +from typing import Dict, Any, Union +from markdown import markdown +from bs4 import BeautifulSoup +import requests + +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput, MultilineInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool +from pydantic import BaseModel, Field + + +class AddContentToPage(LCToolComponent): + display_name: str = "Add Content to Page " + description: str = "Convert markdown text to Notion blocks and append them to a Notion page." + documentation: str = "https://developers.notion.com/reference/patch-block-children" + icon = "NotionDirectoryLoader" + + inputs = [ + MultilineInput( + name="markdown_text", + display_name="Markdown Text", + info="The markdown text to convert to Notion blocks.", + ), + StrInput( + name="block_id", + display_name="Page/Block ID", + info="The ID of the page/block to add the content.", + ), + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + ] + + class AddContentToPageSchema(BaseModel): + markdown_text: str = Field(..., description="The markdown text to convert to Notion blocks.") + block_id: str = Field(..., description="The ID of the page/block to add the content.") + + def run_model(self) -> Data: + result = self._add_content_to_page(self.markdown_text, self.block_id) + return Data(data=result, text=json.dumps(result)) + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="add_content_to_notion_page", + description="Convert markdown text to Notion blocks and append them to a Notion page.", + func=self._add_content_to_page, + args_schema=self.AddContentToPageSchema, + ) + + def _add_content_to_page(self, markdown_text: str, block_id: str) -> Union[Dict[str, Any], str]: + try: + html_text = markdown(markdown_text) + soup = BeautifulSoup(html_text, "html.parser") + blocks = self.process_node(soup) + + url = f"https://api.notion.com/v1/blocks/{block_id}/children" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + } + + data = { + "children": blocks, + } + + response = requests.patch(url, headers=headers, json=data) + response.raise_for_status() + + return response.json() + except requests.exceptions.RequestException as e: + error_message = f"Error: Failed to add content to Notion page. {str(e)}" + if hasattr(e, "response") and e.response is not None: + error_message += f" Status code: {e.response.status_code}, Response: {e.response.text}" + return error_message + except Exception as e: + return f"Error: An unexpected error occurred while adding content to Notion page. {str(e)}" + + def process_node(self, node): + blocks = [] + if isinstance(node, str): + text = node.strip() + if text: + if text.startswith("#"): + heading_level = text.count("#", 0, 6) + heading_text = text[heading_level:].strip() + if heading_level == 1: + blocks.append(self.create_block("heading_1", heading_text)) + elif heading_level == 2: + blocks.append(self.create_block("heading_2", heading_text)) + elif heading_level == 3: + blocks.append(self.create_block("heading_3", heading_text)) + else: + blocks.append(self.create_block("paragraph", text)) + elif node.name == "h1": + blocks.append(self.create_block("heading_1", node.get_text(strip=True))) + elif node.name == "h2": + blocks.append(self.create_block("heading_2", node.get_text(strip=True))) + elif node.name == "h3": + blocks.append(self.create_block("heading_3", node.get_text(strip=True))) + elif node.name == "p": + code_node = node.find("code") + if code_node: + code_text = code_node.get_text() + language, code = self.extract_language_and_code(code_text) + blocks.append(self.create_block("code", code, language=language)) + elif self.is_table(str(node)): + blocks.extend(self.process_table(node)) + else: + blocks.append(self.create_block("paragraph", node.get_text(strip=True))) + elif node.name == "ul": + blocks.extend(self.process_list(node, "bulleted_list_item")) + elif node.name == "ol": + blocks.extend(self.process_list(node, "numbered_list_item")) + elif node.name == "blockquote": + blocks.append(self.create_block("quote", node.get_text(strip=True))) + elif node.name == "hr": + blocks.append(self.create_block("divider", "")) + elif node.name == "img": + blocks.append(self.create_block("image", "", image_url=node.get("src"))) + elif node.name == "a": + blocks.append(self.create_block("bookmark", node.get_text(strip=True), link_url=node.get("href"))) + elif node.name == "table": + blocks.extend(self.process_table(node)) + + for child in node.children: + if isinstance(child, str): + continue + blocks.extend(self.process_node(child)) + + return blocks + + def extract_language_and_code(self, code_text): + lines = code_text.split("\n") + language = lines[0].strip() + code = "\n".join(lines[1:]).strip() + return language, code + + def is_code_block(self, text): + return text.startswith("```") + + def extract_code_block(self, text): + lines = text.split("\n") + language = lines[0].strip("`").strip() + code = "\n".join(lines[1:]).strip("`").strip() + return language, code + + def is_table(self, text): + rows = text.split("\n") + if len(rows) < 2: + return False + + has_separator = False + for i, row in enumerate(rows): + if "|" in row: + cells = [cell.strip() for cell in row.split("|")] + cells = [cell for cell in cells if cell] # Remove empty cells + if i == 1 and all(set(cell) <= set("-|") for cell in cells): + has_separator = True + elif not cells: + return False + + return has_separator and len(rows) >= 3 + + def process_list(self, node, list_type): + blocks = [] + for item in node.find_all("li"): + item_text = item.get_text(strip=True) + checked = item_text.startswith("[x]") + is_checklist = item_text.startswith("[ ]") or checked + + if is_checklist: + item_text = item_text.replace("[x]", "").replace("[ ]", "").strip() + blocks.append(self.create_block("to_do", item_text, checked=checked)) + else: + blocks.append(self.create_block(list_type, item_text)) + return blocks + + def process_table(self, node): + blocks = [] + header_row = node.find("thead").find("tr") if node.find("thead") else None + body_rows = node.find("tbody").find_all("tr") if node.find("tbody") else [] + + if header_row or body_rows: + table_width = max( + len(header_row.find_all(["th", "td"])) if header_row else 0, + max(len(row.find_all(["th", "td"])) for row in body_rows), + ) + + table_block = self.create_block("table", "", table_width=table_width, has_column_header=bool(header_row)) + blocks.append(table_block) + + if header_row: + header_cells = [cell.get_text(strip=True) for cell in header_row.find_all(["th", "td"])] + header_row_block = self.create_block("table_row", header_cells) + blocks.append(header_row_block) + + for row in body_rows: + cells = [cell.get_text(strip=True) for cell in row.find_all(["th", "td"])] + row_block = self.create_block("table_row", cells) + blocks.append(row_block) + + return blocks + + def create_block(self, block_type: str, content: str, **kwargs) -> Dict[str, Any]: + block: dict[str, Any] = { + "object": "block", + "type": block_type, + block_type: {}, + } + + if block_type in [ + "paragraph", + "heading_1", + "heading_2", + "heading_3", + "bulleted_list_item", + "numbered_list_item", + "quote", + ]: + block[block_type]["rich_text"] = [ + { + "type": "text", + "text": { + "content": content, + }, + } + ] + elif block_type == "to_do": + block[block_type]["rich_text"] = [ + { + "type": "text", + "text": { + "content": content, + }, + } + ] + block[block_type]["checked"] = kwargs.get("checked", False) + elif block_type == "code": + block[block_type]["rich_text"] = [ + { + "type": "text", + "text": { + "content": content, + }, + } + ] + block[block_type]["language"] = kwargs.get("language", "plain text") + elif block_type == "image": + block[block_type] = {"type": "external", "external": {"url": kwargs.get("image_url", "")}} + elif block_type == "divider": + pass + elif block_type == "bookmark": + block[block_type]["url"] = kwargs.get("link_url", "") + elif block_type == "table": + block[block_type]["table_width"] = kwargs.get("table_width", 0) + block[block_type]["has_column_header"] = kwargs.get("has_column_header", False) + block[block_type]["has_row_header"] = kwargs.get("has_row_header", False) + elif block_type == "table_row": + block[block_type]["cells"] = [[{"type": "text", "text": {"content": cell}} for cell in content]] + + return block diff --git a/src/backend/base/langflow/components/Notion/create_page.py b/src/backend/base/langflow/components/Notion/create_page.py new file mode 100644 index 000000000000..d34b87c06c1a --- /dev/null +++ b/src/backend/base/langflow/components/Notion/create_page.py @@ -0,0 +1,93 @@ +import json +from typing import Dict, Any, Union +import requests +from pydantic import BaseModel, Field +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput, MultilineInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool + + +class NotionPageCreator(LCToolComponent): + display_name: str = "Create Page " + description: str = "A component for creating Notion pages." + documentation: str = "https://docs.langflow.org/integrations/notion/page-create" + icon = "NotionDirectoryLoader" + + inputs = [ + StrInput( + name="database_id", + display_name="Database ID", + info="The ID of the Notion database.", + ), + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + MultilineInput( + name="properties_json", + display_name="Properties (JSON)", + info="The properties of the new page as a JSON string.", + ), + ] + + class NotionPageCreatorSchema(BaseModel): + database_id: str = Field(..., description="The ID of the Notion database.") + properties_json: str = Field(..., description="The properties of the new page as a JSON string.") + + def run_model(self) -> Data: + result = self._create_notion_page(self.database_id, self.properties_json) + if isinstance(result, str): + # An error occurred, return it as text + return Data(text=result) + else: + # Success, return the created page data + output = "Created page properties:\n" + for prop_name, prop_value in result.get("properties", {}).items(): + output += f"{prop_name}: {prop_value}\n" + return Data(text=output, data=result) + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="create_notion_page", + description="Create a new page in a Notion database. IMPORTANT: Use the tool to check the Database properties for more details before using this tool.", + func=self._create_notion_page, + args_schema=self.NotionPageCreatorSchema, + ) + + def _create_notion_page(self, database_id: str, properties_json: str) -> Union[Dict[str, Any], str]: + if not database_id or not properties_json: + return "Invalid input. Please provide 'database_id' and 'properties_json'." + + try: + properties = json.loads(properties_json) + except json.JSONDecodeError as e: + return f"Invalid properties format. Please provide a valid JSON string. Error: {str(e)}" + + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + } + + data = { + "parent": {"database_id": database_id}, + "properties": properties, + } + + try: + response = requests.post("https://api.notion.com/v1/pages", headers=headers, json=data) + response.raise_for_status() + result = response.json() + return result + except requests.exceptions.RequestException as e: + error_message = f"Failed to create Notion page. Error: {str(e)}" + if hasattr(e, "response") and e.response is not None: + error_message += f" Status code: {e.response.status_code}, Response: {e.response.text}" + return error_message + + def __call__(self, *args, **kwargs): + return self._create_notion_page(*args, **kwargs) diff --git a/src/backend/base/langflow/components/Notion/list_database_properties.py b/src/backend/base/langflow/components/Notion/list_database_properties.py new file mode 100644 index 000000000000..a7b2d3293503 --- /dev/null +++ b/src/backend/base/langflow/components/Notion/list_database_properties.py @@ -0,0 +1,68 @@ +import requests +from typing import Dict, Union +from pydantic import BaseModel, Field +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool + + +class NotionDatabaseProperties(LCToolComponent): + display_name: str = "List Database Properties " + description: str = "Retrieve properties of a Notion database." + documentation: str = "https://docs.langflow.org/integrations/notion/list-database-properties" + icon = "NotionDirectoryLoader" + + inputs = [ + StrInput( + name="database_id", + display_name="Database ID", + info="The ID of the Notion database.", + ), + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + ] + + class NotionDatabasePropertiesSchema(BaseModel): + database_id: str = Field(..., description="The ID of the Notion database.") + + def run_model(self) -> Data: + result = self._fetch_database_properties(self.database_id) + if isinstance(result, str): + # An error occurred, return it as text + return Data(text=result) + else: + # Success, return the properties + return Data(text=str(result), data=result) + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="notion_database_properties", + description="Retrieve properties of a Notion database. Input should include the database ID.", + func=self._fetch_database_properties, + args_schema=self.NotionDatabasePropertiesSchema, + ) + + def _fetch_database_properties(self, database_id: str) -> Union[Dict, str]: + url = f"https://api.notion.com/v1/databases/{database_id}" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Notion-Version": "2022-06-28", # Use the latest supported version + } + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + data = response.json() + properties = data.get("properties", {}) + return properties + except requests.exceptions.RequestException as e: + return f"Error fetching Notion database properties: {str(e)}" + except ValueError as e: + return f"Error parsing Notion API response: {str(e)}" + except Exception as e: + return f"An unexpected error occurred: {str(e)}" diff --git a/src/backend/base/langflow/components/Notion/list_pages.py b/src/backend/base/langflow/components/Notion/list_pages.py new file mode 100644 index 000000000000..ffec829c5637 --- /dev/null +++ b/src/backend/base/langflow/components/Notion/list_pages.py @@ -0,0 +1,116 @@ +import requests +import json +from typing import Dict, Any, List, Optional +from pydantic import BaseModel, Field + +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput, MultilineInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool + + +class NotionListPages(LCToolComponent): + display_name: str = "List Pages " + description: str = ( + "Query a Notion database with filtering and sorting. " + "The input should be a JSON string containing the 'filter' and 'sorts' objects. " + "Example input:\n" + '{"filter": {"property": "Status", "select": {"equals": "Done"}}, "sorts": [{"timestamp": "created_time", "direction": "descending"}]}' + ) + documentation: str = "https://docs.langflow.org/integrations/notion/list-pages" + icon = "NotionDirectoryLoader" + + inputs = [ + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + StrInput( + name="database_id", + display_name="Database ID", + info="The ID of the Notion database to query.", + ), + MultilineInput( + name="query_json", + display_name="Database query (JSON)", + info="A JSON string containing the filters and sorts that will be used for querying the database. Leave empty for no filters or sorts.", + ), + ] + + class NotionListPagesSchema(BaseModel): + database_id: str = Field(..., description="The ID of the Notion database to query.") + query_json: Optional[str] = Field( + default="", + description="A JSON string containing the filters and sorts for querying the database. Leave empty for no filters or sorts.", + ) + + def run_model(self) -> List[Data]: + result = self._query_notion_database(self.database_id, self.query_json) + + if isinstance(result, str): + # An error occurred, return it as a single record + return [Data(text=result)] + + records = [] + combined_text = f"Pages found: {len(result)}\n\n" + + for page in result: + page_data = { + "id": page["id"], + "url": page["url"], + "created_time": page["created_time"], + "last_edited_time": page["last_edited_time"], + "properties": page["properties"], + } + + text = ( + f"id: {page['id']}\n" + f"url: {page['url']}\n" + f"created_time: {page['created_time']}\n" + f"last_edited_time: {page['last_edited_time']}\n" + f"properties: {json.dumps(page['properties'], indent=2)}\n\n" + ) + + combined_text += text + records.append(Data(text=text, **page_data)) + + self.status = records + return records + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="notion_list_pages", + description=self.description, + func=self._query_notion_database, + args_schema=self.NotionListPagesSchema, + ) + + def _query_notion_database(self, database_id: str, query_json: Optional[str] = None) -> List[Dict[str, Any]] | str: + url = f"https://api.notion.com/v1/databases/{database_id}/query" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + } + + query_payload = {} + if query_json and query_json.strip(): + try: + query_payload = json.loads(query_json) + except json.JSONDecodeError as e: + return f"Invalid JSON format for query: {str(e)}" + + try: + response = requests.post(url, headers=headers, json=query_payload) + response.raise_for_status() + results = response.json() + return results["results"] + except requests.exceptions.RequestException as e: + return f"Error querying Notion database: {str(e)}" + except KeyError: + return "Unexpected response format from Notion API" + except Exception as e: + return f"An unexpected error occurred: {str(e)}" diff --git a/src/backend/base/langflow/components/Notion/list_users.py b/src/backend/base/langflow/components/Notion/list_users.py new file mode 100644 index 000000000000..1134f554227a --- /dev/null +++ b/src/backend/base/langflow/components/Notion/list_users.py @@ -0,0 +1,78 @@ +import requests +from typing import List, Dict +from pydantic import BaseModel + +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool + + +class NotionUserList(LCToolComponent): + display_name = "List Users " + description = "Retrieve users from Notion." + documentation = "https://docs.langflow.org/integrations/notion/list-users" + icon = "NotionDirectoryLoader" + + inputs = [ + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + ] + + class NotionUserListSchema(BaseModel): + pass + + def run_model(self) -> List[Data]: + users = self._list_users() + records = [] + combined_text = "" + + for user in users: + output = "User:\n" + for key, value in user.items(): + output += f"{key.replace('_', ' ').title()}: {value}\n" + output += "________________________\n" + + combined_text += output + records.append(Data(text=output, data=user)) + + self.status = records + return records + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="notion_list_users", + description="Retrieve users from Notion.", + func=self._list_users, + args_schema=self.NotionUserListSchema, + ) + + def _list_users(self) -> List[Dict]: + url = "https://api.notion.com/v1/users" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Notion-Version": "2022-06-28", + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + results = data["results"] + + users = [] + for user in results: + user_data = { + "id": user["id"], + "type": user["type"], + "name": user.get("name", ""), + "avatar_url": user.get("avatar_url", ""), + } + users.append(user_data) + + return users diff --git a/src/backend/base/langflow/components/Notion/page_content_viewer.py b/src/backend/base/langflow/components/Notion/page_content_viewer.py new file mode 100644 index 000000000000..71b21d8370e5 --- /dev/null +++ b/src/backend/base/langflow/components/Notion/page_content_viewer.py @@ -0,0 +1,91 @@ +import requests +from pydantic import BaseModel, Field +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool + + +class NotionPageContent(LCToolComponent): + display_name = "Page Content Viewer " + description = "Retrieve the content of a Notion page as plain text." + documentation = "https://docs.langflow.org/integrations/notion/page-content-viewer" + icon = "NotionDirectoryLoader" + + inputs = [ + StrInput( + name="page_id", + display_name="Page ID", + info="The ID of the Notion page to retrieve.", + ), + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + ] + + class NotionPageContentSchema(BaseModel): + page_id: str = Field(..., description="The ID of the Notion page to retrieve.") + + def run_model(self) -> Data: + result = self._retrieve_page_content(self.page_id) + if isinstance(result, str) and result.startswith("Error:"): + # An error occurred, return it as text + return Data(text=result) + else: + # Success, return the content + return Data(text=result, data={"content": result}) + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="notion_page_content", + description="Retrieve the content of a Notion page as plain text.", + func=self._retrieve_page_content, + args_schema=self.NotionPageContentSchema, + ) + + def _retrieve_page_content(self, page_id: str) -> str: + blocks_url = f"https://api.notion.com/v1/blocks/{page_id}/children?page_size=100" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Notion-Version": "2022-06-28", + } + try: + blocks_response = requests.get(blocks_url, headers=headers) + blocks_response.raise_for_status() + blocks_data = blocks_response.json() + return self.parse_blocks(blocks_data.get("results", [])) + except requests.exceptions.RequestException as e: + error_message = f"Error: Failed to retrieve Notion page content. {str(e)}" + if hasattr(e, "response") and e.response is not None: + error_message += f" Status code: {e.response.status_code}, Response: {e.response.text}" + return error_message + except Exception as e: + return f"Error: An unexpected error occurred while retrieving Notion page content. {str(e)}" + + def parse_blocks(self, blocks: list) -> str: + content = "" + for block in blocks: + block_type = block.get("type") + if block_type in ["paragraph", "heading_1", "heading_2", "heading_3", "quote"]: + content += self.parse_rich_text(block[block_type].get("rich_text", [])) + "\n\n" + elif block_type in ["bulleted_list_item", "numbered_list_item"]: + content += self.parse_rich_text(block[block_type].get("rich_text", [])) + "\n" + elif block_type == "to_do": + content += self.parse_rich_text(block["to_do"].get("rich_text", [])) + "\n" + elif block_type == "code": + content += self.parse_rich_text(block["code"].get("rich_text", [])) + "\n\n" + elif block_type == "image": + content += f"[Image: {block['image'].get('external', {}).get('url', 'No URL')}]\n\n" + elif block_type == "divider": + content += "---\n\n" + return content.strip() + + def parse_rich_text(self, rich_text: list) -> str: + return "".join(segment.get("plain_text", "") for segment in rich_text) + + def __call__(self, *args, **kwargs): + return self._retrieve_page_content(*args, **kwargs) diff --git a/src/backend/base/langflow/components/Notion/search.py b/src/backend/base/langflow/components/Notion/search.py new file mode 100644 index 000000000000..cdb30d7f67c1 --- /dev/null +++ b/src/backend/base/langflow/components/Notion/search.py @@ -0,0 +1,109 @@ +import requests +from typing import Dict, Any, List +from pydantic import BaseModel, Field + +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput, DropdownInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool + + +class NotionSearch(LCToolComponent): + display_name: str = "Search " + description: str = "Searches all pages and databases that have been shared with an integration." + documentation: str = "https://docs.langflow.org/integrations/notion/search" + icon = "NotionDirectoryLoader" + + inputs = [ + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + StrInput( + name="query", + display_name="Search Query", + info="The text that the API compares page and database titles against.", + ), + DropdownInput( + name="filter_value", + display_name="Filter Type", + info="Limits the results to either only pages or only databases.", + options=["page", "database"], + value="page", + ), + DropdownInput( + name="sort_direction", + display_name="Sort Direction", + info="The direction to sort the results.", + options=["ascending", "descending"], + value="descending", + ), + ] + + class NotionSearchSchema(BaseModel): + query: str = Field(..., description="The search query text.") + filter_value: str = Field(default="page", description="Filter type: 'page' or 'database'.") + sort_direction: str = Field(default="descending", description="Sort direction: 'ascending' or 'descending'.") + + def run_model(self) -> List[Data]: + results = self._search_notion(self.query, self.filter_value, self.sort_direction) + records = [] + combined_text = f"Results found: {len(results)}\n\n" + + for result in results: + result_data = { + "id": result["id"], + "type": result["object"], + "last_edited_time": result["last_edited_time"], + } + + if result["object"] == "page": + result_data["title_or_url"] = result["url"] + text = f"id: {result['id']}\ntitle_or_url: {result['url']}\n" + elif result["object"] == "database": + if "title" in result and isinstance(result["title"], list) and len(result["title"]) > 0: + result_data["title_or_url"] = result["title"][0]["plain_text"] + text = f"id: {result['id']}\ntitle_or_url: {result['title'][0]['plain_text']}\n" + else: + result_data["title_or_url"] = "N/A" + text = f"id: {result['id']}\ntitle_or_url: N/A\n" + + text += f"type: {result['object']}\nlast_edited_time: {result['last_edited_time']}\n\n" + combined_text += text + records.append(Data(text=text, data=result_data)) + + self.status = records + return records + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="notion_search", + description="Search Notion pages and databases. Input should include the search query and optionally filter type and sort direction.", + func=self._search_notion, + args_schema=self.NotionSearchSchema, + ) + + def _search_notion( + self, query: str, filter_value: str = "page", sort_direction: str = "descending" + ) -> List[Dict[str, Any]]: + url = "https://api.notion.com/v1/search" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + } + + data = { + "query": query, + "filter": {"value": filter_value, "property": "object"}, + "sort": {"direction": sort_direction, "timestamp": "last_edited_time"}, + } + + response = requests.post(url, headers=headers, json=data) + response.raise_for_status() + + results = response.json() + return results["results"] diff --git a/src/backend/base/langflow/components/Notion/update_page_property.py b/src/backend/base/langflow/components/Notion/update_page_property.py new file mode 100644 index 000000000000..9853381fb3d8 --- /dev/null +++ b/src/backend/base/langflow/components/Notion/update_page_property.py @@ -0,0 +1,111 @@ +import json +import requests +from typing import Dict, Any, Union +from pydantic import BaseModel, Field +from langflow.base.langchain_utilities.model import LCToolComponent +from langflow.inputs import SecretStrInput, StrInput, MultilineInput +from langflow.schema import Data +from langflow.field_typing import Tool +from langchain.tools import StructuredTool +from loguru import logger + + +class NotionPageUpdate(LCToolComponent): + display_name: str = "Update Page Property " + description: str = "Update the properties of a Notion page." + documentation: str = "https://docs.langflow.org/integrations/notion/page-update" + icon = "NotionDirectoryLoader" + + inputs = [ + StrInput( + name="page_id", + display_name="Page ID", + info="The ID of the Notion page to update.", + ), + MultilineInput( + name="properties", + display_name="Properties", + info="The properties to update on the page (as a JSON string or a dictionary).", + ), + SecretStrInput( + name="notion_secret", + display_name="Notion Secret", + info="The Notion integration token.", + required=True, + ), + ] + + class NotionPageUpdateSchema(BaseModel): + page_id: str = Field(..., description="The ID of the Notion page to update.") + properties: Union[str, Dict[str, Any]] = Field( + ..., description="The properties to update on the page (as a JSON string or a dictionary)." + ) + + def run_model(self) -> Data: + result = self._update_notion_page(self.page_id, self.properties) + if isinstance(result, str): + # An error occurred, return it as text + return Data(text=result) + else: + # Success, return the updated page data + output = "Updated page properties:\n" + for prop_name, prop_value in result.get("properties", {}).items(): + output += f"{prop_name}: {prop_value}\n" + return Data(text=output, data=result) + + def build_tool(self) -> Tool: + return StructuredTool.from_function( + name="update_notion_page", + description="Update the properties of a Notion page. IMPORTANT: Use the tool to check the Database properties for more details before using this tool.", + func=self._update_notion_page, + args_schema=self.NotionPageUpdateSchema, + ) + + def _update_notion_page(self, page_id: str, properties: Union[str, Dict[str, Any]]) -> Union[Dict[str, Any], str]: + url = f"https://api.notion.com/v1/pages/{page_id}" + headers = { + "Authorization": f"Bearer {self.notion_secret}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", # Use the latest supported version + } + + # Parse properties if it's a string + if isinstance(properties, str): + try: + parsed_properties = json.loads(properties) + except json.JSONDecodeError as e: + error_message = f"Invalid JSON format for properties: {str(e)}" + logger.error(error_message) + return error_message + + else: + parsed_properties = properties + + data = {"properties": parsed_properties} + + try: + logger.info(f"Sending request to Notion API: URL: {url}, Data: {json.dumps(data)}") + response = requests.patch(url, headers=headers, json=data) + response.raise_for_status() + updated_page = response.json() + + logger.info(f"Successfully updated Notion page. Response: {json.dumps(updated_page)}") + return updated_page + except requests.exceptions.HTTPError as e: + error_message = f"HTTP Error occurred: {str(e)}" + if e.response is not None: + error_message += f"\nStatus code: {e.response.status_code}" + error_message += f"\nResponse body: {e.response.text}" + logger.error(error_message) + return error_message + except requests.exceptions.RequestException as e: + error_message = f"An error occurred while making the request: {str(e)}" + logger.error(error_message) + return error_message + except Exception as e: + error_message = f"An unexpected error occurred: {str(e)}" + logger.error(error_message) + return error_message + + def __call__(self, *args, **kwargs): + return self._update_notion_page(*args, **kwargs) diff --git a/src/backend/base/poetry.lock b/src/backend/base/poetry.lock index ad710ae11a86..89493d0c31bd 100644 --- a/src/backend/base/poetry.lock +++ b/src/backend/base/poetry.lock @@ -6315,6 +6315,17 @@ files = [ {file = "types_google_cloud_ndb-2.3.0.20240813-py3-none-any.whl", hash = "sha256:79404e04e97324d0b6466f297e92e734a38fb9cd064c2f3816820311bc6c3f57"}, ] +[[package]] +name = "types-markdown" +version = "3.7.0.20240822" +description = "Typing stubs for Markdown" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-Markdown-3.7.0.20240822.tar.gz", hash = "sha256:183557c9f4f865bdefd8f5f96a38145c31819271cde111d35557c3bd2069e78d"}, + {file = "types_Markdown-3.7.0.20240822-py3-none-any.whl", hash = "sha256:bec91c410aaf2470ffdb103e38438fbcc53689b00133f19e64869eb138432ad7"}, +] + [[package]] name = "types-passlib" version = "1.7.7.20240327" @@ -7049,4 +7060,4 @@ local = [] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "6835a0ed4266b0aae88f40359616174b45cd79515c1d726935060bbb18a106ce" +content-hash = "075141462d7ef6e4aff0d55e5fe902e46faec28139556804f1dc3c7ee011d5c9" diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index 5d768d78df88..a93704e5ab81 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -119,6 +119,7 @@ dictdiffer = "^0.9.0" pytest-split = "^0.9.0" devtools = "^0.12.2" pytest-flakefinder = "^1.1.0" +types-markdown = "^3.7.0.20240822" [tool.pytest.ini_options] diff --git a/src/frontend/feature-config.json b/src/frontend/feature-config.json index d5a3a37b82e2..04884cbc8b86 100644 --- a/src/frontend/feature-config.json +++ b/src/frontend/feature-config.json @@ -4,5 +4,6 @@ "ENABLE_LANGFLOW_STORE": true, "ENABLE_PROFILE_ICONS": true, "ENABLE_SOCIAL_LINKS": true, - "ENABLE_BRANDING": true + "ENABLE_BRANDING": true, + "ENABLE_MVPS": false } \ No newline at end of file diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 5e7d5b18c9ea..275754c8c9c5 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -725,6 +725,8 @@ export const PRIORITY_SIDEBAR_ORDER = [ "embeddings", ]; +export const BUNDLES_SIDEBAR_FOLDER_NAMES = ["notion"]; + export const AUTHORIZED_DUPLICATE_REQUESTS = [ "/health", "/flows", diff --git a/src/frontend/src/pages/FlowPage/components/extraSidebarComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/extraSidebarComponent/index.tsx index b16d7cc733cf..4e0b3b8a11e3 100644 --- a/src/frontend/src/pages/FlowPage/components/extraSidebarComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/extraSidebarComponent/index.tsx @@ -1,3 +1,4 @@ +import FeatureFlags from "@/../feature-config.json"; import { cloneDeep } from "lodash"; import { LinkIcon, SparklesIcon } from "lucide-react"; import { Fragment, useEffect, useState } from "react"; @@ -5,7 +6,10 @@ import IconComponent from "../../../../components/genericIconComponent"; import ShadTooltip from "../../../../components/shadTooltipComponent"; import { Input } from "../../../../components/ui/input"; import { Separator } from "../../../../components/ui/separator"; -import { PRIORITY_SIDEBAR_ORDER } from "../../../../constants/constants"; +import { + BUNDLES_SIDEBAR_FOLDER_NAMES, + PRIORITY_SIDEBAR_ORDER, +} from "../../../../constants/constants"; import useAlertStore from "../../../../stores/alertStore"; import useFlowStore from "../../../../stores/flowStore"; import { useTypesStore } from "../../../../stores/typesStore"; @@ -408,6 +412,132 @@ export default function ExtraSidebar(): JSX.Element { ), )} + {FeatureFlags.ENABLE_MVPS && ( + <> + + + + {Object.keys(dataFilter) + .sort(sortKeys) + .filter((x) => BUNDLES_SIDEBAR_FOLDER_NAMES.includes(x)) + .map((SBSectionName: keyof APIObjectType, index) => + Object.keys(dataFilter[SBSectionName]).length > 0 ? ( + + +
+ {Object.keys(dataFilter[SBSectionName]) + .sort((a, b) => + sensitiveSort( + dataFilter[SBSectionName][a].display_name, + dataFilter[SBSectionName][b].display_name, + ), + ) + .map((SBItemName: string, index) => ( + + + onDragStart(event, { + //split type to remove type in nodes saved with same name removing it's + type: removeCountFromString(SBItemName), + node: dataFilter[SBSectionName][ + SBItemName + ], + }) + } + color={nodeColors[SBSectionName]} + itemName={SBItemName} + //convert error to boolean + error={ + !!dataFilter[SBSectionName][SBItemName] + .error + } + display_name={ + dataFilter[SBSectionName][SBItemName] + .display_name + } + official={ + dataFilter[SBSectionName][SBItemName] + .official === false + ? false + : true + } + /> + + ))} +
+
+ {index === + Object.keys(dataFilter).length - + PRIORITY_SIDEBAR_ORDER.length + + 1 && ( + <> + +
+ {/* BUG ON THIS ICON */} + + + + Discover More + +
+
+
+ +
+
+
+ + )} +
+ ) : ( +
+ ), + )} +
+ + )} ); diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index f1607acb55f0..d51143d2057b 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -283,6 +283,7 @@ export const nodeColors: { [char: string]: string } = { textsplitters: "#B47CB5", toolkits: "#DB2C2C", wrappers: "#E6277A", + notion: "#000000", helpers: "#31A3CC", prototypes: "#E6277A", astra_assistants: "#272541", @@ -309,6 +310,7 @@ export const nodeNames: { [char: string]: string } = { data: "Data", prompts: "Prompts", models: "Models", + notion: "Notion", model_specs: "Model Specs", chains: "Chains", agents: "Agents", @@ -403,6 +405,7 @@ export const nodeIconsLucide: iconsType = { MongoDBAtlasVectorSearch: MongoDBIcon, MongoDB: MongoDBIcon, MongoDBChatMessageHistory: MongoDBIcon, + notion: NotionIcon, NotionDirectoryLoader: NotionIcon, NVIDIA: NvidiaIcon, ChatOpenAI: OpenAiIcon,